use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
pub fn is_pre_quoted_passthrough_enabled() -> bool {
matches!(
std::env::var("COMMAND_STREAM_PREQUOTED_PASSTHROUGH"),
Ok(ref value) if value == "1"
)
}
fn is_balanced_quoted_value(value: &str) -> bool {
let quote_char = match value.chars().next() {
Some(c @ ('\'' | '"')) => c,
_ => return false,
};
if value.chars().count() < 2 || !value.ends_with(quote_char) {
return false;
}
let inner = &value[quote_char.len_utf8()..value.len() - quote_char.len_utf8()];
!inner.contains(quote_char)
}
pub fn quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
if is_pre_quoted_passthrough_enabled() && is_balanced_quoted_value(value) {
return value.to_string();
}
let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
if safe_pattern.is_match(value) {
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuoteContext {
Unquoted,
Single,
Double,
}
pub fn is_quote_context_enabled() -> bool {
match std::env::var("COMMAND_STREAM_QUOTE_CONTEXT") {
Ok(value) => value != "0",
Err(_) => true,
}
}
pub fn scan_quote_context(text: &str, context: QuoteContext) -> QuoteContext {
let chars: Vec<char> = text.chars().collect();
let mut current = context;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match current {
QuoteContext::Single => {
if c == '\'' {
current = QuoteContext::Unquoted;
}
}
QuoteContext::Double => {
if c == '\\' {
i += 2;
continue;
}
if c == '"' {
current = QuoteContext::Unquoted;
}
}
QuoteContext::Unquoted => {
if c == '\\' {
i += 2;
continue;
}
if c == '\'' {
current = QuoteContext::Single;
} else if c == '"' {
current = QuoteContext::Double;
}
}
}
i += 1;
}
current
}
pub fn escape_for_single_quotes(value: &str) -> String {
value.replace('\'', "'\\''")
}
pub fn escape_for_double_quotes(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('$', "\\$")
.replace('`', "\\`")
.replace('"', "\\\"")
}
pub fn quote_for_context(value: &str, context: QuoteContext) -> String {
match context {
QuoteContext::Unquoted => quote(value),
QuoteContext::Single => escape_for_single_quotes(value),
QuoteContext::Double => escape_for_double_quotes(value),
}
}
fn is_double_quote_escape(char: Option<char>) -> bool {
matches!(
char,
Some('$') | Some('`') | Some('"') | Some('\\') | Some('\n')
)
}
pub fn has_shell_escapes(command: &str) -> bool {
if !command.contains('\\') {
return false;
}
let chars: Vec<char> = command.chars().collect();
let mut quote: Option<char> = None;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match quote {
Some('\'') => {
if c == '\'' {
quote = None;
}
}
Some('"') => {
if c == '\\' {
if is_double_quote_escape(chars.get(i + 1).copied()) {
return true;
}
i += 2;
continue;
}
if c == '"' {
quote = None;
}
}
_ => {
if c == '\\' {
if i + 1 < chars.len() {
return true;
}
} else if c == '"' || c == '\'' {
quote = Some(c);
}
}
}
i += 1;
}
false
}
pub fn quote_all(values: &[&str]) -> String {
values
.iter()
.map(|v| quote(v))
.collect::<Vec<_>>()
.join(" ")
}
pub fn needs_quoting(value: &str) -> bool {
if value.is_empty() {
return true;
}
let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
!safe_pattern.is_match(value)
}
pub fn find_split_template_token(command: &str) -> Option<String> {
if !command.contains("{{") {
return None;
}
let chars: Vec<char> = command.chars().collect();
let n = chars.len();
let mut in_single = false;
let mut in_double = false;
let mut i = 0;
while i < n {
let c = chars[i];
if in_single {
in_single = c != '\'';
i += 1;
continue;
}
if in_double {
in_double = c != '"';
i += 1;
continue;
}
if c == '\'' {
in_single = true;
i += 1;
continue;
}
if c == '"' {
in_double = true;
i += 1;
continue;
}
if c == '{' && i + 1 < n && chars[i + 1] == '{' {
let (splits, end) = scan_template_close(&chars, i + 2);
if splits {
return Some(chars[i..=end + 1].iter().collect());
}
i = end + 1;
continue;
}
i += 1;
}
None
}
fn scan_template_close(chars: &[char], start: usize) -> (bool, usize) {
let n = chars.len();
let mut j = start;
let mut has_unquoted_space = false;
let mut in_single = false;
let mut in_double = false;
while j < n {
let c = chars[j];
if in_single {
in_single = c != '\'';
} else if in_double {
in_double = c != '"';
} else if c == '\'' {
in_single = true;
} else if c == '"' {
in_double = true;
} else if c == '}' && j + 1 < n && chars[j + 1] == '}' {
return (has_unquoted_space, j);
} else if c.is_whitespace() {
has_unquoted_space = true;
}
j += 1;
}
(false, j)
}
fn warned_template_snippets() -> &'static Mutex<HashSet<String>> {
static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
WARNED.get_or_init(|| Mutex::new(HashSet::new()))
}
pub fn warn_on_split_template(command: &str) {
if std::env::var_os("COMMAND_STREAM_NO_TEMPLATE_WARNING").is_some() {
return;
}
let snippet = match find_split_template_token(command) {
Some(s) => s,
None => return,
};
{
let mut warned = warned_template_snippets().lock().unwrap();
if !warned.insert(snippet.clone()) {
return;
}
}
eprintln!(
"[command-stream] Warning: template token `{snippet}` contains an \
unquoted space, so the shell splits it into multiple arguments (just like \
bash would). Quote it ('{snippet}') or interpolate it as a single ${{value}} \
to pass it as one argument. See README \"Go templates & {{{{ }}}} arguments\". \
Set COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence."
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quote_empty() {
assert_eq!(quote(""), "''");
}
#[test]
fn test_quote_safe_chars() {
assert_eq!(quote("hello"), "hello");
assert_eq!(quote("/path/to/file"), "/path/to/file");
assert_eq!(quote("file.txt"), "file.txt");
assert_eq!(quote("key=value"), "key=value");
assert_eq!(quote("user@host"), "user@host");
}
#[test]
fn test_quote_special_chars() {
assert_eq!(quote("hello world"), "'hello world'");
assert_eq!(quote("it's"), "'it'\\''s'");
assert_eq!(quote("$var"), "'$var'");
assert_eq!(quote("test*"), "'test*'");
}
#[test]
fn test_quote_treats_quote_characters_as_data() {
assert_eq!(quote("'already quoted'"), "''\\''already quoted'\\'''");
assert_eq!(quote("\"double quoted\""), "'\"double quoted\"'");
assert_eq!(quote("\"it's\""), "'\"it'\\''s\"'");
}
#[test]
fn test_quote_paths_with_spaces() {
assert_eq!(
quote("/Users/john/My Documents/report.txt"),
"'/Users/john/My Documents/report.txt'"
);
assert_eq!(
quote("C:\\Program Files\\App\\app.exe"),
"'C:\\Program Files\\App\\app.exe'"
);
assert_eq!(quote(" /tmp/spaced "), "' /tmp/spaced '");
assert_eq!(
quote("/tmp/it's a dir/f.txt"),
"'/tmp/it'\\''s a dir/f.txt'"
);
}
#[test]
fn test_pre_quoted_passthrough_disabled_by_default() {
if std::env::var("COMMAND_STREAM_PREQUOTED_PASSTHROUGH").is_err() {
assert!(!is_pre_quoted_passthrough_enabled());
}
}
#[test]
fn test_balanced_quoted_value_detection() {
assert!(is_balanced_quoted_value("'/My Documents/f.txt'"));
assert!(is_balanced_quoted_value("\"/My Documents/f.txt\""));
assert!(!is_balanced_quoted_value("\"a\" ; touch pwned ; \"b\""));
assert!(!is_balanced_quoted_value("'a' ; touch pwned ; 'b'"));
assert!(!is_balanced_quoted_value("/plain/path"));
assert!(!is_balanced_quoted_value("'"));
}
#[test]
fn test_quote_all() {
let args = vec!["echo", "hello world", "test"];
assert_eq!(quote_all(&args), "echo 'hello world' test");
}
#[test]
fn test_needs_quoting() {
assert!(!needs_quoting("hello"));
assert!(!needs_quoting("/path/to/file"));
assert!(needs_quoting("hello world"));
assert!(needs_quoting("$PATH"));
assert!(needs_quoting(""));
assert!(needs_quoting("test*"));
}
#[test]
fn test_quote_with_newlines() {
assert_eq!(quote("line1\nline2"), "'line1\nline2'");
}
#[test]
fn test_quote_with_tabs() {
assert_eq!(quote("col1\tcol2"), "'col1\tcol2'");
}
#[test]
fn test_find_split_template_unquoted_with_space() {
assert_eq!(
find_split_template_token("docker inspect --format {{json .Config.Env}}"),
Some("{{json .Config.Env}}".to_string())
);
}
#[test]
fn test_find_split_template_space_free() {
assert_eq!(
find_split_template_token("docker inspect --format {{.Id}}"),
None
);
}
#[test]
fn test_find_split_template_single_quoted() {
assert_eq!(
find_split_template_token("docker inspect --format '{{json .Config.Env}}'"),
None
);
}
#[test]
fn test_find_split_template_double_quoted() {
assert_eq!(
find_split_template_token("docker inspect --format \"{{json .Config.Env}}\""),
None
);
}
#[test]
fn test_find_split_template_none_without_braces() {
assert_eq!(find_split_template_token("echo hello world"), None);
}
}
#[cfg(test)]
mod quote_context_tests {
use super::*;
use crate::macros::build_shell_command;
#[test]
fn test_scan_quote_context_tracks_quotes() {
assert_eq!(
scan_quote_context("echo ", QuoteContext::Unquoted),
QuoteContext::Unquoted
);
assert_eq!(
scan_quote_context("bash -c \"", QuoteContext::Unquoted),
QuoteContext::Double
);
assert_eq!(
scan_quote_context("echo '", QuoteContext::Unquoted),
QuoteContext::Single
);
assert_eq!(
scan_quote_context("\" rest", QuoteContext::Double),
QuoteContext::Unquoted
);
assert_eq!(
scan_quote_context("' rest", QuoteContext::Single),
QuoteContext::Unquoted
);
}
#[test]
fn test_scan_quote_context_quotes_are_inert_inside_the_other_quote() {
assert_eq!(
scan_quote_context("it\"s", QuoteContext::Single),
QuoteContext::Single
);
assert_eq!(
scan_quote_context("it's", QuoteContext::Double),
QuoteContext::Double
);
}
#[test]
fn test_scan_quote_context_honours_escapes() {
assert_eq!(
scan_quote_context("echo \\\"", QuoteContext::Unquoted),
QuoteContext::Unquoted
);
assert_eq!(
scan_quote_context("a \\\" b", QuoteContext::Double),
QuoteContext::Double
);
assert_eq!(
scan_quote_context("a \\'", QuoteContext::Single),
QuoteContext::Unquoted
);
}
#[test]
fn test_escape_for_single_quotes() {
assert_eq!(escape_for_single_quotes("plain"), "plain");
assert_eq!(escape_for_single_quotes("$HOME `id`"), "$HOME `id`");
assert_eq!(escape_for_single_quotes("it's"), "it'\\''s");
}
#[test]
fn test_escape_for_double_quotes() {
assert_eq!(escape_for_double_quotes("plain"), "plain");
assert_eq!(escape_for_double_quotes("$HOME"), "\\$HOME");
assert_eq!(escape_for_double_quotes("`id`"), "\\`id\\`");
assert_eq!(escape_for_double_quotes("say \"hi\""), "say \\\"hi\\\"");
assert_eq!(escape_for_double_quotes("back\\slash"), "back\\\\slash");
assert_eq!(escape_for_double_quotes("it's"), "it's");
}
#[test]
fn test_quote_for_context() {
assert_eq!(
quote_for_context("hello world", QuoteContext::Unquoted),
"'hello world'"
);
assert_eq!(
quote_for_context("hello world", QuoteContext::Double),
"hello world"
);
assert_eq!(
quote_for_context("hello world", QuoteContext::Single),
"hello world"
);
assert_eq!(quote_for_context("", QuoteContext::Double), "");
assert_eq!(quote_for_context("", QuoteContext::Unquoted), "''");
}
#[test]
fn test_build_shell_command_quotes_unquoted_values() {
assert_eq!(
build_shell_command(&["echo ", ""], &["hello world"]),
"echo 'hello world'"
);
}
#[test]
fn test_build_shell_command_issue_49() {
let script = "for file in *.js; do echo \"Processing: $file\"; done";
assert_eq!(
build_shell_command(&["bash -c \"", "\""], &[script]),
"bash -c \"for file in *.js; do echo \\\"Processing: \\$file\\\"; done\""
);
}
#[test]
fn test_build_shell_command_single_quoted_context() {
assert_eq!(
build_shell_command(&["echo '", "'"], &["it's here"]),
"echo 'it'\\''s here'"
);
}
#[test]
fn test_build_shell_command_cannot_break_out_of_quotes() {
let evil = "\"; rm -rf /; echo \"";
let built = build_shell_command(&["bash -c \"", "\""], &[evil]);
assert_eq!(built, "bash -c \"\\\"; rm -rf /; echo \\\"\"");
assert!(built.contains("\\\"; rm -rf /"));
}
#[test]
fn test_build_shell_command_context_persists_across_parts() {
assert_eq!(
build_shell_command(&["sh -c \"echo ", " ", "\""], &["a b", "c d"]),
"sh -c \"echo a b c d\""
);
}
#[test]
fn test_has_shell_escapes() {
assert!(!has_shell_escapes("echo hello"));
assert!(!has_shell_escapes("echo \"plain text\""));
assert!(!has_shell_escapes("echo 'a \\$b'"));
assert!(has_shell_escapes("echo \"5 \\$US\""));
assert!(has_shell_escapes("echo \"say \\\"hi\\\"\""));
assert!(has_shell_escapes("echo a\\ b"));
assert!(has_shell_escapes("echo 'it'\\''s'"));
}
#[test]
fn test_is_quote_context_enabled_defaults_to_on() {
if std::env::var("COMMAND_STREAM_QUOTE_CONTEXT").is_err() {
assert!(is_quote_context_enabled());
}
}
}