Skip to main content

shell_quote/
bash.rs

1#![cfg(feature = "bash")]
2
3use crate::{Quotable, QuoteInto};
4
5/// Quote byte strings for use with Bash, the GNU Bourne-Again Shell.
6///
7/// # Compatibility
8///
9/// Quoted/escaped strings produced by [`Bash`] work in both Bash and Z Shell.
10///
11/// # ⚠️ Warning regarding `NUL`
12///
13/// It is _possible_ to encode NUL in a Bash string, but Bash appears to then
14/// truncate the rest of the string after that point **or** sometimes it filters
15/// the NUL out. It's not yet clear to me when/why each behaviour is chosen.
16///
17/// If you're quoting UTF-8 content this may not be a problem since there is
18/// only one code point – the null character itself – that will ever produce a
19/// NUL byte. To avoid this problem entirely, consider using [Modified
20/// UTF-8][modified-utf-8] so that the NUL byte can never appear in a valid byte
21/// stream.
22///
23/// [modified-utf-8]: https://en.wikipedia.org/wiki/UTF-8#Modified_UTF-8
24///
25/// # ⚠️ Warning regarding `%` and job control in Bash
26///
27/// A word beginning with `%` **cannot be made safe** to use as a **command
28/// name** in Bash, by this crate or by any other means. In command position
29/// Bash reads such a word as a [job specification][job-control]: `%1` on its
30/// own is shorthand for `fg %1`.
31///
32/// Quoting does not prevent it, because Bash tests the word's _value_, after
33/// expansion and quote removal. Every one of these runs `fg`:
34///
35/// ```bash
36/// %1      '%1'      "%1"      $'%1'      \%1      ""%1
37/// ```
38///
39/// Nor is it conditional on job control being enabled. Bash rewrites the word
40/// either way; all that changes is which complaint you get, `fg: no job
41/// control` or `fg: %1: no such job`. It behaves this same way with job control
42/// off, with `set -m`, with `set +m`, under `--posix`, and in Bash 3.2 as
43/// shipped by Apple. Put an executable named `%1` on `PATH` and Bash will not
44/// run it, though it will run it happily by an explicit path.
45///
46/// It is the first character that matters, and only the first: `%1`, `%foo`,
47/// `%`, `%%`, `%+`, `%-`, and `%?x` are all intercepted, while `a%1` is an
48/// ordinary command name.
49///
50/// Z Shell differs: it tests the literal token, so there quoting _does_ prevent
51/// it, and all of the above but the first are ordinary command names. This
52/// crate quotes `%` unconditionally, which is what Z Shell needs, and is at
53/// worst harmless in Bash.
54///
55/// Keep it in proportion, though: this bites only in command position. As an
56/// **argument** – which is what this crate is mostly used for – `%` is an
57/// ordinary character in both shells, and `printf %s $'%1'` prints `%1` as you
58/// would expect.
59///
60/// If you are interpolating an untrusted string into command position, quoting
61/// is not sufficient protection in any case; prefer to invoke a known command
62/// and pass the string as an argument.
63///
64/// [job-control]:
65///     https://www.gnu.org/software/bash/manual/html_node/Job-Control-Basics.html
66///
67/// # Notes
68///
69/// From bash(1):
70///
71///   Words of the form $'string' are treated specially. The word expands to
72///   string, with backslash-escaped characters replaced as specified by the
73///   ANSI C standard. Backslash escape sequences, if present, are decoded as
74///   follows:
75///
76///   ```text
77///   \a     alert (bell)
78///   \b     backspace
79///   \e     an escape character
80///   \f     form feed
81///   \n     new line
82///   \r     carriage return
83///   \t     horizontal tab
84///   \v     vertical tab
85///   \\     backslash
86///   \'     single quote
87///   \nnn   the eight-bit character whose value is the
88///          octal value nnn (one to three digits)
89///   \xHH   the eight-bit character whose value is the
90///          hexadecimal value HH (one or two hex digits)
91///   \cx    a control-x character
92///   ```
93///
94/// Bash allows, in newer versions, for non-ASCII Unicode characters with
95/// `\uHHHH` and `\UXXXXXXXX` syntax inside these [ANSI C quoted
96/// strings][ansi-c-quoting], but we avoid this and work only with bytes. Part
97/// of the problem is that it's not clear how Bash then works with these
98/// strings. Does it encode these characters into bytes according to the user's
99/// current locale? Are strings in Bash now natively Unicode?
100///
101/// For now it's up to the caller to figure out encoding. A significant use case
102/// for this code is to quote filenames into scripts, and on *nix variants I
103/// understand that filenames are essentially arrays of bytes, even if the OS
104/// adds some normalisation and case-insensitivity on top.
105///
106/// [ansi-c-quoting]:
107///     https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html
108///
109#[derive(Debug, Clone, Copy)]
110pub struct Bash;
111
112// ----------------------------------------------------------------------------
113
114impl QuoteInto<Vec<u8>> for Bash {
115    fn quote_into<'q, S: Into<Quotable<'q>>>(s: S, out: &mut Vec<u8>) {
116        Self::quote_into_vec(s, out);
117    }
118}
119
120impl QuoteInto<String> for Bash {
121    fn quote_into<'q, S: Into<Quotable<'q>>>(s: S, out: &mut String) {
122        Self::quote_into_vec(s, unsafe { out.as_mut_vec() })
123    }
124}
125
126#[cfg(unix)]
127impl QuoteInto<std::ffi::OsString> for Bash {
128    fn quote_into<'q, S: Into<Quotable<'q>>>(s: S, out: &mut std::ffi::OsString) {
129        use std::os::unix::ffi::OsStringExt;
130        let s = Self::quote_vec(s);
131        let s = std::ffi::OsString::from_vec(s);
132        out.push(s);
133    }
134}
135
136#[cfg(feature = "bstr")]
137impl QuoteInto<bstr::BString> for Bash {
138    fn quote_into<'q, S: Into<Quotable<'q>>>(s: S, out: &mut bstr::BString) {
139        let s = Self::quote_vec(s);
140        out.extend(s);
141    }
142}
143
144// ----------------------------------------------------------------------------
145
146impl Bash {
147    /// Quote a string of bytes into a new `Vec<u8>`.
148    ///
149    /// This will return one of the following:
150    /// - The string as-is, if no escaping is necessary.
151    /// - An [ANSI-C escaped string][ansi-c-quoting], like `$'foo\nbar'`.
152    ///
153    /// See [`quote_into_vec`][`Self::quote_into_vec`] for a variant that
154    /// extends an existing `Vec` instead of allocating a new one.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// # use shell_quote::Bash;
160    /// assert_eq!(Bash::quote_vec("foobar"), b"foobar");
161    /// assert_eq!(Bash::quote_vec("foo bar"), b"$'foo bar'");
162    /// ```
163    ///
164    /// [ansi-c-quoting]:
165    ///     https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html
166    ///
167    pub fn quote_vec<'a, S: Into<Quotable<'a>>>(s: S) -> Vec<u8> {
168        // Here, previously, in the `Escape` cases, an optimisation
169        // precalculated the required capacity of the output `Vec` to avoid
170        // reallocations later on, but benchmarks showed that it was slower. It
171        // _may_ have lowered maximum RAM required, but that was not measured.
172        match s.into() {
173            Quotable::Bytes(bytes) => match bytes::escape_prepare(bytes) {
174                bytes::Prepared::Empty => vec![b'\'', b'\''],
175                bytes::Prepared::Inert => bytes.into(),
176                bytes::Prepared::Escape(esc) => {
177                    let mut sout = Vec::new();
178                    bytes::escape_chars(esc, &mut sout);
179                    sout
180                }
181            },
182            Quotable::Text(text) => match text::escape_prepare(text) {
183                text::Prepared::Empty => vec![b'\'', b'\''],
184                text::Prepared::Inert => text.into(),
185                text::Prepared::Escape(esc) => {
186                    let mut sout = Vec::new();
187                    text::escape_chars(esc, &mut sout);
188                    sout
189                }
190            },
191        }
192    }
193
194    /// Quote a string of bytes into an existing `Vec<u8>`.
195    ///
196    /// See [`quote_vec`][`Self::quote_vec`] for more details.
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// # use shell_quote::Bash;
202    /// let mut buf = Vec::with_capacity(128);
203    /// Bash::quote_into_vec("foobar", &mut buf);
204    /// buf.push(b' ');  // Add a space.
205    /// Bash::quote_into_vec("foo bar", &mut buf);
206    /// assert_eq!(buf, b"foobar $'foo bar'");
207    /// ```
208    ///
209    pub fn quote_into_vec<'a, S: Into<Quotable<'a>>>(s: S, sout: &mut Vec<u8>) {
210        // Here, previously, in the `Escape` cases, an optimisation
211        // precalculated the required capacity of the output `Vec` to avoid
212        // reallocations later on, but benchmarks showed that it was slower. It
213        // _may_ have lowered maximum RAM required, but that was not measured.
214        match s.into() {
215            Quotable::Bytes(bytes) => match bytes::escape_prepare(bytes) {
216                bytes::Prepared::Empty => sout.extend(b"''"),
217                bytes::Prepared::Inert => sout.extend(bytes),
218                bytes::Prepared::Escape(esc) => bytes::escape_chars(esc, sout),
219            },
220            Quotable::Text(text) => match text::escape_prepare(text) {
221                text::Prepared::Empty => sout.extend(b"''"),
222                text::Prepared::Inert => sout.extend(text.as_bytes()),
223                text::Prepared::Escape(esc) => text::escape_chars(esc, sout),
224            },
225        }
226    }
227}
228
229// ----------------------------------------------------------------------------
230
231mod bytes {
232    use super::u8_to_hex_escape;
233    use crate::ascii::Char;
234
235    pub enum Prepared {
236        Empty,
237        Inert,
238        Escape(Vec<Char>),
239    }
240
241    pub fn escape_prepare(sin: &[u8]) -> Prepared {
242        let esc: Vec<_> = sin.iter().map(Char::from).collect();
243        // An optimisation: if the string is not empty and contains only "safe"
244        // characters we can avoid further work.
245        if esc.is_empty() {
246            Prepared::Empty
247        } else if esc.iter().all(Char::is_inert) {
248            Prepared::Inert
249        } else {
250            Prepared::Escape(esc)
251        }
252    }
253
254    pub fn escape_chars(esc: Vec<Char>, sout: &mut Vec<u8>) {
255        // Push a Bash-style $'...' quoted string into `sout`.
256        sout.extend(b"$'");
257        for mode in esc {
258            use Char::*;
259            match mode {
260                Bell => sout.extend(b"\\a"),
261                Backspace => sout.extend(b"\\b"),
262                Escape => sout.extend(b"\\e"),
263                FormFeed => sout.extend(b"\\f"),
264                NewLine => sout.extend(b"\\n"),
265                CarriageReturn => sout.extend(b"\\r"),
266                HorizontalTab => sout.extend(b"\\t"),
267                VerticalTab => sout.extend(b"\\v"),
268                Control(ch) => sout.extend(&u8_to_hex_escape(ch)),
269                Backslash => sout.extend(b"\\\\"),
270                SingleQuote => sout.extend(b"\\'"),
271                DoubleQuote => sout.extend(b"\""),
272                Delete => sout.extend(b"\\x7F"),
273                PrintableInert(ch) => sout.push(ch),
274                Printable(ch) => sout.push(ch),
275                Extended(ch) => sout.extend(&u8_to_hex_escape(ch)),
276            }
277        }
278        sout.push(b'\'');
279    }
280}
281
282// ----------------------------------------------------------------------------
283
284mod text {
285    use super::u8_to_hex_escape;
286    use crate::utf8::Char;
287
288    pub enum Prepared {
289        Empty,
290        Inert,
291        Escape(Vec<Char>),
292    }
293
294    pub fn escape_prepare(sin: &str) -> Prepared {
295        let esc: Vec<_> = sin.chars().map(Char::from).collect();
296        // An optimisation: if the string is not empty and contains only "safe"
297        // characters we can avoid further work.
298        if esc.is_empty() {
299            Prepared::Empty
300        } else if esc.iter().all(Char::is_inert) {
301            Prepared::Inert
302        } else {
303            Prepared::Escape(esc)
304        }
305    }
306
307    pub fn escape_chars(esc: Vec<Char>, sout: &mut Vec<u8>) {
308        // Push a Bash-style $'...' quoted string into `sout`.
309        sout.extend(b"$'");
310        let buf = &mut [0u8; 4];
311        for mode in esc {
312            use Char::*;
313            match mode {
314                Bell => sout.extend(b"\\a"),
315                Backspace => sout.extend(b"\\b"),
316                Escape => sout.extend(b"\\e"),
317                FormFeed => sout.extend(b"\\f"),
318                NewLine => sout.extend(b"\\n"),
319                CarriageReturn => sout.extend(b"\\r"),
320                HorizontalTab => sout.extend(b"\\t"),
321                VerticalTab => sout.extend(b"\\v"),
322                Control(ch) => sout.extend(&u8_to_hex_escape(ch)),
323                Backslash => sout.extend(b"\\\\"),
324                SingleQuote => sout.extend(b"\\'"),
325                DoubleQuote => sout.extend(b"\""),
326                Delete => sout.extend(b"\\x7F"),
327                PrintableInert(ch) => sout.push(ch),
328                Printable(ch) => sout.push(ch),
329                Utf8(ch) => sout.extend(ch.encode_utf8(buf).as_bytes()),
330            }
331        }
332        sout.push(b'\'');
333    }
334}
335
336// ----------------------------------------------------------------------------
337
338/// Escape a byte as a 4-byte hex escape sequence.
339///
340/// The `\\xHH` format (backslash, a literal "x", two hex characters) is
341/// understood by many shells.
342#[inline]
343fn u8_to_hex_escape(ch: u8) -> [u8; 4] {
344    const HEX_DIGITS: &[u8] = b"0123456789ABCDEF";
345    [
346        b'\\',
347        b'x',
348        HEX_DIGITS[(ch >> 4) as usize],
349        HEX_DIGITS[(ch & 0xF) as usize],
350    ]
351}
352
353#[cfg(test)]
354#[test]
355fn test_u8_to_hex_escape() {
356    for ch in u8::MIN..=u8::MAX {
357        let expected = format!("\\x{ch:02X}");
358        let observed = u8_to_hex_escape(ch);
359        let observed = std::str::from_utf8(&observed).unwrap();
360        assert_eq!(observed, &expected);
361    }
362}