Skip to main content

ssh_cli/ssh/
packing.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Empacotamento seguro de comandos sudo/su (one-shot multi-host para LLMs).
3//!
4//! Usa `sh -c` com escape de aspas para comandos compostos seguros.
5
6use secrecy::{ExposeSecret, SecretString};
7
8/// Escapes a string for safe use inside shell single quotes.
9///
10/// Strategy: wrap in single quotes and escape inner single quotes
11/// with the sequence `'\''` (close quote, backslash-quote, open quote).
12#[must_use]
13pub fn escape_shell_single_quotes(value: &str) -> String {
14    let mut result = String::with_capacity(value.len() + 2);
15    result.push('\'');
16    for ch in value.chars() {
17        if ch == '\'' {
18            result.push_str("'\\''");
19        } else {
20            result.push(ch);
21        }
22    }
23    result.push('\'');
24    result
25}
26
27/// Appends `description` as a shell comment safely.
28#[must_use]
29pub fn append_description(command: &str, description: Option<&str>) -> String {
30    match description {
31        Some(d) if !d.trim().is_empty() => {
32            let limpo = d.replace(['\n', '\r'], " ");
33            format!("{command} # {limpo}")
34        }
35        _ => command.to_string(),
36    }
37}
38
39/// Packing result: remote command **without** secret in argv + optional bytes
40/// to send on the SSH channel stdin (GAP-SSH-SEC-001).
41#[derive(Debug, Clone)]
42pub struct PackedCommand {
43    /// Remote command line (no embedded password).
44    pub command: String,
45    /// Bytes to write on channel stdin (e.g. password + `\n` for `sudo -S` / `su`).
46    pub stdin: Option<Vec<u8>>,
47}
48
49/// Packs a command for `sudo` with `sh -c`.
50///
51/// - With password: `sudo -S -p '' sh -c 'cmd'` and password on the **channel stdin** (not argv).
52/// - Sem password: `sudo -n sh -c 'cmd'`.
53#[must_use]
54pub fn pack_sudo(command: &str, sudo_password: Option<&SecretString>) -> PackedCommand {
55    let cmd_esc = escape_shell_single_quotes(command);
56    match sudo_password {
57        Some(password) => {
58            let mut stdin = password.expose_secret().as_bytes().to_vec();
59            stdin.push(b'\n');
60            PackedCommand {
61                command: format!("sudo -S -p '' sh -c {cmd_esc}"),
62                stdin: Some(stdin),
63            }
64        }
65        None => PackedCommand {
66            command: format!("sudo -n sh -c {cmd_esc}"),
67            stdin: None,
68        },
69    }
70}
71
72/// Packs a command for `su - -c` one-shot; password goes on the channel stdin.
73#[must_use]
74pub fn pack_su(command: &str, su_password: &SecretString) -> PackedCommand {
75    let cmd_esc = escape_shell_single_quotes(command);
76    let mut stdin = su_password.expose_secret().as_bytes().to_vec();
77    stdin.push(b'\n');
78    PackedCommand {
79        command: format!("su - -c {cmd_esc}"),
80        stdin: Some(stdin),
81    }
82}
83
84/// Sanitiza trecho de command para uso best-effort em `pkill -f`.
85///
86/// Accepts alphanumerics and restricted symbols; stops at the first metacharacter
87/// dangerous. Requires at least 3 chars. Never embeds passwords (only the command pattern).
88#[must_use]
89pub fn remote_abort_pattern(command: &str) -> Option<String> {
90    let mut limpo = String::with_capacity(command.len().min(128));
91    for ch in command.chars().take(128) {
92        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ' ' | ':' | '=') {
93            limpo.push(ch);
94        } else {
95            break;
96        }
97    }
98    let t = limpo.trim();
99    if t.len() < 3 {
100        None
101    } else {
102        Some(t.to_string())
103    }
104}
105
106/// Monta command de abort best-effort remoto (TERM depois KILL).
107///
108/// Does not embed secrets; uses only the sanitized command pattern.
109#[must_use]
110pub fn pack_abort_pkill(pattern: &str) -> String {
111    let esc = escape_shell_single_quotes(pattern);
112    format!(
113        "(pkill -TERM -f {esc} 2>/dev/null || true); sleep 0.2; (pkill -KILL -f {esc} 2>/dev/null || true)"
114    )
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn escape_single_quote() {
123        assert_eq!(escape_shell_single_quotes("ab'cd"), "'ab'\\''cd'");
124        assert_eq!(escape_shell_single_quotes("abc"), "'abc'");
125    }
126
127    #[test]
128    fn sudo_with_password_uses_sh_c_no_secret_in_argv() {
129        let password = SecretString::from("s3cr3t".to_string());
130        let pack = pack_sudo("echo hi | tee /tmp/x", Some(&password));
131        assert!(pack.command.contains("sudo -S -p '' sh -c"));
132        assert!(!pack.command.contains("s3cr3t"));
133        assert!(!pack.command.contains("printf"));
134        let stdin = pack.stdin.expect("stdin com senha");
135        assert_eq!(stdin, b"s3cr3t\n");
136    }
137
138    #[test]
139    fn sudo_without_password_uses_n() {
140        let pack = pack_sudo("id", None);
141        assert_eq!(pack.command, "sudo -n sh -c 'id'");
142        assert!(pack.stdin.is_none());
143    }
144
145    #[test]
146    fn su_pack_no_secret_in_argv() {
147        let password = SecretString::from("rootpw".to_string());
148        let pack = pack_su("whoami", &password);
149        assert!(pack.command.contains("su - -c"));
150        assert!(!pack.command.contains("rootpw"));
151        assert_eq!(pack.stdin.as_deref(), Some(b"rootpw\n".as_slice()));
152    }
153
154    #[test]
155    fn description_appends_comment() {
156        assert_eq!(
157            append_description("ls", Some("lista arquivos")),
158            "ls # lista arquivos"
159        );
160        assert_eq!(append_description("ls", None), "ls");
161    }
162
163    #[test]
164    fn abort_pattern_sanitizes() {
165        assert_eq!(
166            remote_abort_pattern("sleep 999"),
167            Some("sleep 999".to_string())
168        );
169        // GAP-SSH-TEST-003: dangerous metacharacter → reject (not a tautology).
170        assert_eq!(remote_abort_pattern("$(rm -rf)"), None);
171        assert!(remote_abort_pattern("ab").is_none());
172    }
173}