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
127const ABORT_MARKER_RANDOM_BYTES: usize = 16;
133
134const ABORT_MARKER_PREFIX: &str = "sshcli-job-";
139
140const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
142
143#[must_use]
159pub fn new_remote_job_marker() -> Option<String> {
160 let mut raw = [0u8; ABORT_MARKER_RANDOM_BYTES];
161 if getrandom::fill(&mut raw).is_err() {
162 return None;
163 }
164 let mut marker = String::with_capacity(ABORT_MARKER_PREFIX.len() + raw.len() * 2);
165 marker.push_str(ABORT_MARKER_PREFIX);
166 for byte in raw {
167 marker.push(HEX_DIGITS[usize::from(byte >> 4)] as char);
168 marker.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char);
169 }
170 Some(marker)
171}
172
173#[must_use]
178pub fn is_remote_job_marker(value: &str) -> bool {
179 value.strip_prefix(ABORT_MARKER_PREFIX).is_some_and(|hex| {
180 hex.len() == ABORT_MARKER_RANDOM_BYTES * 2 && hex.bytes().all(|b| b.is_ascii_hexdigit())
181 })
182}
183
184#[must_use]
196pub fn wrap_with_abort_marker(command: &str, marker: &str) -> String {
197 let cmd_esc = escape_shell_single_quotes(command);
198 let marker_esc = escape_shell_single_quotes(marker);
199 format!("sh -c {cmd_esc} {marker_esc}")
200}
201
202#[must_use]
210pub fn remote_abort_pattern(command: &str) -> Option<String> {
211 let mut cleaned = String::with_capacity(command.len().min(128));
212 for ch in command.chars().take(128) {
213 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ' ' | ':' | '=') {
214 cleaned.push(ch);
215 } else {
216 break;
217 }
218 }
219 let trimmed = cleaned.trim();
221 if trimmed.len() < 3 {
222 None
223 } else if trimmed.len() == cleaned.len() {
224 Some(cleaned)
225 } else {
226 Some(trimmed.to_string())
227 }
228}
229
230#[must_use]
236pub fn pack_abort_pkill(pattern: &str) -> String {
237 let esc = escape_shell_single_quotes(pattern);
238 format!(
239 "(pkill -TERM -f {esc} 2>/dev/null || true); sleep 0.2; (pkill -KILL -f {esc} 2>/dev/null || true)"
240 )
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn escape_single_quote() {
249 assert_eq!(escape_shell_single_quotes("ab'cd"), "'ab'\\''cd'");
250 assert_eq!(escape_shell_single_quotes("abc"), "'abc'");
251 }
252
253 #[test]
254 fn sudo_with_password_uses_sh_c_no_secret_in_argv() {
255 let password = SecretString::from("s3cr3t".to_string());
256 let pack = pack_sudo("echo hi | tee /tmp/x", Some(&password));
257 assert!(pack.command.contains("sudo -S -p '' sh -c"));
258 assert!(!pack.command.contains("s3cr3t"));
259 assert!(!pack.command.contains("printf"));
260 let mut pack = pack;
261 let stdin = pack.take_stdin().expect("stdin with password");
262 assert_eq!(stdin, b"s3cr3t\n");
263 }
264
265 #[test]
266 fn sudo_without_password_uses_n() {
267 let pack = pack_sudo("id", None);
268 assert_eq!(pack.command, "sudo -n sh -c 'id'");
269 assert!(pack.stdin.is_none());
270 }
271
272 #[test]
273 fn su_pack_no_secret_in_argv() {
274 let password = SecretString::from("rootpw".to_string());
275 let pack = pack_su("whoami", &password);
276 assert!(pack.command.contains("su - -c"));
277 assert!(!pack.command.contains("rootpw"));
278 assert_eq!(pack.stdin.as_deref(), Some(b"rootpw\n".as_slice()));
279 }
280
281 #[test]
282 fn description_appends_comment() {
283 assert_eq!(
284 append_description("ls", Some("lista arquivos")),
285 "ls # lista arquivos"
286 );
287 assert_eq!(append_description("ls", None), "ls");
288 }
289
290 #[test]
291 fn debug_redacts_stdin() {
292 let password = SecretString::from("s3cr3t".to_string());
293 let pack = pack_sudo("id", Some(&password));
294 let dbg = format!("{pack:?}");
295 assert!(!dbg.contains("s3cr3t"));
296 assert!(dbg.contains("<redacted bytes>"));
297 }
298
299 #[test]
300 fn abort_pattern_sanitizes() {
301 assert_eq!(
302 remote_abort_pattern("sleep 999"),
303 Some("sleep 999".to_string())
304 );
305 assert_eq!(remote_abort_pattern("$(rm -rf)"), None);
307 assert!(remote_abort_pattern("ab").is_none());
308 }
309
310 #[test]
311 fn job_markers_are_unique_per_invocation() {
312 let a = new_remote_job_marker().expect("csprng available");
314 let b = new_remote_job_marker().expect("csprng available");
315 assert_ne!(a, b);
316 assert!(is_remote_job_marker(&a) && is_remote_job_marker(&b));
317
318 let kill_a = pack_abort_pkill(&a);
319 let kill_b = pack_abort_pkill(&b);
320 assert!(!kill_a.contains(&b));
323 assert!(!kill_b.contains(&a));
324
325 let cmd_a = wrap_with_abort_marker("sleep 999", &a);
326 let cmd_b = wrap_with_abort_marker("sleep 999", &b);
327 assert!(cmd_a.contains(&a) && !cmd_a.contains(&b));
328 assert!(cmd_b.contains(&b) && !cmd_b.contains(&a));
329 }
330
331 #[test]
332 fn abort_marker_never_matches_third_party_sudo() {
333 let password = SecretString::from("s3cr3t".to_string());
336 let pack = pack_sudo("systemctl restart nginx", Some(&password));
337 let marker = new_remote_job_marker().expect("csprng available");
338 let kill = pack_abort_pkill(&marker);
339 assert!(!kill.contains("sudo"));
340 assert!(kill.contains(&marker));
341
342 let wrapped = wrap_with_abort_marker(&pack.command, &marker);
344 assert!(!wrapped.contains("s3cr3t"));
345 assert!(wrapped.contains(&marker));
346 }
347
348 #[test]
349 fn marker_shape_is_validated() {
350 assert!(!is_remote_job_marker("sudo -S -p"));
351 assert!(!is_remote_job_marker("sshcli-job-"));
352 assert!(!is_remote_job_marker("sshcli-job-zz"));
353 }
354}