1use std::collections::HashSet;
7use std::sync::{Mutex, OnceLock};
8
9pub fn quote(value: &str) -> String {
33 if value.is_empty() {
34 return "''".to_string();
35 }
36
37 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 value.starts_with('"') && value.ends_with('"') && value.len() > 2 {
47 return format!("'{}'", value);
48 }
49
50 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 format!("'{}'", value.replace('\'', "'\\''"))
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum QuoteContext {
73 Unquoted,
75 Single,
77 Double,
79}
80
81pub 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
92pub 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 if c == '\'' {
116 current = QuoteContext::Unquoted;
117 }
118 }
119 QuoteContext::Double => {
120 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
146pub fn escape_for_single_quotes(value: &str) -> String {
160 value.replace('\'', "'\\''")
161}
162
163pub fn escape_for_double_quotes(value: &str) -> String {
178 value
179 .replace('\\', "\\\\")
180 .replace('$', "\\$")
181 .replace('`', "\\`")
182 .replace('"', "\\\"")
183}
184
185pub fn quote_for_context(value: &str, context: QuoteContext) -> String {
200 match context {
201 QuoteContext::Unquoted => quote(value),
202 QuoteContext::Single => escape_for_single_quotes(value),
204 QuoteContext::Double => escape_for_double_quotes(value),
205 }
206}
207
208fn is_double_quote_escape(char: Option<char>) -> bool {
210 matches!(
211 char,
212 Some('$') | Some('`') | Some('"') | Some('\\') | Some('\n')
213 )
214}
215
216pub 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 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 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
276pub fn quote_all(values: &[&str]) -> String {
289 values
290 .iter()
291 .map(|v| quote(v))
292 .collect::<Vec<_>>()
293 .join(" ")
294}
295
296pub 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
319pub 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 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
393fn 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
430pub 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 assert_eq!(
587 scan_quote_context("it\"s", QuoteContext::Single),
588 QuoteContext::Single
589 );
590 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 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 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 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 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 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 let evil = "\"; rm -rf /; echo \"";
683 let built = build_shell_command(&["bash -c \"", "\""], &[evil]);
684 assert_eq!(built, "bash -c \"\\\"; rm -rf /; echo \\\"\"");
685 assert!(built.contains("\\\"; rm -rf /"));
688 }
689
690 #[test]
691 fn test_build_shell_command_context_persists_across_parts() {
692 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 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 assert!(has_shell_escapes("echo 'it'\\''s'"));
710 }
711
712 #[test]
713 fn test_is_quote_context_enabled_defaults_to_on() {
714 if std::env::var("COMMAND_STREAM_QUOTE_CONTEXT").is_err() {
717 assert!(is_quote_context_enabled());
718 }
719 }
720}