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/// Quote multiple values and join them with spaces
65///
66/// Convenience function for quoting a list of arguments.
67///
68/// # Examples
69///
70/// ```
71/// use command_stream::quote::quote_all;
72///
73/// let args = vec!["echo", "hello world", "test"];
74/// assert_eq!(quote_all(&args), "echo 'hello world' test");
75/// ```
76pub fn quote_all(values: &[&str]) -> String {
77    values
78        .iter()
79        .map(|v| quote(v))
80        .collect::<Vec<_>>()
81        .join(" ")
82}
83
84/// Check if a string needs quoting for shell usage
85///
86/// Returns true if the string contains characters that would be interpreted
87/// specially by the shell.
88///
89/// # Examples
90///
91/// ```
92/// use command_stream::quote::needs_quoting;
93///
94/// assert!(!needs_quoting("hello"));
95/// assert!(needs_quoting("hello world"));
96/// assert!(needs_quoting("$PATH"));
97/// ```
98pub fn needs_quoting(value: &str) -> bool {
99    if value.is_empty() {
100        return true;
101    }
102
103    let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
104    !safe_pattern.is_match(value)
105}
106
107/// Scan a built command string for an unquoted Go/Handlebars-style template
108/// token (`{{ ... }}`) that contains an unquoted space.
109///
110/// Such a token is split by the shell (and by command-stream, which mirrors
111/// shell word-splitting) into multiple argv words, so `--format {{json .X}}`
112/// reaches the child as `--format`, `{{json`, `.X}}` — exactly what a POSIX
113/// shell would do, but surprising for Go templates. Returns the offending
114/// snippet so callers can point the user at the gotcha.
115///
116/// # Examples
117///
118/// ```
119/// use command_stream::quote::find_split_template_token;
120///
121/// assert_eq!(
122///     find_split_template_token("docker inspect --format {{json .Config.Env}}"),
123///     Some("{{json .Config.Env}}".to_string())
124/// );
125/// // Space-free or quoted tokens are not flagged.
126/// assert_eq!(find_split_template_token("docker inspect --format {{.Id}}"), None);
127/// assert_eq!(
128///     find_split_template_token("docker inspect --format '{{json .Config.Env}}'"),
129///     None
130/// );
131/// ```
132pub fn find_split_template_token(command: &str) -> Option<String> {
133    if !command.contains("{{") {
134        return None;
135    }
136
137    let chars: Vec<char> = command.chars().collect();
138    let n = chars.len();
139    let mut in_single = false;
140    let mut in_double = false;
141    let mut i = 0;
142    while i < n {
143        let c = chars[i];
144        if in_single {
145            in_single = c != '\'';
146            i += 1;
147            continue;
148        }
149        if in_double {
150            in_double = c != '"';
151            i += 1;
152            continue;
153        }
154        if c == '\'' {
155            in_single = true;
156            i += 1;
157            continue;
158        }
159        if c == '"' {
160            in_double = true;
161            i += 1;
162            continue;
163        }
164
165        // An unquoted `{{` — scan forward for its matching `}}`, reporting it
166        // when an unquoted space appears in between (which triggers splitting).
167        if c == '{' && i + 1 < n && chars[i + 1] == '{' {
168            let (splits, end) = scan_template_close(&chars, i + 2);
169            if splits {
170                return Some(chars[i..=end + 1].iter().collect());
171            }
172            i = end + 1;
173            continue;
174        }
175        i += 1;
176    }
177
178    None
179}
180
181/// Starting just after an unquoted `{{`, scan to the matching unquoted `}}`,
182/// tracking whether an unquoted space appears in between.
183///
184/// Returns `(splits, end_index)` where `splits` is true when a closing `}}`
185/// was found with an intervening unquoted space, and `end_index` points at the
186/// first `}` of that closing pair (or the end of input when no `}}` is found).
187fn scan_template_close(chars: &[char], start: usize) -> (bool, usize) {
188    let n = chars.len();
189    let mut j = start;
190    let mut has_unquoted_space = false;
191    let mut in_single = false;
192    let mut in_double = false;
193    while j < n {
194        let c = chars[j];
195        if in_single {
196            in_single = c != '\'';
197        } else if in_double {
198            in_double = c != '"';
199        } else if c == '\'' {
200            in_single = true;
201        } else if c == '"' {
202            in_double = true;
203        } else if c == '}' && j + 1 < n && chars[j + 1] == '}' {
204            return (has_unquoted_space, j);
205        } else if c.is_whitespace() {
206            has_unquoted_space = true;
207        }
208        j += 1;
209    }
210    (false, j)
211}
212
213fn warned_template_snippets() -> &'static Mutex<HashSet<String>> {
214    static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
215    WARNED.get_or_init(|| Mutex::new(HashSet::new()))
216}
217
218/// Emit a one-line diagnostic when a built command contains an unquoted Go
219/// template token with an internal space. This points users at the
220/// shell-splitting gotcha behind the cryptic downstream errors (e.g. Go's
221/// "unclosed action"). Silenced via `COMMAND_STREAM_NO_TEMPLATE_WARNING`, and
222/// each unique snippet is only reported once per process.
223pub fn warn_on_split_template(command: &str) {
224    if std::env::var_os("COMMAND_STREAM_NO_TEMPLATE_WARNING").is_some() {
225        return;
226    }
227    let snippet = match find_split_template_token(command) {
228        Some(s) => s,
229        None => return,
230    };
231    {
232        let mut warned = warned_template_snippets().lock().unwrap();
233        if !warned.insert(snippet.clone()) {
234            return;
235        }
236    }
237    eprintln!(
238        "[command-stream] Warning: template token `{snippet}` contains an \
239unquoted space, so the shell splits it into multiple arguments (just like \
240bash would). Quote it ('{snippet}') or interpolate it as a single ${{value}} \
241to pass it as one argument. See README \"Go templates & {{{{ }}}} arguments\". \
242Set COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence."
243    );
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_quote_empty() {
252        assert_eq!(quote(""), "''");
253    }
254
255    #[test]
256    fn test_quote_safe_chars() {
257        assert_eq!(quote("hello"), "hello");
258        assert_eq!(quote("/path/to/file"), "/path/to/file");
259        assert_eq!(quote("file.txt"), "file.txt");
260        assert_eq!(quote("key=value"), "key=value");
261        assert_eq!(quote("user@host"), "user@host");
262    }
263
264    #[test]
265    fn test_quote_special_chars() {
266        assert_eq!(quote("hello world"), "'hello world'");
267        assert_eq!(quote("it's"), "'it'\\''s'");
268        assert_eq!(quote("$var"), "'$var'");
269        assert_eq!(quote("test*"), "'test*'");
270    }
271
272    #[test]
273    fn test_quote_already_quoted() {
274        assert_eq!(quote("'already quoted'"), "'already quoted'");
275        assert_eq!(quote("\"double quoted\""), "'\"double quoted\"'");
276    }
277
278    #[test]
279    fn test_quote_all() {
280        let args = vec!["echo", "hello world", "test"];
281        assert_eq!(quote_all(&args), "echo 'hello world' test");
282    }
283
284    #[test]
285    fn test_needs_quoting() {
286        assert!(!needs_quoting("hello"));
287        assert!(!needs_quoting("/path/to/file"));
288        assert!(needs_quoting("hello world"));
289        assert!(needs_quoting("$PATH"));
290        assert!(needs_quoting(""));
291        assert!(needs_quoting("test*"));
292    }
293
294    #[test]
295    fn test_quote_with_newlines() {
296        assert_eq!(quote("line1\nline2"), "'line1\nline2'");
297    }
298
299    #[test]
300    fn test_quote_with_tabs() {
301        assert_eq!(quote("col1\tcol2"), "'col1\tcol2'");
302    }
303
304    #[test]
305    fn test_find_split_template_unquoted_with_space() {
306        assert_eq!(
307            find_split_template_token("docker inspect --format {{json .Config.Env}}"),
308            Some("{{json .Config.Env}}".to_string())
309        );
310    }
311
312    #[test]
313    fn test_find_split_template_space_free() {
314        assert_eq!(
315            find_split_template_token("docker inspect --format {{.Id}}"),
316            None
317        );
318    }
319
320    #[test]
321    fn test_find_split_template_single_quoted() {
322        assert_eq!(
323            find_split_template_token("docker inspect --format '{{json .Config.Env}}'"),
324            None
325        );
326    }
327
328    #[test]
329    fn test_find_split_template_double_quoted() {
330        assert_eq!(
331            find_split_template_token("docker inspect --format \"{{json .Config.Env}}\""),
332            None
333        );
334    }
335
336    #[test]
337    fn test_find_split_template_none_without_braces() {
338        assert_eq!(find_split_template_token("echo hello world"), None);
339    }
340}