From 5df2434f045a73aab82c3cc21a772579b7f65c3e Mon Sep 17 00:00:00 2001 From: Gavin Panella Date: Fri, 24 Nov 2023 14:27:08 +0100 Subject: [PATCH] Improve UX, i.e. work with paths, OS strings, references, etc. --- .vscode/settings.json | 1 + Cargo.toml | 4 + README.md | 19 +++-- src/bash.rs | 22 ++--- src/lib.rs | 178 +++++++++++++++++++++++++++++++++++----- src/sh.rs | 22 ++--- tests/test_ux.rs | 185 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 379 insertions(+), 52 deletions(-) create mode 100644 tests/test_ux.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index 149687f..9bcd732 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cSpell.language": "en,en-GB", "cSpell.words": [ "bindir", + "bstr", "Clippy", "dtolnay", "execing", diff --git a/Cargo.toml b/Cargo.toml index eca39dc..21c3570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,11 @@ readme = "README.md" repository = "https://github.com/allenap/shell-quote" version = "0.4.0" +[features] +default = ["bstr"] + [dependencies] +bstr = { version = "1", optional = true } [dev-dependencies] criterion = { version = "^0.5.1", features = ["html_reports"] } diff --git a/README.md b/README.md index 849f072..2506227 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ some limitations and caveats. When quoting using raw bytes it can be convenient to call [`Bash`]'s and [`Sh`]'s associated functions directly: -``` +```rust use shell_quote::{Bash, Sh}; assert_eq!(Bash::quote("foobar"), b"foobar"); assert_eq!(Sh::quote("foobar"), b"foobar"); @@ -29,17 +29,20 @@ assert_eq!(Bash::quote("foo bar"), b"$'foo bar'"); assert_eq!(Sh::quote("foo bar"), b"'foo bar'"); ``` -It's also possible to use the extension trait [`QuoteExt`]: +It's also possible to use the extension trait [`QuoteRefExt`] which provides a +[`quoted`] function: -``` -use shell_quote::{Bash, Sh, QuoteExt}; -assert_eq!(Vec::quoted(Bash, "foo bar"), b"$'foo bar'"); -assert_eq!(Vec::quoted(Sh, "foo bar"), b"'foo bar'"); +```rust +use shell_quote::{Bash, Sh, QuoteRefExt}; +let quoted: Vec = "foo bar".quoted(Bash); +assert_eq!(quoted, b"$'foo bar'"); +let quoted: Vec = "foo bar".quoted(Sh); +assert_eq!(quoted, b"'foo bar'"); ``` -or, to construct something more elaborate: +Or the extension trait [`QuoteExt`] for pushing quoted strings into a buffer: -``` +```rust use shell_quote::{Bash, QuoteExt}; let mut script: String = "echo ".into(); script.push_quoted(Bash, "foo bar"); diff --git a/src/bash.rs b/src/bash.rs index b021025..0f05dcb 100644 --- a/src/bash.rs +++ b/src/bash.rs @@ -1,4 +1,4 @@ -use crate::{ascii::Char, quoter::QuoterSealed, Quoter}; +use crate::{ascii::Char, quoter::QuoterSealed, Quotable, Quoter}; /// Quote byte strings for use with Bash, the GNU Bourne-Again Shell. /// @@ -68,10 +68,10 @@ impl Quoter for Bash {} /// Expose [`Quoter`] implementation as default impl too, for convenience. impl QuoterSealed for Bash { - fn quote>(s: &S) -> Vec { + fn quote<'a, S: ?Sized + Into>>(s: S) -> Vec { Self::quote(s) } - fn quote_into>(s: &S, sout: &mut Vec) { + fn quote_into<'a, S: ?Sized + Into>>(s: S, sout: &mut Vec) { Self::quote_into(s, sout) } } @@ -97,11 +97,11 @@ impl Bash { /// [ansi-c-quoting]: /// https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html /// - pub fn quote>(s: &S) -> Vec { - let sin = s.as_ref(); - match escape_prepare(sin) { + pub fn quote<'a, S: ?Sized + Into>>(s: S) -> Vec { + let sin: Quotable<'a> = s.into(); + match escape_prepare(sin.bytes) { Prepared::Empty => vec![b'\'', b'\''], - Prepared::Inert => sin.into(), + Prepared::Inert => sin.bytes.into(), Prepared::Escape(esc) => { // This may be a pointless optimisation, but calculate the // memory needed to avoid reallocations as we construct the @@ -130,11 +130,11 @@ impl Bash { /// assert_eq!(buf, b"foobar $'foo bar'"); /// ``` /// - pub fn quote_into>(s: &S, sout: &mut Vec) { - let sin = s.as_ref(); - match escape_prepare(sin) { + pub fn quote_into<'a, S: ?Sized + Into>>(s: S, sout: &mut Vec) { + let sin: Quotable<'a> = s.into(); + match escape_prepare(sin.bytes) { Prepared::Empty => sout.extend(b"''"), - Prepared::Inert => sout.extend(sin), + Prepared::Inert => sout.extend(sin.bytes), Prepared::Escape(esc) => { // This may be a pointless optimisation, but calculate the // memory needed to avoid reallocations as we construct the diff --git a/src/lib.rs b/src/lib.rs index 0d999a3..1e4e9ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!("../README.md")] use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; mod ascii; mod bash; @@ -9,41 +10,37 @@ mod sh; pub use bash::Bash; pub use sh::Sh; -/// Extension trait for shell quoting byte slices (e.g. `&[u8]`, `&str`) into -/// byte container types like [`Vec`], [`String`] (and [`OsString`] on -/// Unix). +/// Extension trait for pushing shell quoted byte slices, e.g. `&[u8]`, [`&str`] +/// – anything that's [`Quotable`] – into byte container types like [`Vec`], +/// [`String`], [`OsString`] on Unix, and [`bstr::BString`] if it's enabled pub trait QuoteExt { - fn push_quoted>(&mut self, q: Q, s: &S); - fn quoted>(q: Q, s: &S) -> Self; + fn push_quoted<'a, Q: Quoter, S: ?Sized + Into>>(&mut self, q: Q, s: S); } impl QuoteExt for Vec { - fn push_quoted>(&mut self, _q: Q, s: &S) { + fn push_quoted<'a, Q: Quoter, S: ?Sized + Into>>(&mut self, _q: Q, s: S) { Q::quote_into(s, self); } - - fn quoted>(_q: Q, s: &S) -> Self { - Q::quote(s) - } } #[cfg(unix)] impl QuoteExt for OsString { - fn push_quoted>(&mut self, _q: Q, s: &S) { + fn push_quoted<'a, Q: Quoter, S: ?Sized + Into>>(&mut self, _q: Q, s: S) { use std::os::unix::ffi::OsStrExt; let quoted = Q::quote(s); self.push(OsStr::from_bytes("ed)) } +} - fn quoted>(_q: Q, s: &S) -> Self { - use std::os::unix::ffi::OsStringExt; - let quoted = Q::quote(s); - OsString::from_vec(quoted) +#[cfg(feature = "bstr")] +impl QuoteExt for bstr::BString { + fn push_quoted<'a, Q: Quoter, S: ?Sized + Into>>(&mut self, _q: Q, s: S) { + Q::quote_into(s, self) } } impl QuoteExt for String { - fn push_quoted>(&mut self, _q: Q, s: &S) { + fn push_quoted<'a, Q: Quoter, S: ?Sized + Into>>(&mut self, _q: Q, s: S) { let quoted = Q::quote(s); // SAFETY: `quoted` is valid UTF-8 (ASCII, in truth) because it was // generated by a `quote` implementation from this crate – @@ -51,9 +48,56 @@ impl QuoteExt for String { let quoted = unsafe { std::str::from_utf8_unchecked("ed) }; self.push_str(quoted); } +} - fn quoted>(_q: Q, s: &S) -> Self { - let quoted = Q::quote(s); +// ---------------------------------------------------------------------------- + +/// Extension trait for shell quoting many different owned and reference types, +/// e.g. `&[u8]`, [`&str`] – anything that's [`Quotable`] – into owned types +/// like [`Vec`], [`String`], [`OsString`] on Unix, and [`bstr::BString`] if +/// it's enabled. +pub trait QuoteRefExt { + fn quoted(self, q: Q) -> Output; +} + +impl<'a, S> QuoteRefExt> for S +where + S: ?Sized + Into>, +{ + fn quoted(self, _q: Q) -> Vec { + Q::quote(self) + } +} + +#[cfg(unix)] +impl<'a, S> QuoteRefExt for S +where + S: ?Sized + Into>, +{ + fn quoted(self, _q: Q) -> OsString { + use std::os::unix::ffi::OsStringExt; + let quoted = Q::quote(self); + OsString::from_vec(quoted) + } +} + +#[cfg(feature = "bstr")] +impl<'a, S> QuoteRefExt for S +where + S: ?Sized + Into>, +{ + fn quoted(self, _q: Q) -> bstr::BString { + let quoted = Q::quote(self); + bstr::BString::from(quoted) + } +} + +impl<'a, S> QuoteRefExt for S +where + S: ?Sized + Into>, +{ + fn quoted(self, _q: Q) -> String { + let quoted = Q::quote(self); // SAFETY: `quoted` is valid UTF-8 (ASCII, in truth) because it was // generated by a `quote` implementation from this crate – // enforced because `Quoter` is sealed. @@ -61,13 +105,15 @@ impl QuoteExt for String { } } +// ---------------------------------------------------------------------------- + pub(crate) mod quoter { pub trait QuoterSealed { - /// Quote/escape a string of bytes into a new `Vec`. - fn quote>(s: &S) -> Vec; + /// Quote/escape a string of bytes into a new [`Vec`]. + fn quote<'a, S: ?Sized + Into>>(s: S) -> Vec; - /// Quote/escape a string of bytes into an existing `Vec`. - fn quote_into>(s: &S, sout: &mut Vec); + /// Quote/escape a string of bytes into an existing [`Vec`]. + fn quote_into<'a, S: ?Sized + Into>>(s: S, sout: &mut Vec); } } @@ -78,3 +124,91 @@ pub(crate) mod quoter { /// that quoted bytes are valid UTF-8, and that is only possible if the /// implementation is known and tested in advance. pub trait Quoter: quoter::QuoterSealed {} + +// ---------------------------------------------------------------------------- + +/// A string of bytes that can be quoted/escaped. +/// +/// This is used by many methods in this crate as a generic `Into` +/// constraint. Why not accept [`AsRef<[u8]>`] instead? The ergonomics of that +/// approach were not so good. For example, quoting [`OsString`]/[`OsStr`] and +/// [`PathBuf`]/[`Path`] didn't work in a natural way. +pub struct Quotable<'a> { + pub(crate) bytes: &'a [u8], +} + +impl<'a> From<&'a [u8]> for Quotable<'a> { + fn from(source: &'a [u8]) -> Quotable<'a> { + Quotable { bytes: source } + } +} + +impl<'a, const N: usize> From<&'a [u8; N]> for Quotable<'a> { + fn from(source: &'a [u8; N]) -> Quotable<'a> { + Quotable { bytes: &source[..] } + } +} + +impl<'a> From<&'a Vec> for Quotable<'a> { + fn from(source: &'a Vec) -> Quotable<'a> { + Quotable { bytes: source } + } +} + +impl<'a> From<&'a str> for Quotable<'a> { + fn from(source: &'a str) -> Quotable<'a> { + source.as_bytes().into() + } +} + +impl<'a> From<&'a String> for Quotable<'a> { + fn from(source: &'a String) -> Quotable<'a> { + source.as_bytes().into() + } +} + +#[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() + } +} + +#[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() + } +} + +#[cfg(feature = "bstr")] +impl<'a> From<&'a bstr::BStr> for Quotable<'a> { + fn from(source: &'a bstr::BStr) -> Quotable<'a> { + let bytes: &[u8] = source.as_ref(); + bytes.into() + } +} + +#[cfg(feature = "bstr")] +impl<'a> From<&'a bstr::BString> for Quotable<'a> { + fn from(source: &'a bstr::BString) -> Quotable<'a> { + let bytes: &[u8] = source.as_ref(); + bytes.into() + } +} + +#[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/src/sh.rs b/src/sh.rs index c0afd97..7c5e201 100644 --- a/src/sh.rs +++ b/src/sh.rs @@ -1,4 +1,4 @@ -use crate::{ascii::Char, quoter::QuoterSealed, Quoter}; +use crate::{ascii::Char, quoter::QuoterSealed, Quotable, Quoter}; /// Quote byte strings for use with `/bin/sh`. /// @@ -53,10 +53,10 @@ impl Quoter for Sh {} /// Expose [`Quoter`] implementation as default impl too, for convenience. impl QuoterSealed for Sh { - fn quote>(s: &S) -> Vec { + fn quote<'a, S: ?Sized + Into>>(s: S) -> Vec { Self::quote(s) } - fn quote_into>(s: &S, sout: &mut Vec) { + fn quote_into<'a, S: ?Sized + Into>>(s: S, sout: &mut Vec) { Self::quote_into(s, sout) } } @@ -79,9 +79,9 @@ impl Sh { /// assert_eq!(Sh::quote("foo bar"), b"'foo bar'"); /// ``` /// - pub fn quote>(s: &S) -> Vec { - let sin = s.as_ref(); - if let Some(esc) = escape_prepare(sin) { + pub fn quote<'a, S: ?Sized + Into>>(s: S) -> Vec { + let sin: Quotable<'a> = s.into(); + if let Some(esc) = escape_prepare(sin.bytes) { // This may be a pointless optimisation, but calculate the memory // needed to avoid reallocations as we construct the output. Since // we know we're going to use single quotes, we also add 2 bytes. @@ -90,7 +90,7 @@ impl Sh { escape_chars(esc, &mut sout); // Do the work. sout } else { - sin.into() + sin.bytes.into() } } @@ -109,9 +109,9 @@ impl Sh { /// assert_eq!(buf, b"foobar 'foo bar'"); /// ``` /// - pub fn quote_into>(s: &S, sout: &mut Vec) { - let sin = s.as_ref(); - if let Some(esc) = escape_prepare(sin) { + pub fn quote_into<'a, S: ?Sized + Into>>(s: S, sout: &mut Vec) { + let sin: Quotable<'a> = s.into(); + if let Some(esc) = escape_prepare(sin.bytes) { // This may be a pointless optimisation, but calculate the memory // needed to avoid reallocations as we construct the output. Since // we know we're going to use single quotes, we also add 2 bytes. @@ -119,7 +119,7 @@ impl Sh { sout.reserve(size + 2); escape_chars(esc, sout); // Do the work. } else { - sout.extend(sin); + sout.extend(sin.bytes); } } } diff --git a/tests/test_ux.rs b/tests/test_ux.rs new file mode 100644 index 0000000..2eebe87 --- /dev/null +++ b/tests/test_ux.rs @@ -0,0 +1,185 @@ +use bstr::{BString, ByteSlice}; +use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + +use shell_quote::{Bash, Quotable, QuoteRefExt}; + +#[test] +fn test_quotable_conversions() { + let bytes = b"bytes"; + 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 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(&string); +} + +#[test] +fn test_quote_ref_ext_byte_array() { + let source = b"bytes!"; + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'bytes!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'bytes!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'bytes!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'bytes!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_byte_slice() { + let source = &b"bytes!"[..]; + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'bytes!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'bytes!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'bytes!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'bytes!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_vec() { + let source = Vec::from(b"vec!"); + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'vec!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'vec!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'vec!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'vec!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_os_string() { + let source = OsString::from("os-string!"); + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'os-string!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'os-string!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'os-string!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'os-string!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_os_str() { + let source = OsString::from("os-str!"); + let source = source.as_os_str(); + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'os-str!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'os-str!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'os-str!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'os-str!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_b_string() { + let source = bstr::BString::from(b"b-string!"); + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'b-string!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'b-string!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'b-string!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'b-string!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_b_str() { + let source = bstr::BString::from(b"b-str!"); + let source: &bstr::BStr = source.as_ref(); + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'b-str!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'b-str!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'b-str!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'b-str!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_path_buf() { + let source = std::path::PathBuf::from("path-buf!"); + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'path-buf!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'path-buf!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'path-buf!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'path-buf!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_path() { + let source = std::path::PathBuf::from("path!"); + let source = source.as_path(); + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'path!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'path!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'path!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'path!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_string() { + let source = "string!".to_owned(); + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'string!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'string!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'string!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'string!'"), quoted); +} + +#[test] +fn test_quote_ref_ext_str() { + let source = "str!"; + let quoted: Vec = source.quoted(Bash); + assert_eq!(Vec::from(b"$'str!'"), quoted); + let quoted: OsString = source.quoted(Bash); + assert_eq!(OsString::from_vec(b"$'str!'".into()), quoted); + let quoted: BString = source.quoted(Bash); + assert_eq!(BString::from(b"$'str!'"), quoted); + let quoted: String = source.quoted(Bash); + assert_eq!(String::from("$'str!'"), quoted); +}