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}