Skip to main content

ssh_mcp/ssh/
sanitize.rs

1//! Command sanitization and escaping utilities
2//!
3//! Provides functions for validating and escaping commands before SSH execution.
4
5use crate::error::{Result, SshMcpError};
6
7/// Sanitize a command before execution
8///
9/// This function:
10/// - Validates that the command is not empty
11/// - Trims whitespace
12/// - Checks length against max_chars limit
13///
14/// # Arguments
15/// * `command` - The raw command string
16/// * `max_chars` - Optional maximum character limit (None = unlimited)
17///
18/// # Returns
19/// * `Ok(String)` - The sanitized command
20/// * `Err(SshMcpError::InvalidParams)` - If command is empty or too long
21///
22/// # Examples
23/// ```
24/// use ssh_mcp::ssh::sanitize::sanitize_command;
25///
26/// let cmd = sanitize_command("  ls -la  ", Some(1000)).unwrap();
27/// assert_eq!(cmd, "ls -la");
28///
29/// // Too long command
30/// let result = sanitize_command("a".repeat(100).as_str(), Some(50));
31/// assert!(result.is_err());
32/// ```
33pub fn sanitize_command(command: &str, max_chars: Option<usize>) -> Result<String> {
34    let trimmed = command.trim();
35
36    if trimmed.is_empty() {
37        return Err(SshMcpError::invalid_params("Command cannot be empty"));
38    }
39
40    // Check length limit
41    if let Some(max) = max_chars
42        && trimmed.len() > max
43    {
44        return Err(SshMcpError::invalid_params(format!(
45            "Command is too long (max {} characters, got {})",
46            max,
47            trimmed.len()
48        )));
49    }
50
51    Ok(trimmed.to_string())
52}
53
54/// Escape a command for safe execution in shell (for pkill -f patterns)
55///
56/// This escapes characters that could cause shell injection when used
57/// inside single-quoted shell strings (like in `pkill -f 'command'`).
58///
59/// Escaped characters:
60/// - Single quotes: `'` → `'"'"'`
61/// - Dollar signs: `$` → `\$` (prevents variable expansion)
62/// - Backticks: `` ` `` → `\`` (prevents command substitution)
63/// - Backslashes: `\` → `\\` (prevents escape sequences)
64/// - Parentheses: `(` and `)` → `\(` and `\)` (prevents subshells)
65/// - Pipes: `|` → `\|` (prevents command chaining)
66///
67/// # Example
68/// ```
69/// use ssh_mcp::ssh::sanitize::escape_command_for_shell;
70///
71/// let escaped = escape_command_for_shell("echo 'hello' | cat");
72/// assert_eq!(escaped, "echo '\"'\"'hello'\"'\"' \\| cat");
73/// ```
74pub fn escape_command_for_shell(command: &str) -> String {
75    // Escape order matters for backslash - we escape it first
76    // to avoid double-escaping subsequent characters
77    crate::shell_escape::escape_for_shell(
78        &command
79            .replace('\\', "\\\\")
80            .replace('$', "\\$")
81            .replace('`', "\\`")
82            .replace('(', "\\(")
83            .replace(')', "\\)")
84            .replace('|', "\\|"),
85    )
86}
87
88/// Wrap a command for execution via POSIX shell.
89///
90/// The command payload is escaped for safe single-quoted embedding before
91/// constructing either `sh -c` or `sh -lc`.
92///
93/// # Arguments
94/// * `command` - Raw command to wrap
95/// * `login` - Use login shell mode (`sh -lc`) when true
96///
97/// # Examples
98/// ```
99/// use ssh_mcp::ssh::sanitize::wrap_in_posix_shell;
100///
101/// assert_eq!(wrap_in_posix_shell("echo hello", false), "sh -c 'echo hello'");
102/// assert_eq!(wrap_in_posix_shell("echo hello", true), "sh -lc 'echo hello'");
103/// ```
104pub fn wrap_in_posix_shell(command: &str, login: bool) -> String {
105    let escaped = escape_for_timeout_wrapper(command);
106    if login {
107        format!("sh -lc '{escaped}'")
108    } else {
109        format!("sh -c '{escaped}'")
110    }
111}
112
113/// Escapes a command for safe inclusion inside single quotes in timeout wrapper
114///
115/// This function escapes characters that would break out of single quotes
116/// when used inside the timeout wrapper: `timeout -k 2s 10s sh -lc '{cmd}'`
117///
118/// # Arguments
119/// * `command` - The raw command string
120///
121/// # Returns
122/// A safely escaped command string
123///
124/// # Examples
125/// ```
126/// use ssh_mcp::ssh::sanitize::escape_for_timeout_wrapper;
127///
128/// // Simple command
129/// assert_eq!(escape_for_timeout_wrapper("sleep 10"), "sleep 10");
130///
131/// // Command with single quotes
132/// assert_eq!(escape_for_timeout_wrapper("echo 'hello'"), "echo '\"'\"'hello'\"'\"'");
133///
134/// // Command with backslashes
135/// assert_eq!(escape_for_timeout_wrapper(r"echo \$HOME"), r"echo \$HOME");
136/// ```
137pub fn escape_for_timeout_wrapper(command: &str) -> String {
138    // The command is placed inside single quotes, so backslashes are already preserved.
139    // We only need to escape single quotes to keep the wrapper syntax valid.
140    crate::shell_escape::escape_for_shell(command)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_sanitize_command_valid() {
149        let result = sanitize_command("ls -la", Some(1000));
150        assert!(result.is_ok());
151        assert_eq!(result.unwrap(), "ls -la");
152    }
153
154    #[test]
155    fn test_sanitize_command_trims_whitespace() {
156        let result = sanitize_command("  ls -la  ", Some(1000));
157        assert!(result.is_ok());
158        assert_eq!(result.unwrap(), "ls -la");
159    }
160
161    #[test]
162    fn test_sanitize_command_empty() {
163        let result = sanitize_command("", Some(1000));
164        assert!(result.is_err());
165        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
166    }
167
168    #[test]
169    fn test_sanitize_command_whitespace_only() {
170        let result = sanitize_command("   ", Some(1000));
171        assert!(result.is_err());
172        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
173    }
174
175    #[test]
176    fn test_sanitize_command_too_long() {
177        let long_cmd = "a".repeat(100);
178        let result = sanitize_command(&long_cmd, Some(50));
179        assert!(result.is_err());
180        assert!(result.unwrap_err().to_string().contains("too long"));
181    }
182
183    #[test]
184    fn test_sanitize_command_exactly_at_limit() {
185        let cmd = "a".repeat(50);
186        let result = sanitize_command(&cmd, Some(50));
187        assert!(result.is_ok());
188    }
189
190    #[test]
191    fn test_sanitize_command_unlimited() {
192        let long_cmd = "a".repeat(10000);
193        let result = sanitize_command(&long_cmd, None);
194        assert!(result.is_ok());
195    }
196
197    #[test]
198    fn test_escape_command_for_shell_no_quotes() {
199        let escaped = escape_command_for_shell("ls -la");
200        assert_eq!(escaped, "ls -la");
201    }
202
203    #[test]
204    fn test_escape_command_for_shell_with_quotes() {
205        let escaped = escape_command_for_shell("echo 'hello'");
206        assert_eq!(escaped, "echo '\"'\"'hello'\"'\"'");
207    }
208
209    #[test]
210    fn test_escape_command_for_shell_dollar_sign() {
211        let escaped = escape_command_for_shell("echo $HOME");
212        assert_eq!(escaped, "echo \\$HOME");
213    }
214
215    #[test]
216    fn test_escape_command_for_shell_backtick() {
217        let escaped = escape_command_for_shell("echo `date`");
218        assert_eq!(escaped, "echo \\`date\\`");
219    }
220
221    #[test]
222    fn test_escape_command_for_shell_backslash() {
223        let escaped = escape_command_for_shell("echo \\n");
224        assert_eq!(escaped, "echo \\\\n");
225    }
226
227    #[test]
228    fn test_escape_command_for_shell_parentheses() {
229        let escaped = escape_command_for_shell("echo (test)");
230        assert_eq!(escaped, "echo \\(test\\)");
231    }
232
233    #[test]
234    fn test_escape_command_for_shell_pipe() {
235        let escaped = escape_command_for_shell("cat file | grep test");
236        assert_eq!(escaped, "cat file \\| grep test");
237    }
238
239    #[test]
240    fn test_escape_command_for_shell_combined_special_chars() {
241        let escaped = escape_command_for_shell("echo '$HOME' | cat");
242        assert_eq!(escaped, "echo '\"'\"'\\$HOME'\"'\"' \\| cat");
243    }
244
245    #[test]
246    fn test_escape_command_for_shell_multiple_quotes() {
247        let escaped = escape_command_for_shell("echo 'a' 'b'");
248        assert_eq!(escaped, "echo '\"'\"'a'\"'\"' '\"'\"'b'\"'\"'");
249    }
250
251    #[test]
252    fn test_escape_command_for_shell_empty() {
253        let escaped = escape_command_for_shell("");
254        assert_eq!(escaped, "");
255    }
256
257    #[test]
258    fn test_escape_for_timeout_wrapper_no_special_chars() {
259        let escaped = escape_for_timeout_wrapper("sleep 10");
260        assert_eq!(escaped, "sleep 10");
261    }
262
263    #[test]
264    fn test_escape_for_timeout_wrapper_with_single_quotes() {
265        let escaped = escape_for_timeout_wrapper("echo 'hello'");
266        assert_eq!(escaped, "echo '\"'\"'hello'\"'\"'");
267    }
268
269    #[test]
270    fn test_escape_for_timeout_wrapper_with_backslashes() {
271        let escaped = escape_for_timeout_wrapper("echo \\$HOME");
272        assert_eq!(escaped, "echo \\$HOME");
273    }
274
275    #[test]
276    fn test_escape_for_timeout_wrapper_with_both_quotes_and_backslashes() {
277        let escaped = escape_for_timeout_wrapper("echo '$HOME'");
278        assert_eq!(escaped, "echo '\"'\"'$HOME'\"'\"'");
279    }
280
281    #[test]
282    fn test_escape_for_timeout_wrapper_empty() {
283        let escaped = escape_for_timeout_wrapper("");
284        assert_eq!(escaped, "");
285    }
286
287    #[test]
288    fn test_escape_for_timeout_wrapper_multiple_quotes() {
289        let escaped = escape_for_timeout_wrapper("echo 'a' 'b'");
290        assert_eq!(escaped, "echo '\"'\"'a'\"'\"' '\"'\"'b'\"'\"'");
291    }
292
293    #[test]
294    fn test_wrap_in_posix_shell_non_login() {
295        let wrapped = wrap_in_posix_shell("ls -la", false);
296        assert_eq!(wrapped, "sh -c 'ls -la'");
297    }
298
299    #[test]
300    fn test_wrap_in_posix_shell_login() {
301        let wrapped = wrap_in_posix_shell("ls -la", true);
302        assert_eq!(wrapped, "sh -lc 'ls -la'");
303    }
304
305    #[test]
306    fn test_wrap_in_posix_shell_with_embedded_single_quotes() {
307        let wrapped = wrap_in_posix_shell("echo 'hello'", false);
308        assert_eq!(wrapped, "sh -c 'echo '\"'\"'hello'\"'\"''");
309    }
310
311    #[test]
312    fn test_wrap_in_posix_shell_empty_command() {
313        let wrapped = wrap_in_posix_shell("", false);
314        assert_eq!(wrapped, "sh -c ''");
315    }
316
317    #[test]
318    fn test_wrap_in_posix_shell_preserves_dollar_syntax() {
319        let wrapped = wrap_in_posix_shell("echo $HOME", false);
320        assert_eq!(wrapped, "sh -c 'echo $HOME'");
321    }
322
323    #[test]
324    fn test_wrap_in_posix_shell_preserves_pipe_syntax() {
325        let wrapped = wrap_in_posix_shell("printf test | wc -c", false);
326        assert_eq!(wrapped, "sh -c 'printf test | wc -c'");
327    }
328
329    #[test]
330    fn test_wrap_in_posix_shell_preserves_command_substitution_syntax() {
331        let wrapped = wrap_in_posix_shell("echo `whoami` $(pwd)", false);
332        assert_eq!(wrapped, "sh -c 'echo `whoami` $(pwd)'");
333    }
334}