1#![forbid(unsafe_code)]
4use secrecy::{ExposeSecret, SecretString};
18use zeroize::Zeroize;
19
20#[must_use]
25pub fn escape_shell_single_quotes(value: &str) -> String {
26 let mut result = String::with_capacity(value.len() + 2);
27 result.push('\'');
28 for ch in value.chars() {
29 if ch == '\'' {
30 result.push_str("'\\''");
31 } else {
32 result.push(ch);
33 }
34 }
35 result.push('\'');
36 result
37}
38
39#[must_use]
41pub fn append_description(command: &str, description: Option<&str>) -> String {
42 match description {
43 Some(d) if !d.trim().is_empty() => {
44 let cleaned = d.replace(['\n', '\r'], " ");
45 format!("{command} # {cleaned}")
46 }
47 _ => command.to_string(),
48 }
49}
50
51#[derive(Clone)]
58pub struct PackedCommand {
59 pub command: String,
61 pub stdin: Option<Vec<u8>>,
63}
64
65impl std::fmt::Debug for PackedCommand {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 f.debug_struct("PackedCommand")
68 .field("command", &self.command)
69 .field("stdin", &self.stdin.as_ref().map(|_| "<redacted bytes>"))
70 .finish()
71 }
72}
73
74impl Drop for PackedCommand {
75 fn drop(&mut self) {
76 if let Some(ref mut bytes) = self.stdin {
77 bytes.zeroize();
78 }
79 }
80}
81
82impl PackedCommand {
83 #[must_use]
87 pub fn take_stdin(&mut self) -> Option<Vec<u8>> {
88 self.stdin.take()
89 }
90}
91
92#[must_use]
97pub fn pack_sudo(command: &str, sudo_password: Option<&SecretString>) -> PackedCommand {
98 let cmd_esc = escape_shell_single_quotes(command);
99 match sudo_password {
100 Some(password) => {
101 let mut stdin = password.expose_secret().as_bytes().to_vec();
102 stdin.push(b'\n');
103 PackedCommand {
104 command: format!("sudo -S -p '' sh -c {cmd_esc}"),
105 stdin: Some(stdin),
106 }
107 }
108 None => PackedCommand {
109 command: format!("sudo -n sh -c {cmd_esc}"),
110 stdin: None,
111 },
112 }
113}
114
115#[must_use]
117pub fn pack_su(command: &str, su_password: &SecretString) -> PackedCommand {
118 let cmd_esc = escape_shell_single_quotes(command);
119 let mut stdin = su_password.expose_secret().as_bytes().to_vec();
120 stdin.push(b'\n');
121 PackedCommand {
122 command: format!("su - -c {cmd_esc}"),
123 stdin: Some(stdin),
124 }
125}
126
127#[must_use]
132pub fn remote_abort_pattern(command: &str) -> Option<String> {
133 let mut cleaned = String::with_capacity(command.len().min(128));
134 for ch in command.chars().take(128) {
135 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ' ' | ':' | '=') {
136 cleaned.push(ch);
137 } else {
138 break;
139 }
140 }
141 let trimmed = cleaned.trim();
143 if trimmed.len() < 3 {
144 None
145 } else if trimmed.len() == cleaned.len() {
146 Some(cleaned)
147 } else {
148 Some(trimmed.to_string())
149 }
150}
151
152#[must_use]
156pub fn pack_abort_pkill(pattern: &str) -> String {
157 let esc = escape_shell_single_quotes(pattern);
158 format!(
159 "(pkill -TERM -f {esc} 2>/dev/null || true); sleep 0.2; (pkill -KILL -f {esc} 2>/dev/null || true)"
160 )
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn escape_single_quote() {
169 assert_eq!(escape_shell_single_quotes("ab'cd"), "'ab'\\''cd'");
170 assert_eq!(escape_shell_single_quotes("abc"), "'abc'");
171 }
172
173 #[test]
174 fn sudo_with_password_uses_sh_c_no_secret_in_argv() {
175 let password = SecretString::from("s3cr3t".to_string());
176 let pack = pack_sudo("echo hi | tee /tmp/x", Some(&password));
177 assert!(pack.command.contains("sudo -S -p '' sh -c"));
178 assert!(!pack.command.contains("s3cr3t"));
179 assert!(!pack.command.contains("printf"));
180 let mut pack = pack;
181 let stdin = pack.take_stdin().expect("stdin with password");
182 assert_eq!(stdin, b"s3cr3t\n");
183 }
184
185 #[test]
186 fn sudo_without_password_uses_n() {
187 let pack = pack_sudo("id", None);
188 assert_eq!(pack.command, "sudo -n sh -c 'id'");
189 assert!(pack.stdin.is_none());
190 }
191
192 #[test]
193 fn su_pack_no_secret_in_argv() {
194 let password = SecretString::from("rootpw".to_string());
195 let pack = pack_su("whoami", &password);
196 assert!(pack.command.contains("su - -c"));
197 assert!(!pack.command.contains("rootpw"));
198 assert_eq!(pack.stdin.as_deref(), Some(b"rootpw\n".as_slice()));
199 }
200
201 #[test]
202 fn description_appends_comment() {
203 assert_eq!(
204 append_description("ls", Some("lista arquivos")),
205 "ls # lista arquivos"
206 );
207 assert_eq!(append_description("ls", None), "ls");
208 }
209
210 #[test]
211 fn debug_redacts_stdin() {
212 let password = SecretString::from("s3cr3t".to_string());
213 let pack = pack_sudo("id", Some(&password));
214 let dbg = format!("{pack:?}");
215 assert!(!dbg.contains("s3cr3t"));
216 assert!(dbg.contains("<redacted bytes>"));
217 }
218
219 #[test]
220 fn abort_pattern_sanitizes() {
221 assert_eq!(
222 remote_abort_pattern("sleep 999"),
223 Some("sleep 999".to_string())
224 );
225 assert_eq!(remote_abort_pattern("$(rm -rf)"), None);
227 assert!(remote_abort_pattern("ab").is_none());
228 }
229}