Skip to main content

ssh_cli/ssh/
packing.rs

1//! Empacotamento seguro de comandos sudo/su (one-shot multi-host para LLMs).
2//!
3//! Usa `sh -c` com escape de aspas para comandos compostos seguros.
4
5use secrecy::{ExposeSecret, SecretString};
6
7/// Escapa uma string para uso seguro dentro de single quotes no shell.
8///
9/// Estratégia: envolve em single quotes e escapa single quotes internas
10/// com a sequência `'\''` (fecha quote, backslash-quote, abre quote).
11#[must_use]
12pub fn escapar_shell_single_quotes(valor: &str) -> String {
13    let mut resultado = String::with_capacity(valor.len() + 2);
14    resultado.push('\'');
15    for ch in valor.chars() {
16        if ch == '\'' {
17            resultado.push_str("'\\''");
18        } else {
19            resultado.push(ch);
20        }
21    }
22    resultado.push('\'');
23    resultado
24}
25
26/// Anexa `description` como comentário shell de forma segura .
27#[must_use]
28pub fn anexar_description(comando: &str, description: Option<&str>) -> String {
29    match description {
30        Some(d) if !d.trim().is_empty() => {
31            let limpo = d.replace(['\n', '\r'], " ");
32            format!("{comando} # {limpo}")
33        }
34        _ => comando.to_string(),
35    }
36}
37
38/// Resultado do packing: comando remoto **sem** segredo na argv + bytes opcionais
39/// a enviar no stdin do canal SSH (GAP-SSH-SEC-001).
40#[derive(Debug, Clone)]
41pub struct ComandoEmpacotado {
42    /// Linha de comando remota (sem senha embutida).
43    pub comando: String,
44    /// Dados a escrever no stdin do canal (ex.: senha + `\n` para `sudo -S` / `su`).
45    pub stdin: Option<Vec<u8>>,
46}
47
48/// Empacota comando para `sudo` com `sh -c`.
49///
50/// - Com senha: `sudo -S -p '' sh -c 'cmd'` e senha no **stdin do canal** (não na argv).
51/// - Sem senha: `sudo -n sh -c 'cmd'`.
52#[must_use]
53pub fn empacotar_sudo(comando: &str, senha_sudo: Option<&SecretString>) -> ComandoEmpacotado {
54    let cmd_esc = escapar_shell_single_quotes(comando);
55    match senha_sudo {
56        Some(senha) => {
57            let mut stdin = senha.expose_secret().as_bytes().to_vec();
58            stdin.push(b'\n');
59            ComandoEmpacotado {
60                comando: format!("sudo -S -p '' sh -c {cmd_esc}"),
61                stdin: Some(stdin),
62            }
63        }
64        None => ComandoEmpacotado {
65            comando: format!("sudo -n sh -c {cmd_esc}"),
66            stdin: None,
67        },
68    }
69}
70
71/// Empacota comando para `su - -c` one-shot; senha vai no stdin do canal.
72#[must_use]
73pub fn empacotar_su(comando: &str, senha_su: &SecretString) -> ComandoEmpacotado {
74    let cmd_esc = escapar_shell_single_quotes(comando);
75    let mut stdin = senha_su.expose_secret().as_bytes().to_vec();
76    stdin.push(b'\n');
77    ComandoEmpacotado {
78        comando: format!("su - -c {cmd_esc}"),
79        stdin: Some(stdin),
80    }
81}
82
83/// Sanitiza trecho de comando para uso best-effort em `pkill -f`.
84///
85/// Aceita alfanuméricos e símbolos restritos; para no primeiro metacaractere
86/// perigoso. Exige ao menos 3 chars. Nunca embute senhas (só o padrão do cmd).
87#[must_use]
88pub fn padrao_abort_remoto(comando: &str) -> Option<String> {
89    let mut limpo = String::with_capacity(comando.len().min(128));
90    for ch in comando.chars().take(128) {
91        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ' ' | ':' | '=') {
92            limpo.push(ch);
93        } else {
94            break;
95        }
96    }
97    let t = limpo.trim();
98    if t.len() < 3 {
99        None
100    } else {
101        Some(t.to_string())
102    }
103}
104
105/// Monta comando de abort best-effort remoto (TERM depois KILL).
106///
107/// Não embute segredos; usa apenas o padrão sanitizado do comando.
108#[must_use]
109pub fn empacotar_abort_pkill(padrao: &str) -> String {
110    let esc = escapar_shell_single_quotes(padrao);
111    format!(
112        "(pkill -TERM -f {esc} 2>/dev/null || true); sleep 0.2; (pkill -KILL -f {esc} 2>/dev/null || true)"
113    )
114}
115
116#[cfg(test)]
117mod testes {
118    use super::*;
119
120    #[test]
121    fn escape_single_quote() {
122        assert_eq!(escapar_shell_single_quotes("ab'cd"), "'ab'\\''cd'");
123        assert_eq!(escapar_shell_single_quotes("abc"), "'abc'");
124    }
125
126    #[test]
127    fn sudo_com_senha_usa_sh_c_sem_secret_na_argv() {
128        let senha = SecretString::from("s3cr3t".to_string());
129        let pack = empacotar_sudo("echo hi | tee /tmp/x", Some(&senha));
130        assert!(pack.comando.contains("sudo -S -p '' sh -c"));
131        assert!(!pack.comando.contains("s3cr3t"));
132        assert!(!pack.comando.contains("printf"));
133        let stdin = pack.stdin.expect("stdin com senha");
134        assert_eq!(stdin, b"s3cr3t\n");
135    }
136
137    #[test]
138    fn sudo_sem_senha_usa_n() {
139        let pack = empacotar_sudo("id", None);
140        assert_eq!(pack.comando, "sudo -n sh -c 'id'");
141        assert!(pack.stdin.is_none());
142    }
143
144    #[test]
145    fn su_pack_sem_secret_na_argv() {
146        let senha = SecretString::from("rootpw".to_string());
147        let pack = empacotar_su("whoami", &senha);
148        assert!(pack.comando.contains("su - -c"));
149        assert!(!pack.comando.contains("rootpw"));
150        assert_eq!(pack.stdin.as_deref(), Some(b"rootpw\n".as_slice()));
151    }
152
153    #[test]
154    fn description_anexa_comentario() {
155        assert_eq!(
156            anexar_description("ls", Some("lista arquivos")),
157            "ls # lista arquivos"
158        );
159        assert_eq!(anexar_description("ls", None), "ls");
160    }
161
162    #[test]
163    fn padrao_abort_sanitiza() {
164        assert_eq!(
165            padrao_abort_remoto("sleep 999"),
166            Some("sleep 999".to_string())
167        );
168        // GAP-SSH-TEST-003: metacaractere perigoso → rejeita (não tautologia).
169        assert_eq!(padrao_abort_remoto("$(rm -rf)"), None);
170        assert!(padrao_abort_remoto("ab").is_none());
171    }
172}