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