Skip to main content

shrub_core/
escape.rs

1//! String escaping utilities
2
3use std::borrow::Cow;
4
5use itertools::Itertools;
6
7use crate::{error, int_utils};
8
9/// Escape expansion mode.
10#[derive(Clone, Copy)]
11pub enum EscapeExpansionMode {
12    /// echo builtin mode.
13    EchoBuiltin,
14    /// ANSI-C quotes.
15    AnsiCQuotes,
16}
17
18/// Expands backslash escapes in the provided string.
19///
20/// # Arguments
21///
22/// * `s` - The string to expand.
23/// * `mode` - The mode to use for expansion.
24///
25/// # Errors
26///
27/// Returns [`error::ErrorKind::IntParseError`] if an octal or hexadecimal
28/// escape digit sequence cannot be parsed into a value of its width.
29#[expect(clippy::too_many_lines)]
30pub fn expand_backslash_escapes(
31    s: &str,
32    mode: EscapeExpansionMode,
33) -> Result<(Vec<u8>, bool), error::Error> {
34    let mut result: Vec<u8> = Vec::with_capacity(s.len());
35    let mut it = s.chars();
36    while let Some(c) = it.next() {
37        if c != '\\' {
38            // Not a backslash, add and move on.
39            result.append(c.to_string().into_bytes().as_mut());
40            continue;
41        }
42
43        let Some(escape_cmd) = it.next() else {
44            // Trailing backslash.
45            result.push(b'\\');
46            continue;
47        };
48
49        match escape_cmd {
50            'a' => result.push(b'\x07'),
51            'b' => result.push(b'\x08'),
52            'c' => {
53                match mode {
54                    EscapeExpansionMode::EchoBuiltin => {
55                        // Stop all additional output!
56                        return Ok((result, false));
57                    }
58                    EscapeExpansionMode::AnsiCQuotes => {
59                        if let Some(char_value) = it.next() {
60                            // Special case backslash. If it's immediately
61                            // followed by another
62                            // backslash, then we consume both; if not, we still
63                            // will use the
64                            // backslash character as the one to apply the
65                            // control transformation
66                            // to.
67                            if char_value == '\\' {
68                                let orig_it = it.clone();
69                                if !matches!(it.next(), Some('\\')) {
70                                    // Didn't find another backslash; restore
71                                    // iterator.
72                                    it = orig_it;
73                                }
74                            }
75
76                            let mut bytes: Vec<u8> = if char_value.is_ascii_lowercase() {
77                                char_value
78                                    .to_ascii_uppercase()
79                                    .to_string()
80                                    .bytes()
81                                    .collect()
82                            } else {
83                                char_value.to_string().bytes().collect()
84                            };
85
86                            if !bytes.is_empty() {
87                                if bytes[0] == b'?' {
88                                    // We can't explain why this is the case,
89                                    // but it is.
90                                    bytes[0] = 0x7f;
91                                } else {
92                                    bytes[0] &= 0x1f;
93                                }
94                            }
95
96                            result.append(bytes.as_mut());
97                        } else {
98                            result.push(b'\\');
99                            result.push(b'c');
100                        }
101                    }
102                }
103            }
104            'e' | 'E' => result.push(b'\x1b'),
105            'f' => result.push(b'\x0c'),
106            'n' => result.push(b'\n'),
107            'r' => result.push(b'\r'),
108            't' => result.push(b'\t'),
109            'v' => result.push(b'\x0b'),
110            '\\' => result.push(b'\\'),
111            '\'' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\''),
112            '\"' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\"'),
113            '?' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'?'),
114            '0' => {
115                // Consume 0-3 valid octal chars
116                let mut taken_so_far = 0;
117                let mut octal_chars: String = it
118                    .take_while_ref(|c| {
119                        if taken_so_far < 3 && matches!(*c, '0'..='7') {
120                            taken_so_far += 1;
121                            true
122                        } else {
123                            false
124                        }
125                    })
126                    .collect();
127
128                if octal_chars.is_empty() {
129                    octal_chars.push('0');
130                }
131
132                let value = int_utils::parse::<u8>(octal_chars.as_str(), 8)?;
133                result.push(value);
134            }
135            'x' => {
136                // Consume 1-2 valid hex chars (or unlimited with braces in
137                // ANSI-C mode)
138                let mut hex_chars = String::new();
139                let mut invalid_prefix = false;
140                let mut hexits_consumed = 0;
141                let mut start_brace_consumed = false;
142
143                loop {
144                    // Save the original in case we go too far and need to
145                    // restore.
146                    let orig_it = it.clone();
147
148                    let Some(next_c) = it.next() else {
149                        break;
150                    };
151
152                    if matches!(mode, EscapeExpansionMode::AnsiCQuotes)
153                        && !start_brace_consumed
154                        && next_c == '{'
155                    {
156                        start_brace_consumed = true;
157                    } else if start_brace_consumed && next_c == '}' {
158                        break;
159                    } else if ((start_brace_consumed && !invalid_prefix)
160                        || (!start_brace_consumed && hexits_consumed < 2))
161                        && next_c.is_ascii_hexdigit()
162                    {
163                        hex_chars.push(next_c);
164                        hexits_consumed += 1;
165                    } else if start_brace_consumed && hexits_consumed == 0 {
166                        invalid_prefix = true;
167                    } else {
168                        // Went too far; restore iterator and break.
169                        it = orig_it;
170                        break;
171                    }
172                }
173
174                if hex_chars.is_empty() {
175                    if start_brace_consumed {
176                        result.push(0);
177                    } else {
178                        result.push(b'\\');
179                        result.append(escape_cmd.to_string().into_bytes().as_mut());
180                    }
181                } else {
182                    let value32 = int_utils::parse::<u32>(hex_chars.as_str(), 16)?;
183                    let value8: u8 = (value32 & 0xff) as u8;
184                    result.push(value8);
185                }
186            }
187            'u' => {
188                // Consume 1-4 hex digits
189                let mut taken_so_far = 0;
190                let hex_chars: String = it
191                    .take_while_ref(|next_c| {
192                        if taken_so_far < 4 && next_c.is_ascii_hexdigit() {
193                            taken_so_far += 1;
194                            true
195                        } else {
196                            false
197                        }
198                    })
199                    .collect();
200
201                if hex_chars.is_empty() {
202                    result.push(b'\\');
203                    result.append(escape_cmd.to_string().into_bytes().as_mut());
204                } else {
205                    let value = int_utils::parse::<u16>(hex_chars.as_str(), 16)?;
206                    if let Some(decoded) = char::from_u32(u32::from(value)) {
207                        result.append(decoded.to_string().into_bytes().as_mut());
208                    } else {
209                        result.push(b'\\');
210                        result.append(escape_cmd.to_string().into_bytes().as_mut());
211                    }
212                }
213            }
214            'U' => {
215                // Consume 1-8 hex digits
216                let mut taken_so_far = 0;
217                let hex_chars: String = it
218                    .take_while_ref(|next_c| {
219                        if taken_so_far < 8 && next_c.is_ascii_hexdigit() {
220                            taken_so_far += 1;
221                            true
222                        } else {
223                            false
224                        }
225                    })
226                    .collect();
227
228                if hex_chars.is_empty() {
229                    result.push(b'\\');
230                    result.append(escape_cmd.to_string().into_bytes().as_mut());
231                } else {
232                    let value = int_utils::parse::<u32>(hex_chars.as_str(), 16)?;
233                    if let Some(decoded) = char::from_u32(value) {
234                        result.append(decoded.to_string().into_bytes().as_mut());
235                    } else {
236                        result.push(b'\\');
237                        result.append(escape_cmd.to_string().into_bytes().as_mut());
238                    }
239                }
240            }
241            first_octal @ '1'..='7' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => {
242                // We've already consumed the first octal digit.
243                let mut octal_chars = String::new();
244                octal_chars.push(first_octal);
245
246                // Consume up to 2 more valid octal chars
247                let mut taken_so_far = 1;
248                for next_c in it.take_while_ref(|next_c| {
249                    if taken_so_far < 3 && matches!(next_c, '0'..='7') {
250                        taken_so_far += 1;
251                        true
252                    } else {
253                        false
254                    }
255                }) {
256                    octal_chars.push(next_c);
257                }
258
259                let value = int_utils::parse::<u8>(octal_chars.as_str(), 8)?;
260                result.push(value);
261            }
262            unknown => {
263                // Not a valid escape sequence.
264                result.push(b'\\');
265                result.append(unknown.to_string().into_bytes().as_mut());
266            }
267        }
268    }
269
270    // In ANSI-C quotes, we crop the result at the first NUL.
271    if matches!(mode, EscapeExpansionMode::AnsiCQuotes) {
272        if let Some(nul_index) = result.iter().position(|&b| b == 0) {
273            result.truncate(nul_index);
274        }
275    }
276
277    Ok((result, true))
278}
279
280/// Quoting mode to use for escaping.
281#[derive(Clone, Copy, Debug, Default)]
282pub enum QuoteMode {
283    /// Single-quote.
284    #[default]
285    SingleQuote,
286    /// Double-quote.
287    DoubleQuote,
288    /// Backslash-escape.
289    BackslashEscape,
290}
291
292/// Options for [`quote`]: how to quote a string so that, read back as a shell
293/// word, it yields just that string, and which chars that are special only in
294/// some places (or to some readers) to treat as special.
295///
296/// Build one with [`QuoteOptions::builder`]; every option defaults to off (and
297/// the mode to [`QuoteMode::SingleQuote`]), which is what [`quote_if_needed`]
298/// uses.
299#[derive(Clone, Debug, Default, bon::Builder)]
300pub struct QuoteOptions {
301    /// Whether to quote the input even if nothing in it needs quoting.
302    #[builder(default)]
303    pub(crate) always_quote: bool,
304    /// Preferred mode for quoting. It's upgraded to ANSI-C quoting (`$'...'`)
305    /// if the input has chars only that can express.
306    #[builder(default)]
307    pub(crate) preferred_mode: QuoteMode,
308    /// Whether to avoid ANSI-C quoting just for newlines, which otherwise
309    /// require it.
310    #[builder(default)]
311    pub(crate) avoid_ansi_c_quoting_newline: bool,
312    /// Whether to leave a `#` starting the input unquoted. Bash does in some
313    /// output that isn't read back as shell input, e.g. tracing a `[[ -n
314    /// ... ]]` test.
315    #[builder(default)]
316    pub(crate) leave_leading_hash: bool,
317    /// Whether to leave a `~` unquoted where it would be tilde-expanded. Bash
318    /// does when quoting a file name it completes, so that a `~user`
319    /// completion still expands.
320    #[builder(default)]
321    pub(crate) leave_tilde: bool,
322    /// Whether to quote commas, which aren't special on their own. Bash's
323    /// `printf %q` does, so its output is safe even inside a brace
324    /// expansion.
325    #[builder(default)]
326    pub(crate) quote_commas: bool,
327}
328
329/// Quotes `s` as `options` say, so that, read back as a shell word, it yields
330/// just `s`.
331///
332/// # Arguments
333///
334/// * `s` - The string to quote.
335/// * `options` - How to quote it.
336pub fn quote<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> {
337    let use_ansi_c_quotes = s.contains(|c| {
338        needs_ansi_c_quoting(c) && (!options.avoid_ansi_c_quoting_newline || c != '\n')
339    });
340
341    if use_ansi_c_quotes {
342        return ansi_c_quote(s).into();
343    }
344
345    let use_default_quotes =
346        !use_ansi_c_quotes && (options.always_quote || s.is_empty() || needs_quoting(s, options));
347
348    if !use_default_quotes {
349        return s.into();
350    }
351
352    match options.preferred_mode {
353        QuoteMode::BackslashEscape => backslash_escape(s, options),
354        QuoteMode::SingleQuote => single_quote(s),
355        QuoteMode::DoubleQuote => double_quote(s).into(),
356    }
357}
358
359/// Escape the given string, forcing quoting.
360///
361/// # Arguments
362///
363/// * `s` - The string to escape.
364/// * `mode` - The quoting mode to use.
365pub fn force_quote(s: &str, mode: QuoteMode) -> String {
366    let options = QuoteOptions {
367        always_quote: true,
368        preferred_mode: mode,
369        ..Default::default()
370    };
371
372    quote(s, &options).to_string()
373}
374
375/// Applies the given quoting mode to the provided string, only changing it if
376/// required.
377///
378/// # Arguments
379///
380/// * `s` - The string to escape.
381/// * `mode` - The quoting mode to use.
382pub fn quote_if_needed(s: &str, mode: QuoteMode) -> Cow<'_, str> {
383    let options = QuoteOptions {
384        always_quote: false,
385        preferred_mode: mode,
386        ..Default::default()
387    };
388
389    quote(s, &options)
390}
391
392fn backslash_escape<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> {
393    if s.is_empty() {
394        // An empty string must be represented as '' to be a valid shell word.
395        Cow::Owned("''".to_string())
396    } else if !needs_quoting(s, options) {
397        Cow::Borrowed(s)
398    } else {
399        let mut output = String::with_capacity(s.len());
400        for (i, c) in s.char_indices() {
401            if needs_escaping_at(s, i, c, options) {
402                output.push('\\');
403            }
404            output.push(c);
405        }
406        Cow::Owned(output)
407    }
408}
409
410/// Plain single-quoting, as bash's `sh_single_quote` does it.
411///
412/// Unlike [`force_quote`] and [`quote_if_needed`], this never upgrades to
413/// ANSI-C quoting; control characters are emitted literally. Use it where bash
414/// prints values verbatim, such as `alias` output.
415///
416/// # Arguments
417///
418/// * `s` - The string to quote.
419pub fn single_quote(s: &str) -> Cow<'_, str> {
420    // Special-case the empty string and a lone single quote; both are rendered
421    // without any empty quoted runs.
422    match s {
423        "" => return Cow::Borrowed("''"),
424        "'" => return Cow::Borrowed(r"\'"),
425        _ => (),
426    }
427
428    let mut result = String::with_capacity(s.len() + 2);
429
430    // Go through the string; put everything in single quotes except for
431    // the single quote character itself. It will get escaped outside
432    // all quoting, with the quoted runs on either side of it kept even
433    // when they're empty.
434    for (i, part) in s.split('\'').enumerate() {
435        if i > 0 {
436            result.push_str(r"\'");
437        }
438
439        result.push('\'');
440        result.push_str(part);
441        result.push('\'');
442    }
443
444    Cow::Owned(result)
445}
446
447fn double_quote(s: &str) -> String {
448    let mut result = String::with_capacity(s.len());
449
450    result.push('"');
451
452    for c in s.chars() {
453        if matches!(c, '$' | '`' | '"' | '\\') {
454            result.push('\\');
455        }
456
457        result.push(c);
458    }
459
460    result.push('"');
461
462    result
463}
464
465fn ansi_c_quote(s: &str) -> String {
466    let mut result = String::with_capacity(s.len());
467    result.push_str("$'");
468
469    for c in s.chars() {
470        match c {
471            '\x07' => result.push_str("\\a"),
472            '\x08' => result.push_str("\\b"),
473            '\x1b' => result.push_str("\\E"),
474            '\x0c' => result.push_str("\\f"),
475            '\n' => result.push_str("\\n"),
476            '\r' => result.push_str("\\r"),
477            '\t' => result.push_str("\\t"),
478            '\x0b' => result.push_str("\\v"),
479            '\\' => result.push_str("\\\\"),
480            '\'' => result.push_str("\\'"),
481            c if needs_ansi_c_quoting(c) => {
482                result.push_str(std::format!("\\{:03o}", c as u8).as_str());
483            }
484            _ => result.push(c),
485        }
486    }
487
488    result.push('\'');
489
490    result
491}
492
493/// Returns whether `s` has a char that needs quoting for `s` to be a shell word
494/// that expands to just `s`, as `options` say.
495fn needs_quoting(s: &str, options: &QuoteOptions) -> bool {
496    s.char_indices()
497        .any(|(i, c)| needs_escaping_at(s, i, c, options))
498}
499
500/// Returns whether `c`, at byte offset `i` in `s`, needs quoting: if it's
501/// always special, or, like bash, it's a `#` starting the word (which would
502/// start a comment), or a `~` starting it or following a `=` or `:` (where it
503/// would be tilde-expanded) -- unless `options` say to leave those.
504fn needs_escaping_at(s: &str, i: usize, c: char, options: &QuoteOptions) -> bool {
505    match c {
506        '#' => i == 0 && !options.leave_leading_hash,
507        '~' if options.leave_tilde => false,
508        ',' => options.quote_commas,
509        '~' => {
510            let prev = s.get(..i).and_then(|before| before.chars().next_back());
511            matches!(prev, None | Some('=' | ':'))
512        }
513        _ => needs_escaping(c),
514    }
515}
516
517// Returns whether or not the given character needs to be escaped (or quoted) if
518// outside quotes.
519const fn needs_escaping(c: char) -> bool {
520    matches!(
521        c,
522        '(' | ')'
523            | '['
524            | ']'
525            | '{'
526            | '}'
527            | '$'
528            | '*'
529            | '?'
530            | '|'
531            | '&'
532            | ';'
533            | '<'
534            | '>'
535            | '`'
536            | '\\'
537            | '"'
538            | '!'
539            | '^'
540            | ' '
541            | '\''
542    )
543}
544
545const fn needs_ansi_c_quoting(c: char) -> bool {
546    c.is_ascii_control()
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_backslash_escape() {
555        assert_eq!(quote_if_needed("a", QuoteMode::BackslashEscape), "a");
556        assert_eq!(quote_if_needed("a b", QuoteMode::BackslashEscape), r"a\ b");
557        assert_eq!(quote_if_needed("", QuoteMode::BackslashEscape), "''");
558    }
559
560    #[test]
561    fn test_leading_hash_and_tilde_need_quoting() {
562        // Like bash: a `#` starting a word, and a `~` starting it or following
563        // `=` or `:`.
564        assert_eq!(quote_if_needed("#a", QuoteMode::BackslashEscape), r"\#a");
565        assert_eq!(quote_if_needed("~/x", QuoteMode::BackslashEscape), r"\~/x");
566        assert_eq!(quote_if_needed("a=~", QuoteMode::BackslashEscape), r"a=\~");
567        assert_eq!(quote_if_needed("a:~", QuoteMode::BackslashEscape), r"a:\~");
568        assert_eq!(quote_if_needed("#a", QuoteMode::SingleQuote), "'#a'");
569        assert_eq!(quote_if_needed("~", QuoteMode::SingleQuote), "'~'");
570        // Elsewhere, they're ordinary chars.
571        assert_eq!(quote_if_needed("a#", QuoteMode::SingleQuote), "a#");
572        assert_eq!(quote_if_needed("a~", QuoteMode::SingleQuote), "a~");
573        // A multibyte char before them doesn't change that.
574        assert_eq!(quote_if_needed("é=~", QuoteMode::BackslashEscape), r"é=\~");
575        assert_eq!(quote_if_needed("é~", QuoteMode::BackslashEscape), "é~");
576        assert_eq!(quote_if_needed("é#", QuoteMode::BackslashEscape), "é#");
577    }
578
579    #[test]
580    fn test_single_quote_escape() {
581        assert_eq!(quote_if_needed("a", QuoteMode::SingleQuote), "a");
582        assert_eq!(quote_if_needed("a b", QuoteMode::SingleQuote), "'a b'");
583        assert_eq!(quote_if_needed("", QuoteMode::SingleQuote), "''");
584        assert_eq!(quote_if_needed("'", QuoteMode::SingleQuote), r"\'");
585
586        // The quoted run on either side of an escaped quote is kept even when
587        // it's empty; only a string that is *nothing but* a single
588        // quote loses its surrounding quotes.
589        assert_eq!(quote_if_needed("a'b", QuoteMode::SingleQuote), r"'a'\''b'");
590        assert_eq!(
591            quote_if_needed("a''b", QuoteMode::SingleQuote),
592            r"'a'\'''\''b'"
593        );
594        assert_eq!(quote_if_needed("'a", QuoteMode::SingleQuote), r"''\''a'");
595        assert_eq!(quote_if_needed("a'", QuoteMode::SingleQuote), r"'a'\'''");
596        assert_eq!(quote_if_needed("''", QuoteMode::SingleQuote), r"''\'''\'''");
597    }
598
599    #[test]
600    fn test_single_quote_never_upgrades() {
601        // Unlike `quote`, this never reaches for ANSI-C quoting; control
602        // characters are emitted literally.
603        assert_eq!(single_quote("a\tb"), "'a\tb'");
604        assert_eq!(single_quote("a\nb"), "'a\nb'");
605        assert_eq!(single_quote("a"), "'a'");
606
607        // ...whereas `quote` does upgrade for the same input.
608        assert_eq!(quote_if_needed("a\tb", QuoteMode::SingleQuote), r"$'a\tb'");
609    }
610
611    fn assert_echo_expands_to(unexpanded: &str, expected: &str) {
612        assert_eq!(
613            String::from_utf8(
614                expand_backslash_escapes(unexpanded, EscapeExpansionMode::EchoBuiltin)
615                    .unwrap()
616                    .0
617            )
618            .unwrap(),
619            expected
620        );
621    }
622
623    #[test]
624    fn test_echo_expansion() {
625        assert_echo_expands_to("a", "a");
626        assert_echo_expands_to(r"\M", "\\M");
627        assert_echo_expands_to(r"a\nb", "a\nb");
628        assert_echo_expands_to(r"\a", "\x07");
629        assert_echo_expands_to(r"\b", "\x08");
630        assert_echo_expands_to(r"\e", "\x1b");
631        assert_echo_expands_to(r"\f", "\x0c");
632        assert_echo_expands_to(r"\n", "\n");
633        assert_echo_expands_to(r"\r", "\r");
634        assert_echo_expands_to(r"\t", "\t");
635        assert_echo_expands_to(r"\v", "\x0b");
636        assert_echo_expands_to(r"\\", "\\");
637        assert_echo_expands_to(r"\'", "\\'");
638        assert_echo_expands_to(r#"\""#, r#"\""#);
639        assert_echo_expands_to(r"\?", "\\?");
640        assert_echo_expands_to(r"\0", "\0");
641        assert_echo_expands_to(r"\00", "\0");
642        assert_echo_expands_to(r"\000", "\0");
643        assert_echo_expands_to(r"\081", "\081");
644        assert_echo_expands_to(r"\0101", "A");
645        assert_echo_expands_to(r"abc\", "abc\\");
646        assert_echo_expands_to(r"\x41", "A");
647        assert_echo_expands_to(r"\xf0\x9f\x90\x8d", "🐍");
648        assert_echo_expands_to(r"\u2620", "☠");
649        assert_echo_expands_to(r"\U0001f602", "😂");
650    }
651}