diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47d6145..c63a549 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,16 +8,25 @@ on: jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable # Make sure we have all the target shells installed. - - run: sudo apt-get install -y bash dash fish zsh + - name: Install shells (Ubuntu) + if: runner.os == 'Linux' + run: sudo apt-get install -y bash dash fish zsh + - name: Install shells (Windows) + if: runner.os == 'Windows' + run: choco install git -y --no-progress # `git` includes `bash`. # Record shell versions. Almost unbelievably, it's not possible to get the # version of Dash from `dash` itself, so we skip it here. Since `sh` might # be `dash`, we also do not try to get its version. - - name: Shell versions + - name: Shell versions (Ubuntu) + if: runner.os == 'Linux' run: | for shell in sh dash; do for path in $(type -ap "$shell"); do @@ -30,6 +39,17 @@ jobs: printf "%10s @ %-30q: %s\n" "$shell" "$path" "$version" done done + - name: Shell versions (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + for shell in bash; do + if command -v "$shell" &> /dev/null; then + path=$(command -v "$shell") + version=$("$shell" --version | head -n 1) + printf "%10s @ %-30s: %s\n" "$shell" "$path" "$version" + fi + done # Test in debug mode first. - run: cargo test # Test in release mode too, to defend against, for example, use of diff --git a/Cargo.toml b/Cargo.toml index a19f692..3391520 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,9 @@ sh = [] bstr = { version = "1", optional = true } [dev-dependencies] -criterion = { version = "^0.5.1", features = ["html_reports"] } -lenient_semver = "0.4.2" -semver = "1.0.23" +criterion = { version = "0.7", features = ["html_reports"] } +lenient_semver = "0.4" +semver = "1.0" test-case = "3.3.1" [[bench]] diff --git a/Makefile b/Makefile index 36c3d10..a2f92b6 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,8 @@ .PHONY: test test: python3 test.py | bash -xe + +.PHONY: check +check: + cargo check --all-targets --all-features + cargo check --all-targets --all-features --target aarch64-pc-windows-msvc diff --git a/README.md b/README.md index 6025cc5..07e3970 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ It can take as input many different string and byte string types: - [`&str`] and [`String`] - [`&bstr::BStr`][`bstr::BStr`] and [`bstr::BString`] - [`&[u8]`][`slice`] and [`Vec`] -- [`&OsStr`][`OsStr`] and [`OsString`] (on UNIX) +- [`&OsStr`][`OsStr`] and [`OsString`] - [`&Path`][`Path`] and [`PathBuf`] and produce output as (or push into) the following types: @@ -57,7 +57,7 @@ and produce output as (or push into) the following types: - [`String`] (for shells that support it, i.e. not [`Sh`]/[`Dash`]) - [`bstr::BString`] - [`Vec`] -- [`OsString`] (on UNIX) +- [`OsString`] (except for [`Sh`]) Inspired by the Haskell [shell-escape][] package. diff --git a/benches/bash.rs b/benches/bash.rs index e0cf0c6..6474c33 100644 --- a/benches/bash.rs +++ b/benches/bash.rs @@ -1,4 +1,6 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion}; use shell_quote::Bash; diff --git a/benches/fish.rs b/benches/fish.rs index dc50d93..ae6612e 100644 --- a/benches/fish.rs +++ b/benches/fish.rs @@ -1,4 +1,6 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion}; use shell_quote::Fish; diff --git a/benches/sh.rs b/benches/sh.rs index 2ff96df..fb263ce 100644 --- a/benches/sh.rs +++ b/benches/sh.rs @@ -1,4 +1,6 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion}; use shell_quote::Sh; diff --git a/src/bash.rs b/src/bash.rs index 63ba917..9b7a82c 100644 --- a/src/bash.rs +++ b/src/bash.rs @@ -81,12 +81,11 @@ impl QuoteInto for Bash { } } -#[cfg(unix)] impl QuoteInto for Bash { fn quote_into<'q, S: Into>>(s: S, out: &mut std::ffi::OsString) { - use std::os::unix::ffi::OsStringExt; let s = Self::quote_vec(s); - let s = std::ffi::OsString::from_vec(s); + // SAFETY: `Bash::quote_vec` produces UTF-8. + let s = unsafe { std::ffi::OsString::from_encoded_bytes_unchecked(s) }; out.push(s); } } diff --git a/src/fish.rs b/src/fish.rs index 647cbcd..1212e20 100644 --- a/src/fish.rs +++ b/src/fish.rs @@ -53,12 +53,11 @@ impl QuoteInto for Fish { } } -#[cfg(unix)] impl QuoteInto for Fish { fn quote_into<'q, S: Into>>(s: S, out: &mut std::ffi::OsString) { - use std::os::unix::ffi::OsStringExt; let s = Self::quote_vec(s); - let s = std::ffi::OsString::from_vec(s); + // SAFETY: `Fish::quote_vec` produces UTF-8. + let s = unsafe { std::ffi::OsString::from_encoded_bytes_unchecked(s) }; out.push(s); } } diff --git a/src/lib.rs b/src/lib.rs index a1504ec..2588102 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,15 +36,17 @@ doc = include_str!("../README.md") )] -use std::ffi::{OsStr, OsString}; -use std::path::{Path, PathBuf}; - mod ascii; mod bash; mod fish; mod sh; mod utf8; +use std::{ + ffi::{OsStr, OsString}, + path::{Path, PathBuf}, +}; + #[cfg(feature = "bash")] pub use bash::Bash; #[cfg(feature = "fish")] @@ -87,7 +89,8 @@ impl, OUT: Default> Quote for T {} /// Extension trait for pushing shell quoted byte slices, e.g. `&[u8]`, [`&str`] /// – anything that's [`Quotable`] – into container types like [`Vec`], -/// [`String`], [`OsString`] on Unix, and [`bstr::BString`] if it's enabled. +/// [`String`], [`OsString`] where possible, and [`bstr::BString`] if it's +/// enabled. pub trait QuoteExt { fn push_quoted<'q, Q, S>(&mut self, _q: Q, s: S) where @@ -109,7 +112,7 @@ impl QuoteExt for T { /// Extension trait for shell quoting many different owned and reference types, /// e.g. `&[u8]`, [`&str`] – anything that's [`Quotable`] – into owned container -/// types like [`Vec`], [`String`], [`OsString`] on Unix, and +/// types like [`Vec`], [`String`], [`OsString`] where possible, and /// [`bstr::BString`] if it's enabled. pub trait QuoteRefExt { fn quoted>(self, q: Q) -> Output; @@ -176,19 +179,15 @@ impl<'a> From<&'a String> for Quotable<'a> { } } -#[cfg(unix)] impl<'a> From<&'a OsStr> for Quotable<'a> { fn from(source: &'a OsStr) -> Quotable<'a> { - use std::os::unix::ffi::OsStrExt; - source.as_bytes().into() + source.as_encoded_bytes().into() } } -#[cfg(unix)] impl<'a> From<&'a OsString> for Quotable<'a> { fn from(source: &'a OsString) -> Quotable<'a> { - use std::os::unix::ffi::OsStrExt; - source.as_bytes().into() + source.as_encoded_bytes().into() } } @@ -208,14 +207,12 @@ impl<'a> From<&'a bstr::BString> for Quotable<'a> { } } -#[cfg(unix)] impl<'a> From<&'a Path> for Quotable<'a> { fn from(source: &'a Path) -> Quotable<'a> { source.as_os_str().into() } } -#[cfg(unix)] impl<'a> From<&'a PathBuf> for Quotable<'a> { fn from(source: &'a PathBuf) -> Quotable<'a> { source.as_os_str().into() diff --git a/tests/test_bash.rs b/tests/test_bash.rs index f1f00f5..e0c0688 100644 --- a/tests/test_bash.rs +++ b/tests/test_bash.rs @@ -81,41 +81,36 @@ mod bash_impl { assert_eq!(buffer, b"$'-_=/,.+'"); } - #[cfg(unix)] #[test_matrix( (script_bytes, script_text), ("bash", "zsh") )] fn test_roundtrip(prepare: fn() -> (OsString, OsString), shell: &str) { - use std::os::unix::ffi::OsStringExt; let (input, script) = prepare(); for bin in find_bins(shell) { let output = invoke_shell(&bin, &script).unwrap(); - let result = OsString::from_vec(output.stdout); + let result = unsafe { OsString::from_encoded_bytes_unchecked(output.stdout) }; assert_eq!(result, input); } } - #[cfg(unix)] fn script_bytes() -> (OsString, OsString) { - use std::os::unix::ffi::{OsStrExt, OsStringExt}; // It doesn't seem possible to roundtrip NUL, probably because it is the // string terminator character in C. - let input: OsString = OsString::from_vec((1..=u8::MAX).collect()); + let input: OsString = + unsafe { OsString::from_encoded_bytes_unchecked((1..=u8::MAX).collect()) }; // NOTE: Do NOT use `echo` here; in most/all shells it interprets // escapes with no way to disable that behaviour (unlike the `echo` // builtin in Bash, for example, which accepts a `-E` flag). Using // `printf %s` seems to do the right thing in most shells, i.e. it does // not interpret the arguments in any way. let mut script = b"printf %s ".to_vec(); - Bash::quote_into_vec(input.as_bytes(), &mut script); - let script = OsString::from_vec(script); + Bash::quote_into_vec(input.as_encoded_bytes(), &mut script); + let script = unsafe { OsString::from_encoded_bytes_unchecked(script) }; (input, script) } - #[cfg(unix)] fn script_text() -> (OsString, OsString) { - use std::os::unix::ffi::OsStringExt; // NOTE: Do NOT use `echo` here; in most/all shells it interprets // escapes with no way to disable that behaviour (unlike the `echo` // builtin in Bash, for example, which accepts a `-E` flag). Using @@ -123,14 +118,12 @@ mod bash_impl { // not interpret the arguments in any way. let mut script = b"printf %s ".to_vec(); Bash::quote_into_vec(resources::UTF8_SAMPLE, &mut script); - let script = OsString::from_vec(script); + let script = unsafe { OsString::from_encoded_bytes_unchecked(script) }; (resources::UTF8_SAMPLE.into(), script) } - #[cfg(unix)] #[test_matrix(("bash", "zsh"))] fn test_roundtrip_utf8_full(shell: &str) { - use std::os::unix::ffi::OsStringExt; let utf8: Vec<_> = ('\x01'..=char::MAX).collect(); // Not including NUL. for bin in find_bins(shell) { // Chunk to avoid over-length arguments (see`getconf ARG_MAX`). @@ -138,9 +131,9 @@ mod bash_impl { let input: String = String::from_iter(chunk); let mut script = b"printf %s ".to_vec(); Bash::quote_into_vec(&input, &mut script); - let script = OsString::from_vec(script); + let script = unsafe { OsString::from_encoded_bytes_unchecked(script) }; let output = invoke_shell(&bin, &script).unwrap(); - let observed = OsString::from_vec(output.stdout); + let observed = unsafe { OsString::from_encoded_bytes_unchecked(output.stdout) }; assert_eq!(observed.into_string(), Ok(input)); } } @@ -160,12 +153,9 @@ mod bash_quote_ext { assert_eq!(string, "Hello, $'World, Bob, !@#$%^&*(){}[]'"); } - #[cfg(unix)] #[test] fn test_os_string_push_quoted_with_bash() { - use std::ffi::OsString; - - let mut buffer: OsString = "Hello, ".into(); + let mut buffer: std::ffi::OsString = "Hello, ".into(); buffer.push_quoted(Bash, "World, Bob, !@#$%^&*(){}[]"); let string = buffer.into_string().unwrap(); // -> test failures are more readable. assert_eq!(string, "Hello, $'World, Bob, !@#$%^&*(){}[]'"); diff --git a/tests/test_fish.rs b/tests/test_fish.rs index 4c0e027..5f02964 100644 --- a/tests/test_fish.rs +++ b/tests/test_fish.rs @@ -128,48 +128,41 @@ mod fish_impl { assert_eq!(buffer, b"-_'=/,.+'"); } - #[cfg(unix)] #[test_matrix((script_bytes, script_text))] fn test_roundtrip(prepare: fn() -> (OsString, OsString)) { - use std::os::unix::ffi::OsStringExt; let (input, script) = prepare(); // Test with every version of `fish` we find on `PATH`. for bin in find_bins("fish") { let output = invoke_shell(&bin, &script).unwrap(); - let result = OsString::from_vec(output.stdout); + let result = unsafe { OsString::from_encoded_bytes_unchecked(output.stdout) }; assert_eq!(result, input); } } - #[cfg(unix)] fn script_bytes() -> (OsString, OsString) { - use std::os::unix::ffi::{OsStrExt, OsStringExt}; // It doesn't seem possible to roundtrip NUL, probably because it is the // string terminator character in C. - let input: OsString = OsString::from_vec((1..=u8::MAX).collect()); + let input: OsString = + unsafe { OsString::from_encoded_bytes_unchecked((1..=u8::MAX).collect()) }; // Unlike many/most other shells, `echo` is safe here because backslash // escapes are _not_ interpreted by default. let mut script = b"echo -n -- ".to_vec(); - Fish::quote_into_vec(input.as_bytes(), &mut script); - let script = OsString::from_vec(script); + Fish::quote_into_vec(input.as_encoded_bytes(), &mut script); + let script = unsafe { OsString::from_encoded_bytes_unchecked(script) }; (input, script) } - #[cfg(unix)] fn script_text() -> (OsString, OsString) { - use std::os::unix::ffi::OsStringExt; // Unlike many/most other shells, `echo` is safe here because backslash // escapes are _not_ interpreted by default. let mut script = b"echo -n -- ".to_vec(); Fish::quote_into_vec(resources::UTF8_SAMPLE, &mut script); - let script = OsString::from_vec(script); + let script = unsafe { OsString::from_encoded_bytes_unchecked(script) }; (resources::UTF8_SAMPLE.into(), script) } - #[cfg(unix)] #[test] fn test_roundtrip_utf8_full() { - use std::os::unix::ffi::OsStringExt; let utf8: Vec<_> = ('\x01'..=char::MAX).collect(); // Not including NUL. for bin in find_bins("fish") { let version = super::fish_version(&bin); @@ -182,20 +175,18 @@ mod fish_impl { let input: String = String::from_iter(chunk); let mut script = b"printf %s ".to_vec(); Fish::quote_into_vec(&input, &mut script); - let script = OsString::from_vec(script); + let script = unsafe { OsString::from_encoded_bytes_unchecked(script) }; let output = invoke_shell(&bin, &script).unwrap(); - let observed = OsString::from_vec(output.stdout); + let observed = unsafe { OsString::from_encoded_bytes_unchecked(output.stdout) }; assert_eq!(observed.into_string(), Ok(input)); } } } - #[cfg(unix)] #[test] /// IIRC, this caught bugs not found by `test_roundtrip_utf8_full`, and it /// was much easier to figure out what the failures meant. For now it stays! fn test_roundtrip_utf8_full_char_by_char() { - use std::os::unix::ffi::OsStringExt; let utf8: Vec<_> = ('\x01'..=char::MAX).collect(); // Not including NUL. for bin in find_bins("fish") { let version = super::fish_version(&bin); @@ -205,14 +196,16 @@ mod fish_impl { } // Chunk to avoid over-length arguments (see`getconf ARG_MAX`). for chunk in utf8.chunks(2usize.pow(12)) { - let script = OsString::from_vec(chunk.iter().fold( - b"printf '%s\\0'".to_vec(), - |mut script, ch| { - script.push(b' '); - Fish::quote_into_vec(&ch.to_string(), &mut script); - script - }, - )); + let script = unsafe { + OsString::from_encoded_bytes_unchecked(chunk.iter().fold( + b"printf '%s\\0'".to_vec(), + |mut script, ch| { + script.push(b' '); + Fish::quote_into_vec(&ch.to_string(), &mut script); + script + }, + )) + }; let output = invoke_shell(&bin, &script).unwrap(); let observed = output.stdout.split(|ch| *ch == 0).filter(|s| !s.is_empty()); @@ -242,12 +235,9 @@ mod fish_quote_ext { assert_eq!(string, "Hello, World,' Bob, !@#$%^&*(){}[]'"); } - #[cfg(unix)] #[test] fn test_os_string_push_quoted_with_fish() { - use std::ffi::OsString; - - let mut buffer: OsString = "Hello, ".into(); + let mut buffer: std::ffi::OsString = "Hello, ".into(); buffer.push_quoted(Fish, "World, Bob, !@#$%^&*(){}[]"); let string = buffer.into_string().unwrap(); // -> test failures are more readable. assert_eq!(string, "Hello, World,' Bob, !@#$%^&*(){}[]'"); diff --git a/tests/test_ux.rs b/tests/test_ux.rs index 646fed6..717af42 100644 --- a/tests/test_ux.rs +++ b/tests/test_ux.rs @@ -1,9 +1,24 @@ -#![cfg(all(unix, feature = "bash", feature = "bstr"))] +#![cfg(all(any(unix, windows), feature = "bash", feature = "bstr"))] use bstr::{BString, ByteSlice}; -use std::{ffi::OsString, os::unix::ffi::OsStringExt}; +use std::ffi::OsString; -use shell_quote::{Bash, Quotable, QuoteRefExt}; +#[cfg(unix)] +use std::os::unix::ffi::OsStringExt; + +use shell_quote::Quotable; + +#[cfg(unix)] +use shell_quote::{Bash, QuoteRefExt}; + +// ----------------------------------------------------------------------------- + +/// Convert `T` into a `Quotable`. +fn into_quotable<'a, T: Into>>(source: T) -> Quotable<'a> { + source.into() +} + +// ----------------------------------------------------------------------------- #[test] fn test_quotable_conversions() { @@ -11,35 +26,24 @@ fn test_quotable_conversions() { let byte_slice = &b"bytes"[..]; let vec = Vec::from(b"vec"); let os_string = OsString::from("os-string"); - let b_string = bstr::BString::from(b"b-string"); + let b_string = BString::from(b"b-string"); let path_buf = std::path::PathBuf::from("/path/[to]/file"); let string = "string".to_owned(); - let _: Quotable = bytes.into(); - let _: Quotable = byte_slice.into(); - let _: Quotable = (&vec).into(); - let _: Quotable = (&os_string).into(); - let _: Quotable = os_string.as_os_str().into(); - let _: Quotable = (&b_string).into(); - let _: Quotable = b_string.as_bstr().into(); - let _: Quotable = (&path_buf).into(); - let _: Quotable = path_buf.as_path().into(); - let _: Quotable = (&string).into(); - let _: Quotable = string.as_str().into(); - - fn into_quotable<'a, T: Into>>(source: T) -> Quotable<'a> { - source.into() - } - into_quotable(bytes); into_quotable(byte_slice); into_quotable(&vec); - into_quotable(&os_string); into_quotable(&b_string); - into_quotable(&path_buf); + into_quotable(b_string.as_bstr()); into_quotable(&string); + into_quotable(string.as_str()); + into_quotable(&os_string); + into_quotable(os_string.as_os_str()); + into_quotable(&path_buf); + into_quotable(path_buf.as_path()); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_byte_array() { let source = b"bytes!"; @@ -53,6 +57,7 @@ fn test_quote_ref_ext_byte_array() { assert_eq!(BString::from(b"$'bytes!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_byte_slice() { let source = &b"bytes!"[..]; @@ -66,6 +71,7 @@ fn test_quote_ref_ext_byte_slice() { assert_eq!(BString::from(b"$'bytes!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_vec() { let source = Vec::from(b"vec!"); @@ -79,6 +85,7 @@ fn test_quote_ref_ext_vec() { assert_eq!(BString::from(b"$'vec!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_os_string() { let source = OsString::from("os-string!"); @@ -92,6 +99,7 @@ fn test_quote_ref_ext_os_string() { assert_eq!(BString::from(b"$'os-string!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_os_str() { let source = OsString::from("os-str!"); @@ -106,6 +114,7 @@ fn test_quote_ref_ext_os_str() { assert_eq!(BString::from(b"$'os-str!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_b_string() { let source = bstr::BString::from(b"b-string!"); @@ -119,6 +128,7 @@ fn test_quote_ref_ext_b_string() { assert_eq!(BString::from(b"$'b-string!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_b_str() { let source = bstr::BString::from(b"b-str!"); @@ -133,6 +143,7 @@ fn test_quote_ref_ext_b_str() { assert_eq!(BString::from(b"$'b-str!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_path_buf() { let source = std::path::PathBuf::from("path-buf!"); @@ -146,6 +157,7 @@ fn test_quote_ref_ext_path_buf() { assert_eq!(BString::from(b"$'path-buf!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_path() { let source = std::path::PathBuf::from("path!"); @@ -160,6 +172,7 @@ fn test_quote_ref_ext_path() { assert_eq!(BString::from(b"$'path!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_string() { let source = "string!".to_owned(); @@ -173,6 +186,7 @@ fn test_quote_ref_ext_string() { assert_eq!(BString::from(b"$'string!'"), quoted); } +#[cfg(unix)] #[test] fn test_quote_ref_ext_str() { let source = "str!";