ssh_commander_core/ssh/
shell.rs1pub 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
25pub 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
35pub 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 let attack = "foo'; rm -rf /; echo 'x";
77 let quoted = quote(attack);
78 assert_eq!(quoted, r#"'foo'\''; rm -rf /; echo '\''x'"#);
79 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}