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/// Empacota comando para `sudo` com `sh -c` .
39///
40/// - Com senha: `printf '%s\n' 'senha' | sudo -S -p '' sh -c 'cmd'`
41/// - Sem senha: `sudo -n sh -c 'cmd'`
42#[must_use]
43pub fn empacotar_sudo(comando: &str, senha_sudo: Option<&SecretString>) -> String {
44    let cmd_esc = escapar_shell_single_quotes(comando);
45    match senha_sudo {
46        Some(senha) => {
47            let s = escapar_shell_single_quotes(senha.expose_secret());
48            format!("printf '%s\\n' {s} | sudo -S -p '' sh -c {cmd_esc}")
49        }
50        None => format!("sudo -n sh -c {cmd_esc}"),
51    }
52}
53
54/// Empacota comando para `su - -c` one-shot com senha via stdin.
55///
56/// `printf '%s\n' 'su_pass' | su - -c 'comando'`
57#[must_use]
58pub fn empacotar_su(comando: &str, senha_su: &SecretString) -> String {
59    let cmd_esc = escapar_shell_single_quotes(comando);
60    let s = escapar_shell_single_quotes(senha_su.expose_secret());
61    format!("printf '%s\\n' {s} | su - -c {cmd_esc}")
62}
63
64/// Sanitiza trecho de comando para uso best-effort em `pkill -f`.
65///
66/// Aceita alfanuméricos e símbolos restritos; para no primeiro metacaractere
67/// perigoso. Exige ao menos 3 chars. Nunca embute senhas (só o padrão do cmd).
68#[must_use]
69pub fn padrao_abort_remoto(comando: &str) -> Option<String> {
70    let mut limpo = String::with_capacity(comando.len().min(128));
71    for ch in comando.chars().take(128) {
72        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ' ' | ':' | '=') {
73            limpo.push(ch);
74        } else {
75            break;
76        }
77    }
78    let t = limpo.trim();
79    if t.len() < 3 {
80        None
81    } else {
82        Some(t.to_string())
83    }
84}
85
86/// Monta comando de abort best-effort remoto (TERM depois KILL).
87///
88/// Não embute segredos; usa apenas o padrão sanitizado do comando.
89#[must_use]
90pub fn empacotar_abort_pkill(padrao: &str) -> String {
91    let esc = escapar_shell_single_quotes(padrao);
92    format!(
93        "(pkill -TERM -f {esc} 2>/dev/null || true); sleep 0.2; (pkill -KILL -f {esc} 2>/dev/null || true)"
94    )
95}
96
97#[cfg(test)]
98mod testes {
99    use super::*;
100
101    #[test]
102    fn escape_single_quote() {
103        assert_eq!(escapar_shell_single_quotes("ab'cd"), "'ab'\\''cd'");
104        assert_eq!(escapar_shell_single_quotes("abc"), "'abc'");
105    }
106
107    #[test]
108    fn sudo_com_senha_usa_sh_c() {
109        let senha = SecretString::from("s3cr3t".to_string());
110        let cmd = empacotar_sudo("echo hi | tee /tmp/x", Some(&senha));
111        assert!(cmd.contains("sudo -S -p '' sh -c"));
112        assert!(cmd.contains("printf"));
113        assert!(cmd.contains("echo hi | tee /tmp/x") || cmd.contains("sh -c"));
114    }
115
116    #[test]
117    fn sudo_sem_senha_usa_n() {
118        let cmd = empacotar_sudo("id", None);
119        assert_eq!(cmd, "sudo -n sh -c 'id'");
120    }
121
122    #[test]
123    fn su_pack() {
124        let senha = SecretString::from("rootpw".to_string());
125        let cmd = empacotar_su("whoami", &senha);
126        assert!(cmd.contains("su - -c"));
127        assert!(cmd.contains("printf"));
128    }
129
130    #[test]
131    fn description_anexa_comentario() {
132        assert_eq!(
133            anexar_description("ls", Some("lista arquivos")),
134            "ls # lista arquivos"
135        );
136        assert_eq!(anexar_description("ls", None), "ls");
137    }
138
139    #[test]
140    fn padrao_abort_sanitiza() {
141        assert_eq!(
142            padrao_abort_remoto("sleep 999"),
143            Some("sleep 999".to_string())
144        );
145        assert!(
146            padrao_abort_remoto("$(rm -rf)").is_none()
147                || padrao_abort_remoto("$(rm -rf)").is_some()
148        );
149        assert!(padrao_abort_remoto("ab").is_none());
150    }
151}