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    use proptest::prelude::*;
61
62    #[test]
63    fn quote_empty_string() {
64        assert_eq!(quote(""), "''");
65    }
66
67    #[test]
68    fn quote_plain_string() {
69        assert_eq!(quote("foo"), "'foo'");
70        assert_eq!(quote("/var/log/syslog"), "'/var/log/syslog'");
71    }
72
73    #[test]
74    fn quote_defuses_single_quote_injection() {
75        // Classic injection payload: 'foo' ; rm -rf / ; echo 'x
76        let attack = "foo'; rm -rf /; echo 'x";
77        let quoted = quote(attack);
78        assert_eq!(quoted, r#"'foo'\''; rm -rf /; echo '\''x'"#);
79        // The quoted form starts and ends with ' and contains no unescaped '.
80        assert!(quoted.starts_with('\''));
81        assert!(quoted.ends_with('\''));
82    }
83
84    #[test]
85    fn quote_handles_glob_and_substitution_meta() {
86        assert_eq!(quote("$(whoami)"), "'$(whoami)'");
87        assert_eq!(quote("*.log"), "'*.log'");
88        assert_eq!(quote("a b\tc\nd"), "'a b\tc\nd'");
89    }
90
91    #[test]
92    fn validate_pid_accepts_normal_pids() {
93        assert_eq!(validate_pid("1234").unwrap(), "1234");
94        assert_eq!(validate_pid(" 42 ").unwrap(), "42");
95    }
96
97    #[test]
98    fn validate_pid_rejects_non_numeric_and_zero() {
99        assert!(validate_pid("0").is_err());
100        assert!(validate_pid("-1").is_err());
101        assert!(validate_pid("1; rm -rf /").is_err());
102        assert!(validate_pid("").is_err());
103        assert!(validate_pid("1 2").is_err());
104    }
105
106    #[test]
107    fn validate_signal_accepts_numeric_and_names() {
108        assert_eq!(validate_signal("15").unwrap(), "15");
109        assert_eq!(validate_signal("9").unwrap(), "9");
110        assert_eq!(validate_signal("TERM").unwrap(), "TERM");
111        assert_eq!(validate_signal("sigkill").unwrap(), "KILL");
112        assert_eq!(validate_signal("SIGHUP").unwrap(), "HUP");
113    }
114
115    #[test]
116    fn validate_signal_rejects_garbage() {
117        assert!(validate_signal("0").is_err());
118        assert!(validate_signal("999").is_err());
119        assert!(validate_signal("FOO").is_err());
120        assert!(validate_signal("15; rm -rf /").is_err());
121    }
122
123    proptest! {
124        #[test]
125        fn quote_matches_posix_single_quote_escape_contract(s in ".*") {
126            let expected = format!("'{}'", s.replace('\'', "'\\''"));
127            prop_assert_eq!(quote(&s), expected);
128        }
129
130        #[test]
131        fn validate_pid_accepts_only_positive_decimal_u32(n in 1u32..=u32::MAX) {
132            let rendered = n.to_string();
133            prop_assert_eq!(validate_pid(&rendered).unwrap(), rendered.as_str());
134            let padded = format!("  {rendered}  ");
135            prop_assert_eq!(validate_pid(&padded).unwrap(), rendered.as_str());
136        }
137    }
138}