Skip to main content

command_stream/
quote.rs

1//! Shell quoting utilities for command-stream
2//!
3//! This module provides functions for safely quoting values for shell usage,
4//! preventing command injection and ensuring proper argument handling.
5
6use std::collections::HashSet;
7use std::sync::{Mutex, OnceLock};
8
9/// Whether the legacy pre-quoted passthrough heuristic is active.
10///
11/// Older versions treated a value that happened to start and end with a quote
12/// character as "already quoted" and spliced it into the command as shell
13/// syntax, so the value `'/My Documents/x'` reached the command as
14/// `/My Documents/x` - the quotes vanished. sh does the opposite: `"$var"`
15/// always yields the value verbatim, quote characters included, which is also
16/// what Bun's $, zx and execa do. Worse, the heuristic could hand the shell
17/// unbalanced quotes, and an injected command ran (issue #41).
18///
19/// The heuristic is therefore off by default; set
20/// `COMMAND_STREAM_PREQUOTED_PASSTHROUGH=1` to restore it for code that relies
21/// on hand-quoted values. Even then only values that stay balanced are passed
22/// through, so the injection above can no longer happen.
23pub fn is_pre_quoted_passthrough_enabled() -> bool {
24    matches!(
25        std::env::var("COMMAND_STREAM_PREQUOTED_PASSTHROUGH"),
26        Ok(ref value) if value == "1"
27    )
28}
29
30/// Whether a value is wrapped in matching quotes that contain none of that
31/// quote character inside, i.e. it is balanced shell syntax on its own.
32fn is_balanced_quoted_value(value: &str) -> bool {
33    let quote_char = match value.chars().next() {
34        Some(c @ ('\'' | '"')) => c,
35        _ => return false,
36    };
37    if value.chars().count() < 2 || !value.ends_with(quote_char) {
38        return false;
39    }
40    let inner = &value[quote_char.len_utf8()..value.len() - quote_char.len_utf8()];
41    !inner.contains(quote_char)
42}
43
44/// Quote a value for safe shell usage
45///
46/// The value is always treated as literal text - exactly one argument, spaces
47/// and quote characters included - which is what `"$var"` does in sh.
48///
49/// # Examples
50///
51/// ```
52/// use command_stream::quote::quote;
53///
54/// // Safe characters are passed through unchanged
55/// assert_eq!(quote("hello"), "hello");
56/// assert_eq!(quote("/path/to/file"), "/path/to/file");
57///
58/// // Special characters are quoted
59/// assert_eq!(quote("hello world"), "'hello world'");
60///
61/// // Paths with spaces stay a single argument
62/// assert_eq!(quote("/My Documents/report.txt"), "'/My Documents/report.txt'");
63///
64/// // Single quotes in strings are escaped
65/// assert_eq!(quote("it's"), "'it'\\''s'");
66///
67/// // Empty strings are quoted
68/// assert_eq!(quote(""), "''");
69/// ```
70pub fn quote(value: &str) -> String {
71    if value.is_empty() {
72        return "''".to_string();
73    }
74
75    // Legacy: the caller quoted the value themselves, so use it as shell syntax.
76    if is_pre_quoted_passthrough_enabled() && is_balanced_quoted_value(value) {
77        return value.to_string();
78    }
79
80    // Check if the string needs quoting at all
81    // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus, at
82    let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
83
84    if safe_pattern.is_match(value) {
85        return value.to_string();
86    }
87
88    // Wrap in single quotes and escape any internal single quotes.
89    // The shell escape sequence for a single quote inside single quotes is: '\''
90    // This ends the single quote, adds an escaped single quote, and starts single quotes again
91    format!("'{}'", value.replace('\'', "'\\''"))
92}
93
94/// Where in a command an interpolated value lands.
95///
96/// A tagged template / format string can place an interpolation inside quotes
97/// the author wrote themselves, e.g. `s!("bash -c \"{}\"", script)`. Wrapping
98/// the value in single quotes there produces `bash -c "'...'"`, which the
99/// inner shell refuses to run (issue #49). Inside quotes the value is instead
100/// spliced in as escaped literal text, exactly like `"$var"` in a POSIX shell.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum QuoteContext {
103    /// Outside any quotes: the value is fully quoted.
104    Unquoted,
105    /// Inside `'...'` written by the author.
106    Single,
107    /// Inside `"..."` written by the author.
108    Double,
109}
110
111/// Whether context-aware quoting is enabled.
112///
113/// On by default; set `COMMAND_STREAM_QUOTE_CONTEXT=0` to restore the previous
114/// behaviour of always single-quoting interpolated values.
115pub fn is_quote_context_enabled() -> bool {
116    match std::env::var("COMMAND_STREAM_QUOTE_CONTEXT") {
117        Ok(value) => value != "0",
118        Err(_) => true,
119    }
120}
121
122/// Advance the shell quoting state across a literal chunk of a template.
123///
124/// Only the template's own text is scanned - interpolated values never change
125/// the state, which is exactly why they cannot break out of their quotes.
126///
127/// # Examples
128///
129/// ```
130/// use command_stream::quote::{scan_quote_context, QuoteContext};
131///
132/// assert_eq!(scan_quote_context("echo ", QuoteContext::Unquoted), QuoteContext::Unquoted);
133/// assert_eq!(scan_quote_context("bash -c \"", QuoteContext::Unquoted), QuoteContext::Double);
134/// assert_eq!(scan_quote_context("echo '", QuoteContext::Unquoted), QuoteContext::Single);
135/// ```
136pub fn scan_quote_context(text: &str, context: QuoteContext) -> QuoteContext {
137    let chars: Vec<char> = text.chars().collect();
138    let mut current = context;
139    let mut i = 0;
140    while i < chars.len() {
141        let c = chars[i];
142        match current {
143            QuoteContext::Single => {
144                // Inside '...' nothing is special except the closing quote.
145                if c == '\'' {
146                    current = QuoteContext::Unquoted;
147                }
148            }
149            QuoteContext::Double => {
150                // Inside "..." a backslash escapes the next character.
151                if c == '\\' {
152                    i += 2;
153                    continue;
154                }
155                if c == '"' {
156                    current = QuoteContext::Unquoted;
157                }
158            }
159            QuoteContext::Unquoted => {
160                if c == '\\' {
161                    i += 2;
162                    continue;
163                }
164                if c == '\'' {
165                    current = QuoteContext::Single;
166                } else if c == '"' {
167                    current = QuoteContext::Double;
168                }
169            }
170        }
171        i += 1;
172    }
173    current
174}
175
176/// Escape a value so it can sit inside `'...'` as literal text.
177///
178/// A single quote is emitted as `'\''` - close, escaped quote, reopen - which
179/// is the standard POSIX idiom.
180///
181/// # Examples
182///
183/// ```
184/// use command_stream::quote::escape_for_single_quotes;
185///
186/// assert_eq!(escape_for_single_quotes("plain $text"), "plain $text");
187/// assert_eq!(escape_for_single_quotes("it's"), "it'\\''s");
188/// ```
189pub fn escape_for_single_quotes(value: &str) -> String {
190    value.replace('\'', "'\\''")
191}
192
193/// Escape a value so it can sit inside `"..."` as literal text.
194///
195/// Backslash, dollar, backtick and double quote are the only characters the
196/// shell still interprets inside double quotes, so escaping them makes the
197/// value literal - the inner program (e.g. `bash -c`) sees the original text.
198///
199/// # Examples
200///
201/// ```
202/// use command_stream::quote::escape_for_double_quotes;
203///
204/// assert_eq!(escape_for_double_quotes("plain text"), "plain text");
205/// assert_eq!(escape_for_double_quotes("$HOME"), "\\$HOME");
206/// ```
207pub fn escape_for_double_quotes(value: &str) -> String {
208    value
209        .replace('\\', "\\\\")
210        .replace('$', "\\$")
211        .replace('`', "\\`")
212        .replace('"', "\\\"")
213}
214
215/// Quote a value for the context it is interpolated into.
216///
217/// In an unquoted position this is plain [`quote`]. Inside quotes the value is
218/// inserted as escaped literal text, without adding another layer of quotes.
219///
220/// # Examples
221///
222/// ```
223/// use command_stream::quote::{quote_for_context, QuoteContext};
224///
225/// assert_eq!(quote_for_context("hello world", QuoteContext::Unquoted), "'hello world'");
226/// assert_eq!(quote_for_context("hello world", QuoteContext::Double), "hello world");
227/// assert_eq!(quote_for_context("it's", QuoteContext::Single), "it'\\''s");
228/// ```
229pub fn quote_for_context(value: &str, context: QuoteContext) -> String {
230    match context {
231        QuoteContext::Unquoted => quote(value),
232        // Inside quotes an empty value expands to nothing, like "$unset".
233        QuoteContext::Single => escape_for_single_quotes(value),
234        QuoteContext::Double => escape_for_double_quotes(value),
235    }
236}
237
238/// Characters a backslash may escape inside double quotes, per POSIX.
239fn is_double_quote_escape(char: Option<char>) -> bool {
240    matches!(
241        char,
242        Some('$') | Some('`') | Some('"') | Some('\\') | Some('\n')
243    )
244}
245
246/// Detect backslash escapes that a real shell removes but the lightweight
247/// built-in command path keeps verbatim.
248///
249/// Commands like this are routed to the system shell, which is the only way to
250/// get exactly the POSIX result - important now that interpolating a value into
251/// a quoted position escapes it (issue #49).
252///
253/// # Examples
254///
255/// ```
256/// use command_stream::quote::has_shell_escapes;
257///
258/// assert!(has_shell_escapes("echo \"5 \\$US\""));
259/// assert!(!has_shell_escapes("echo \"plain\""));
260/// assert!(!has_shell_escapes("echo 'a \\$b'"));
261/// ```
262pub fn has_shell_escapes(command: &str) -> bool {
263    if !command.contains('\\') {
264        return false;
265    }
266    let chars: Vec<char> = command.chars().collect();
267    let mut quote: Option<char> = None;
268    let mut i = 0;
269    while i < chars.len() {
270        let c = chars[i];
271        match quote {
272            // Inside '...' a backslash is an ordinary character.
273            Some('\'') => {
274                if c == '\'' {
275                    quote = None;
276                }
277            }
278            Some('"') => {
279                if c == '\\' {
280                    if is_double_quote_escape(chars.get(i + 1).copied()) {
281                        return true;
282                    }
283                    i += 2;
284                    continue;
285                }
286                if c == '"' {
287                    quote = None;
288                }
289            }
290            _ => {
291                if c == '\\' {
292                    // Outside quotes a backslash escapes whatever follows it.
293                    if i + 1 < chars.len() {
294                        return true;
295                    }
296                } else if c == '"' || c == '\'' {
297                    quote = Some(c);
298                }
299            }
300        }
301        i += 1;
302    }
303    false
304}
305
306/// Quote multiple values and join them with spaces
307///
308/// Convenience function for quoting a list of arguments.
309///
310/// # Examples
311///
312/// ```
313/// use command_stream::quote::quote_all;
314///
315/// let args = vec!["echo", "hello world", "test"];
316/// assert_eq!(quote_all(&args), "echo 'hello world' test");
317/// ```
318pub fn quote_all(values: &[&str]) -> String {
319    values
320        .iter()
321        .map(|v| quote(v))
322        .collect::<Vec<_>>()
323        .join(" ")
324}
325
326/// Check if a string needs quoting for shell usage
327///
328/// Returns true if the string contains characters that would be interpreted
329/// specially by the shell.
330///
331/// # Examples
332///
333/// ```
334/// use command_stream::quote::needs_quoting;
335///
336/// assert!(!needs_quoting("hello"));
337/// assert!(needs_quoting("hello world"));
338/// assert!(needs_quoting("$PATH"));
339/// ```
340pub fn needs_quoting(value: &str) -> bool {
341    if value.is_empty() {
342        return true;
343    }
344
345    let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
346    !safe_pattern.is_match(value)
347}
348
349/// Scan a built command string for an unquoted Go/Handlebars-style template
350/// token (`{{ ... }}`) that contains an unquoted space.
351///
352/// Such a token is split by the shell (and by command-stream, which mirrors
353/// shell word-splitting) into multiple argv words, so `--format {{json .X}}`
354/// reaches the child as `--format`, `{{json`, `.X}}` — exactly what a POSIX
355/// shell would do, but surprising for Go templates. Returns the offending
356/// snippet so callers can point the user at the gotcha.
357///
358/// # Examples
359///
360/// ```
361/// use command_stream::quote::find_split_template_token;
362///
363/// assert_eq!(
364///     find_split_template_token("docker inspect --format {{json .Config.Env}}"),
365///     Some("{{json .Config.Env}}".to_string())
366/// );
367/// // Space-free or quoted tokens are not flagged.
368/// assert_eq!(find_split_template_token("docker inspect --format {{.Id}}"), None);
369/// assert_eq!(
370///     find_split_template_token("docker inspect --format '{{json .Config.Env}}'"),
371///     None
372/// );
373/// ```
374pub fn find_split_template_token(command: &str) -> Option<String> {
375    if !command.contains("{{") {
376        return None;
377    }
378
379    let chars: Vec<char> = command.chars().collect();
380    let n = chars.len();
381    let mut in_single = false;
382    let mut in_double = false;
383    let mut i = 0;
384    while i < n {
385        let c = chars[i];
386        if in_single {
387            in_single = c != '\'';
388            i += 1;
389            continue;
390        }
391        if in_double {
392            in_double = c != '"';
393            i += 1;
394            continue;
395        }
396        if c == '\'' {
397            in_single = true;
398            i += 1;
399            continue;
400        }
401        if c == '"' {
402            in_double = true;
403            i += 1;
404            continue;
405        }
406
407        // An unquoted `{{` — scan forward for its matching `}}`, reporting it
408        // when an unquoted space appears in between (which triggers splitting).
409        if c == '{' && i + 1 < n && chars[i + 1] == '{' {
410            let (splits, end) = scan_template_close(&chars, i + 2);
411            if splits {
412                return Some(chars[i..=end + 1].iter().collect());
413            }
414            i = end + 1;
415            continue;
416        }
417        i += 1;
418    }
419
420    None
421}
422
423/// Starting just after an unquoted `{{`, scan to the matching unquoted `}}`,
424/// tracking whether an unquoted space appears in between.
425///
426/// Returns `(splits, end_index)` where `splits` is true when a closing `}}`
427/// was found with an intervening unquoted space, and `end_index` points at the
428/// first `}` of that closing pair (or the end of input when no `}}` is found).
429fn scan_template_close(chars: &[char], start: usize) -> (bool, usize) {
430    let n = chars.len();
431    let mut j = start;
432    let mut has_unquoted_space = false;
433    let mut in_single = false;
434    let mut in_double = false;
435    while j < n {
436        let c = chars[j];
437        if in_single {
438            in_single = c != '\'';
439        } else if in_double {
440            in_double = c != '"';
441        } else if c == '\'' {
442            in_single = true;
443        } else if c == '"' {
444            in_double = true;
445        } else if c == '}' && j + 1 < n && chars[j + 1] == '}' {
446            return (has_unquoted_space, j);
447        } else if c.is_whitespace() {
448            has_unquoted_space = true;
449        }
450        j += 1;
451    }
452    (false, j)
453}
454
455fn warned_template_snippets() -> &'static Mutex<HashSet<String>> {
456    static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
457    WARNED.get_or_init(|| Mutex::new(HashSet::new()))
458}
459
460/// Emit a one-line diagnostic when a built command contains an unquoted Go
461/// template token with an internal space. This points users at the
462/// shell-splitting gotcha behind the cryptic downstream errors (e.g. Go's
463/// "unclosed action"). Silenced via `COMMAND_STREAM_NO_TEMPLATE_WARNING`, and
464/// each unique snippet is only reported once per process.
465pub fn warn_on_split_template(command: &str) {
466    if std::env::var_os("COMMAND_STREAM_NO_TEMPLATE_WARNING").is_some() {
467        return;
468    }
469    let snippet = match find_split_template_token(command) {
470        Some(s) => s,
471        None => return,
472    };
473    {
474        let mut warned = warned_template_snippets().lock().unwrap();
475        if !warned.insert(snippet.clone()) {
476            return;
477        }
478    }
479    eprintln!(
480        "[command-stream] Warning: template token `{snippet}` contains an \
481unquoted space, so the shell splits it into multiple arguments (just like \
482bash would). Quote it ('{snippet}') or interpolate it as a single ${{value}} \
483to pass it as one argument. See README \"Go templates & {{{{ }}}} arguments\". \
484Set COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence."
485    );
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn test_quote_empty() {
494        assert_eq!(quote(""), "''");
495    }
496
497    #[test]
498    fn test_quote_safe_chars() {
499        assert_eq!(quote("hello"), "hello");
500        assert_eq!(quote("/path/to/file"), "/path/to/file");
501        assert_eq!(quote("file.txt"), "file.txt");
502        assert_eq!(quote("key=value"), "key=value");
503        assert_eq!(quote("user@host"), "user@host");
504    }
505
506    #[test]
507    fn test_quote_special_chars() {
508        assert_eq!(quote("hello world"), "'hello world'");
509        assert_eq!(quote("it's"), "'it'\\''s'");
510        assert_eq!(quote("$var"), "'$var'");
511        assert_eq!(quote("test*"), "'test*'");
512    }
513
514    #[test]
515    fn test_quote_treats_quote_characters_as_data() {
516        // Quote characters inside a value are data, exactly like "$var" in sh -
517        // they never quote the value itself (issue #41).
518        assert_eq!(quote("'already quoted'"), "''\\''already quoted'\\'''");
519        assert_eq!(quote("\"double quoted\""), "'\"double quoted\"'");
520        // The old "already double-quoted" shortcut emitted '"it's"', which the
521        // shell rejects as an unterminated quoted string.
522        assert_eq!(quote("\"it's\""), "'\"it'\\''s\"'");
523    }
524
525    #[test]
526    fn test_quote_paths_with_spaces() {
527        assert_eq!(
528            quote("/Users/john/My Documents/report.txt"),
529            "'/Users/john/My Documents/report.txt'"
530        );
531        assert_eq!(
532            quote("C:\\Program Files\\App\\app.exe"),
533            "'C:\\Program Files\\App\\app.exe'"
534        );
535        assert_eq!(quote("  /tmp/spaced  "), "'  /tmp/spaced  '");
536        assert_eq!(
537            quote("/tmp/it's a dir/f.txt"),
538            "'/tmp/it'\\''s a dir/f.txt'"
539        );
540    }
541
542    #[test]
543    fn test_pre_quoted_passthrough_disabled_by_default() {
544        // The opt-in is read from the environment on every call, so with the
545        // variable unset the sh-like literal behaviour must be in effect.
546        if std::env::var("COMMAND_STREAM_PREQUOTED_PASSTHROUGH").is_err() {
547            assert!(!is_pre_quoted_passthrough_enabled());
548        }
549    }
550
551    #[test]
552    fn test_balanced_quoted_value_detection() {
553        assert!(is_balanced_quoted_value("'/My Documents/f.txt'"));
554        assert!(is_balanced_quoted_value("\"/My Documents/f.txt\""));
555        // Unbalanced quoting is what made the old heuristic injectable.
556        assert!(!is_balanced_quoted_value("\"a\" ; touch pwned ; \"b\""));
557        assert!(!is_balanced_quoted_value("'a' ; touch pwned ; 'b'"));
558        assert!(!is_balanced_quoted_value("/plain/path"));
559        assert!(!is_balanced_quoted_value("'"));
560    }
561
562    #[test]
563    fn test_quote_all() {
564        let args = vec!["echo", "hello world", "test"];
565        assert_eq!(quote_all(&args), "echo 'hello world' test");
566    }
567
568    #[test]
569    fn test_needs_quoting() {
570        assert!(!needs_quoting("hello"));
571        assert!(!needs_quoting("/path/to/file"));
572        assert!(needs_quoting("hello world"));
573        assert!(needs_quoting("$PATH"));
574        assert!(needs_quoting(""));
575        assert!(needs_quoting("test*"));
576    }
577
578    #[test]
579    fn test_quote_with_newlines() {
580        assert_eq!(quote("line1\nline2"), "'line1\nline2'");
581    }
582
583    #[test]
584    fn test_quote_with_tabs() {
585        assert_eq!(quote("col1\tcol2"), "'col1\tcol2'");
586    }
587
588    #[test]
589    fn test_find_split_template_unquoted_with_space() {
590        assert_eq!(
591            find_split_template_token("docker inspect --format {{json .Config.Env}}"),
592            Some("{{json .Config.Env}}".to_string())
593        );
594    }
595
596    #[test]
597    fn test_find_split_template_space_free() {
598        assert_eq!(
599            find_split_template_token("docker inspect --format {{.Id}}"),
600            None
601        );
602    }
603
604    #[test]
605    fn test_find_split_template_single_quoted() {
606        assert_eq!(
607            find_split_template_token("docker inspect --format '{{json .Config.Env}}'"),
608            None
609        );
610    }
611
612    #[test]
613    fn test_find_split_template_double_quoted() {
614        assert_eq!(
615            find_split_template_token("docker inspect --format \"{{json .Config.Env}}\""),
616            None
617        );
618    }
619
620    #[test]
621    fn test_find_split_template_none_without_braces() {
622        assert_eq!(find_split_template_token("echo hello world"), None);
623    }
624}
625
626#[cfg(test)]
627mod quote_context_tests {
628    use super::*;
629    use crate::macros::build_shell_command;
630
631    #[test]
632    fn test_scan_quote_context_tracks_quotes() {
633        assert_eq!(
634            scan_quote_context("echo ", QuoteContext::Unquoted),
635            QuoteContext::Unquoted
636        );
637        assert_eq!(
638            scan_quote_context("bash -c \"", QuoteContext::Unquoted),
639            QuoteContext::Double
640        );
641        assert_eq!(
642            scan_quote_context("echo '", QuoteContext::Unquoted),
643            QuoteContext::Single
644        );
645        assert_eq!(
646            scan_quote_context("\" rest", QuoteContext::Double),
647            QuoteContext::Unquoted
648        );
649        assert_eq!(
650            scan_quote_context("' rest", QuoteContext::Single),
651            QuoteContext::Unquoted
652        );
653    }
654
655    #[test]
656    fn test_scan_quote_context_quotes_are_inert_inside_the_other_quote() {
657        // A double quote inside '...' is literal, so the state must not change.
658        assert_eq!(
659            scan_quote_context("it\"s", QuoteContext::Single),
660            QuoteContext::Single
661        );
662        // ...and a single quote inside "..." is literal too.
663        assert_eq!(
664            scan_quote_context("it's", QuoteContext::Double),
665            QuoteContext::Double
666        );
667    }
668
669    #[test]
670    fn test_scan_quote_context_honours_escapes() {
671        // An escaped quote does not open or close anything.
672        assert_eq!(
673            scan_quote_context("echo \\\"", QuoteContext::Unquoted),
674            QuoteContext::Unquoted
675        );
676        assert_eq!(
677            scan_quote_context("a \\\" b", QuoteContext::Double),
678            QuoteContext::Double
679        );
680        // Inside single quotes a backslash is literal, so this quote closes.
681        assert_eq!(
682            scan_quote_context("a \\'", QuoteContext::Single),
683            QuoteContext::Unquoted
684        );
685    }
686
687    #[test]
688    fn test_escape_for_single_quotes() {
689        assert_eq!(escape_for_single_quotes("plain"), "plain");
690        assert_eq!(escape_for_single_quotes("$HOME `id`"), "$HOME `id`");
691        assert_eq!(escape_for_single_quotes("it's"), "it'\\''s");
692    }
693
694    #[test]
695    fn test_escape_for_double_quotes() {
696        assert_eq!(escape_for_double_quotes("plain"), "plain");
697        assert_eq!(escape_for_double_quotes("$HOME"), "\\$HOME");
698        assert_eq!(escape_for_double_quotes("`id`"), "\\`id\\`");
699        assert_eq!(escape_for_double_quotes("say \"hi\""), "say \\\"hi\\\"");
700        assert_eq!(escape_for_double_quotes("back\\slash"), "back\\\\slash");
701        // Apostrophes are ordinary characters inside double quotes.
702        assert_eq!(escape_for_double_quotes("it's"), "it's");
703    }
704
705    #[test]
706    fn test_quote_for_context() {
707        assert_eq!(
708            quote_for_context("hello world", QuoteContext::Unquoted),
709            "'hello world'"
710        );
711        assert_eq!(
712            quote_for_context("hello world", QuoteContext::Double),
713            "hello world"
714        );
715        assert_eq!(
716            quote_for_context("hello world", QuoteContext::Single),
717            "hello world"
718        );
719        // Empty values expand to nothing inside quotes, like "$unset".
720        assert_eq!(quote_for_context("", QuoteContext::Double), "");
721        assert_eq!(quote_for_context("", QuoteContext::Unquoted), "''");
722    }
723
724    #[test]
725    fn test_build_shell_command_quotes_unquoted_values() {
726        assert_eq!(
727            build_shell_command(&["echo ", ""], &["hello world"]),
728            "echo 'hello world'"
729        );
730    }
731
732    #[test]
733    fn test_build_shell_command_issue_49() {
734        // The reported failure: a script interpolated into bash -c "..." was
735        // wrapped in single quotes, which bash then refused to run.
736        let script = "for file in *.js; do echo \"Processing: $file\"; done";
737        assert_eq!(
738            build_shell_command(&["bash -c \"", "\""], &[script]),
739            "bash -c \"for file in *.js; do echo \\\"Processing: \\$file\\\"; done\""
740        );
741    }
742
743    #[test]
744    fn test_build_shell_command_single_quoted_context() {
745        assert_eq!(
746            build_shell_command(&["echo '", "'"], &["it's here"]),
747            "echo 'it'\\''s here'"
748        );
749    }
750
751    #[test]
752    fn test_build_shell_command_cannot_break_out_of_quotes() {
753        // A value trying to close the quote and append a command stays literal.
754        let evil = "\"; rm -rf /; echo \"";
755        let built = build_shell_command(&["bash -c \"", "\""], &[evil]);
756        assert_eq!(built, "bash -c \"\\\"; rm -rf /; echo \\\"\"");
757        // Every quote coming from the value is escaped, so `rm -rf /` stays a
758        // literal argument of the inner echo instead of a new command.
759        assert!(built.contains("\\\"; rm -rf /"));
760    }
761
762    #[test]
763    fn test_build_shell_command_context_persists_across_parts() {
764        // The quote opened in the first part is still open for the second value.
765        assert_eq!(
766            build_shell_command(&["sh -c \"echo ", " ", "\""], &["a b", "c d"]),
767            "sh -c \"echo a b c d\""
768        );
769    }
770
771    #[test]
772    fn test_has_shell_escapes() {
773        assert!(!has_shell_escapes("echo hello"));
774        assert!(!has_shell_escapes("echo \"plain text\""));
775        // A backslash inside single quotes is literal, not an escape.
776        assert!(!has_shell_escapes("echo 'a \\$b'"));
777        assert!(has_shell_escapes("echo \"5 \\$US\""));
778        assert!(has_shell_escapes("echo \"say \\\"hi\\\"\""));
779        assert!(has_shell_escapes("echo a\\ b"));
780        // The '\'' idiom used for single-quoted values.
781        assert!(has_shell_escapes("echo 'it'\\''s'"));
782    }
783
784    #[test]
785    fn test_is_quote_context_enabled_defaults_to_on() {
786        // The opt-out is read from the environment on every call, so the
787        // default (variable unset in the test process) must be enabled.
788        if std::env::var("COMMAND_STREAM_QUOTE_CONTEXT").is_err() {
789            assert!(is_quote_context_enabled());
790        }
791    }
792}