Skip to main content

ssh_commander_core/ssh/
shell.rs

1//! Safe argument quoting for remote shell commands.
2//!
3//! Every path or user-supplied string that is about to be interpolated into a
4//! POSIX shell command must go through `quote` to defeat argument splitting,
5//! globbing, and command injection. A filename containing `'` or `;` or `$(` is
6//! otherwise a direct RCE against the remote host.
7
8/// Wrap `s` in single quotes, escaping any embedded single quotes using the
9/// portable POSIX idiom `'\''`. Always returns a quoted string, even for the
10/// empty string — callers should never concatenate unquoted.
11pub fn quote(s: &str) -> String {
12    let mut out = String::with_capacity(s.len() + 2);
13    out.push('\'');
14    for ch in s.chars() {
15        if ch == '\'' {
16            out.push_str("'\\''");
17        } else {
18            out.push(ch);
19        }
20    }
21    out.push('\'');
22    out
23}
24
25/// Validate that `s` is a plain decimal integer in `1..=u32::MAX`, suitable for
26/// use as a PID. Returns the original string if valid.
27pub fn validate_pid(s: &str) -> Result<&str, String> {
28    let trimmed = s.trim();
29    match trimmed.parse::<u32>() {
30        Ok(n) if n >= 1 => Ok(trimmed),
31        _ => Err(format!("Invalid PID: {:?}", s)),
32    }
33}
34
35/// Validate a POSIX kill signal. Accepts a numeric signal in `1..=64` or one of
36/// the common signal names. Returns the canonical form to interpolate directly.
37pub fn validate_signal(s: &str) -> Result<String, String> {
38    const NAMES: &[&str] = &[
39        "HUP", "INT", "QUIT", "ILL", "TRAP", "ABRT", "BUS", "FPE", "KILL", "USR1", "SEGV", "USR2",
40        "PIPE", "ALRM", "TERM", "STKFLT", "CHLD", "CONT", "STOP", "TSTP", "TTIN", "TTOU", "URG",
41        "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "IO", "PWR", "SYS",
42    ];
43    let trimmed = s.trim().to_ascii_uppercase();
44    let trimmed = trimmed.trim_start_matches("SIG").to_string();
45    if let Ok(n) = trimmed.parse::<u32>() {
46        if (1..=64).contains(&n) {
47            return Ok(n.to_string());
48        }
49        return Err(format!("Signal out of range: {}", n));
50    }
51    if NAMES.iter().any(|&n| n == trimmed) {
52        return Ok(trimmed);
53    }
54    Err(format!("Unknown signal: {:?}", s))
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn quote_empty_string() {
63        assert_eq!(quote(""), "''");
64    }
65
66    #[test]
67    fn quote_plain_string() {
68        assert_eq!(quote("foo"), "'foo'");
69        assert_eq!(quote("/var/log/syslog"), "'/var/log/syslog'");
70    }
71
72    #[test]
73    fn quote_defuses_single_quote_injection() {
74        // Classic injection payload: 'foo' ; rm -rf / ; echo 'x
75        let attack = "foo'; rm -rf /; echo 'x";
76        let quoted = quote(attack);
77        assert_eq!(quoted, r#"'foo'\''; rm -rf /; echo '\''x'"#);
78        // The quoted form starts and ends with ' and contains no unescaped '.
79        assert!(quoted.starts_with('\''));
80        assert!(quoted.ends_with('\''));
81    }
82
83    #[test]
84    fn quote_handles_glob_and_substitution_meta() {
85        assert_eq!(quote("$(whoami)"), "'$(whoami)'");
86        assert_eq!(quote("*.log"), "'*.log'");
87        assert_eq!(quote("a b\tc\nd"), "'a b\tc\nd'");
88    }
89
90    #[test]
91    fn validate_pid_accepts_normal_pids() {
92        assert_eq!(validate_pid("1234").unwrap(), "1234");
93        assert_eq!(validate_pid(" 42 ").unwrap(), "42");
94    }
95
96    #[test]
97    fn validate_pid_rejects_non_numeric_and_zero() {
98        assert!(validate_pid("0").is_err());
99        assert!(validate_pid("-1").is_err());
100        assert!(validate_pid("1; rm -rf /").is_err());
101        assert!(validate_pid("").is_err());
102        assert!(validate_pid("1 2").is_err());
103    }
104
105    #[test]
106    fn validate_signal_accepts_numeric_and_names() {
107        assert_eq!(validate_signal("15").unwrap(), "15");
108        assert_eq!(validate_signal("9").unwrap(), "9");
109        assert_eq!(validate_signal("TERM").unwrap(), "TERM");
110        assert_eq!(validate_signal("sigkill").unwrap(), "KILL");
111        assert_eq!(validate_signal("SIGHUP").unwrap(), "HUP");
112    }
113
114    #[test]
115    fn validate_signal_rejects_garbage() {
116        assert!(validate_signal("0").is_err());
117        assert!(validate_signal("999").is_err());
118        assert!(validate_signal("FOO").is_err());
119        assert!(validate_signal("15; rm -rf /").is_err());
120    }
121}