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
6/// Quote a value for safe shell usage
7///
8/// This function quotes strings appropriately for use in shell commands,
9/// handling special characters and edge cases.
10///
11/// # Examples
12///
13/// ```
14/// use command_stream::quote::quote;
15///
16/// // Safe characters are passed through unchanged
17/// assert_eq!(quote("hello"), "hello");
18/// assert_eq!(quote("/path/to/file"), "/path/to/file");
19///
20/// // Special characters are quoted
21/// assert_eq!(quote("hello world"), "'hello world'");
22///
23/// // Single quotes in strings are escaped
24/// assert_eq!(quote("it's"), "'it'\\''s'");
25///
26/// // Empty strings are quoted
27/// assert_eq!(quote(""), "''");
28/// ```
29pub fn quote(value: &str) -> String {
30    if value.is_empty() {
31        return "''".to_string();
32    }
33
34    // If already properly quoted with single quotes, check if we can use as-is
35    if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
36        let inner = &value[1..value.len() - 1];
37        if !inner.contains('\'') {
38            return value.to_string();
39        }
40    }
41
42    // If already double-quoted, wrap in single quotes
43    if value.starts_with('"') && value.ends_with('"') && value.len() > 2 {
44        return format!("'{}'", value);
45    }
46
47    // Check if the string needs quoting at all
48    // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus, at
49    let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
50
51    if safe_pattern.is_match(value) {
52        return value.to_string();
53    }
54
55    // Default behavior: wrap in single quotes and escape any internal single quotes
56    // The shell escape sequence for a single quote inside single quotes is: '\''
57    // This ends the single quote, adds an escaped single quote, and starts single quotes again
58    format!("'{}'", value.replace('\'', "'\\''"))
59}
60
61/// Quote multiple values and join them with spaces
62///
63/// Convenience function for quoting a list of arguments.
64///
65/// # Examples
66///
67/// ```
68/// use command_stream::quote::quote_all;
69///
70/// let args = vec!["echo", "hello world", "test"];
71/// assert_eq!(quote_all(&args), "echo 'hello world' test");
72/// ```
73pub fn quote_all(values: &[&str]) -> String {
74    values
75        .iter()
76        .map(|v| quote(v))
77        .collect::<Vec<_>>()
78        .join(" ")
79}
80
81/// Check if a string needs quoting for shell usage
82///
83/// Returns true if the string contains characters that would be interpreted
84/// specially by the shell.
85///
86/// # Examples
87///
88/// ```
89/// use command_stream::quote::needs_quoting;
90///
91/// assert!(!needs_quoting("hello"));
92/// assert!(needs_quoting("hello world"));
93/// assert!(needs_quoting("$PATH"));
94/// ```
95pub fn needs_quoting(value: &str) -> bool {
96    if value.is_empty() {
97        return true;
98    }
99
100    let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
101    !safe_pattern.is_match(value)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_quote_empty() {
110        assert_eq!(quote(""), "''");
111    }
112
113    #[test]
114    fn test_quote_safe_chars() {
115        assert_eq!(quote("hello"), "hello");
116        assert_eq!(quote("/path/to/file"), "/path/to/file");
117        assert_eq!(quote("file.txt"), "file.txt");
118        assert_eq!(quote("key=value"), "key=value");
119        assert_eq!(quote("user@host"), "user@host");
120    }
121
122    #[test]
123    fn test_quote_special_chars() {
124        assert_eq!(quote("hello world"), "'hello world'");
125        assert_eq!(quote("it's"), "'it'\\''s'");
126        assert_eq!(quote("$var"), "'$var'");
127        assert_eq!(quote("test*"), "'test*'");
128    }
129
130    #[test]
131    fn test_quote_already_quoted() {
132        assert_eq!(quote("'already quoted'"), "'already quoted'");
133        assert_eq!(quote("\"double quoted\""), "'\"double quoted\"'");
134    }
135
136    #[test]
137    fn test_quote_all() {
138        let args = vec!["echo", "hello world", "test"];
139        assert_eq!(quote_all(&args), "echo 'hello world' test");
140    }
141
142    #[test]
143    fn test_needs_quoting() {
144        assert!(!needs_quoting("hello"));
145        assert!(!needs_quoting("/path/to/file"));
146        assert!(needs_quoting("hello world"));
147        assert!(needs_quoting("$PATH"));
148        assert!(needs_quoting(""));
149        assert!(needs_quoting("test*"));
150    }
151
152    #[test]
153    fn test_quote_with_newlines() {
154        assert_eq!(quote("line1\nline2"), "'line1\nline2'");
155    }
156
157    #[test]
158    fn test_quote_with_tabs() {
159        assert_eq!(quote("col1\tcol2"), "'col1\tcol2'");
160    }
161}