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