ssh-cli 0.3.9

Native Rust CLI that gives LLMs (Claude Code, Cursor, Windsurf) the ability to operate remote servers via SSH over stdin/stdout
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Modelo de dados `VpsRegistro` (schema v2).
//!
//! Senhas usam `SecretString` para zeroize automático via `Drop`. O TOML
//! gravado em disco: texto claro (0o600) ou cifrado (`sshcli-enc:v1:`) se houver chave mestra.
//! `Debug` é customizado para NUNCA expor valores sensíveis.
//!
//! Schema v2: auth por senha **ou** chave, dualidade max_command/max_output,
//! `disable_sudo` e migração automática de `max_chars` legado.

use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};

/// Versão atual do schema do arquivo `config.toml`.
pub const SCHEMA_VERSION_ATUAL: u32 = 2;

/// Timeout padrão em milissegundos (default 60s).
pub const TIMEOUT_PADRAO_MS: u64 = 60_000;

/// Limite padrão de caracteres no **comando** (automação one-shot maxChars).
pub const MAX_COMMAND_CHARS_PADRAO: usize = 1_000;

/// Limite padrão de caracteres em **output** capturado.
pub const MAX_OUTPUT_CHARS_PADRAO: usize = 100_000;

/// Registro de uma VPS no arquivo de configuração.
#[derive(Clone, Serialize, Deserialize)]
pub struct VpsRegistro {
    /// Nome lógico único da VPS.
    pub nome: String,
    /// Hostname ou IP do servidor.
    pub host: String,
    /// Porta SSH.
    pub porta: u16,
    /// Usuário SSH.
    pub usuario: String,
    /// Senha SSH (vazia se auth só por chave).
    #[serde(default, with = "secret_string_serde")]
    pub senha: SecretString,
    /// Caminho absoluto ou expandível para chave privada OpenSSH.
    #[serde(default)]
    pub key_path: Option<String>,
    /// Passphrase da chave privada (opcional).
    #[serde(default, with = "opcao_secret_string_serde")]
    pub key_passphrase: Option<SecretString>,
    /// Timeout em milissegundos.
    pub timeout_ms: u64,
    /// Limite de caracteres do comando (entrada). `0` = ilimitado em runtime.
    #[serde(default = "default_max_command_chars")]
    pub max_command_chars: usize,
    /// Limite de caracteres de stdout/stderr. Aceita alias legado `max_chars`.
    #[serde(default = "default_max_output_chars", alias = "max_chars")]
    pub max_output_chars: usize,
    /// Senha para `sudo` (opcional).
    #[serde(default, with = "opcao_secret_string_serde")]
    pub senha_sudo: Option<SecretString>,
    /// Senha para `su -` (opcional).
    #[serde(default, with = "opcao_secret_string_serde")]
    pub senha_su: Option<SecretString>,
    /// Se true, `sudo-exec` e `su-exec` são recusados para este host.
    #[serde(default)]
    pub disable_sudo: bool,
    /// Versão do schema deste registro.
    pub schema_version: u32,
    /// Timestamp RFC 3339 de inclusão.
    pub adicionado_em: String,
}

fn default_max_command_chars() -> usize {
    MAX_COMMAND_CHARS_PADRAO
}

fn default_max_output_chars() -> usize {
    MAX_OUTPUT_CHARS_PADRAO
}

impl std::fmt::Debug for VpsRegistro {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("VpsRegistro")
            .field("nome", &self.nome)
            .field("host", &self.host)
            .field("porta", &self.porta)
            .field("usuario", &self.usuario)
            .field("senha", &"<redacted>")
            .field("key_path", &self.key_path)
            .field(
                "key_passphrase",
                &self.key_passphrase.as_ref().map(|_| "<redacted>"),
            )
            .field("timeout_ms", &self.timeout_ms)
            .field("max_command_chars", &self.max_command_chars)
            .field("max_output_chars", &self.max_output_chars)
            .field(
                "senha_sudo",
                &self.senha_sudo.as_ref().map(|_| "<redacted>"),
            )
            .field("senha_su", &self.senha_su.as_ref().map(|_| "<redacted>"))
            .field("disable_sudo", &self.disable_sudo)
            .field("schema_version", &self.schema_version)
            .field("adicionado_em", &self.adicionado_em)
            .finish()
    }
}

impl VpsRegistro {
    /// Cria um novo registro aplicando defaults.
    #[must_use]
    #[allow(clippy::too_many_arguments)]
    pub fn novo(
        nome: String,
        host: String,
        porta: u16,
        usuario: String,
        senha: SecretString,
        key_path: Option<String>,
        key_passphrase: Option<SecretString>,
        timeout_ms: Option<u64>,
        max_command_chars: Option<usize>,
        max_output_chars: Option<usize>,
        senha_sudo: Option<SecretString>,
        senha_su: Option<SecretString>,
        disable_sudo: bool,
    ) -> Self {
        Self {
            nome,
            host,
            porta,
            usuario,
            senha,
            key_path,
            key_passphrase,
            timeout_ms: timeout_ms.unwrap_or(TIMEOUT_PADRAO_MS),
            max_command_chars: max_command_chars.unwrap_or(MAX_COMMAND_CHARS_PADRAO),
            max_output_chars: max_output_chars.unwrap_or(MAX_OUTPUT_CHARS_PADRAO),
            senha_sudo,
            senha_su,
            disable_sudo,
            schema_version: SCHEMA_VERSION_ATUAL,
            adicionado_em: chrono::Utc::now().to_rfc3339(),
        }
    }

    /// Retorna true se há senha não vazia.
    #[must_use]
    pub fn tem_senha(&self) -> bool {
        !self.senha.expose_secret().is_empty()
    }

    /// Retorna true se há caminho de chave privada.
    #[must_use]
    pub fn tem_chave(&self) -> bool {
        self.key_path.as_ref().is_some_and(|p| !p.trim().is_empty())
    }

    /// Valida que existe pelo menos um método de autenticação.
    pub fn validar_credenciais(&self) -> Result<(), String> {
        if !self.tem_senha() && !self.tem_chave() {
            return Err(
                "é obrigatório fornecer --password ou --key (auth password ou chave privada)"
                    .to_string(),
            );
        }
        Ok(())
    }

    /// Validação completa do registro na fronteira de escrita (add/edit/import).
    ///
    /// Garante porta ∈ 1..=65535, host/usuário não vazios e credenciais presentes.
    /// Não verifica existência de `key_path` no filesystem (isso fica no dispatcher).
    pub fn validar(&self) -> Result<(), String> {
        if self.porta == 0 {
            return Err("porta SSH inválida: 0 (use 1..=65535)".to_string());
        }
        if self.host.trim().is_empty() {
            return Err("host não pode ser vazio".to_string());
        }
        if self.usuario.trim().is_empty() {
            return Err("usuário SSH não pode ser vazio".to_string());
        }
        self.validar_credenciais()
    }

    /// Normaliza schema após deserialização (migração v1 → v2).
    pub fn normalizar_schema(&mut self) {
        if self.schema_version < SCHEMA_VERSION_ATUAL {
            self.schema_version = SCHEMA_VERSION_ATUAL;
        }
        if self.max_command_chars == 0 && self.max_output_chars == 0 {
            // nada: 0 significa ilimitado na validação de runtime
        }
    }
}

/// Interpreta string de limite (`"none"`, `"0"` ou número).
///
/// `0`/`none` → `0` (ilimitado no runtime).
#[must_use]
pub fn parse_limite_chars(s: &str) -> usize {
    let t = s.trim();
    if t.eq_ignore_ascii_case("none") || t == "0" {
        0
    } else {
        t.parse().unwrap_or(MAX_OUTPUT_CHARS_PADRAO)
    }
}

/// Converte limite de config em valor efetivo para truncagem/validação.
///
/// `0` = sem limite (`usize::MAX` para comparação).
#[must_use]
pub fn limite_efetivo(configurado: usize) -> usize {
    if configurado == 0 {
        usize::MAX
    } else {
        configurado
    }
}

mod secret_string_serde {
    use super::{ExposeSecret, SecretString};
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(valor: &SecretString, s: S) -> Result<S::Ok, S::Error> {
        let plain = valor.expose_secret();
        let out = crate::secrets::serializar_segredo(plain).map_err(serde::ser::Error::custom)?;
        s.serialize_str(&out)
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
        let s = String::deserialize(d)?;
        let plain = crate::secrets::deserializar_segredo(&s).map_err(serde::de::Error::custom)?;
        Ok(SecretString::from(plain))
    }
}

mod opcao_secret_string_serde {
    use super::{ExposeSecret, SecretString};
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(valor: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
        match valor {
            Some(v) => {
                let out = crate::secrets::serializar_segredo(v.expose_secret())
                    .map_err(serde::ser::Error::custom)?;
                s.serialize_some(&out)
            }
            None => s.serialize_none(),
        }
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
        let opt = Option::<String>::deserialize(d)?;
        match opt {
            None => Ok(None),
            Some(s) => {
                let plain =
                    crate::secrets::deserializar_segredo(&s).map_err(serde::de::Error::custom)?;
                Ok(Some(SecretString::from(plain)))
            }
        }
    }
}

#[cfg(test)]
mod testes {
    use super::*;

    #[test]
    fn novo_registro_aplica_defaults() {
        let r = VpsRegistro::novo(
            "teste".into(),
            "1.2.3.4".into(),
            22,
            "root".into(),
            SecretString::from("senha".to_string()),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            false,
        );
        assert_eq!(r.timeout_ms, TIMEOUT_PADRAO_MS);
        assert_eq!(r.max_command_chars, MAX_COMMAND_CHARS_PADRAO);
        assert_eq!(r.max_output_chars, MAX_OUTPUT_CHARS_PADRAO);
        assert_eq!(r.schema_version, SCHEMA_VERSION_ATUAL);
        assert!(!r.adicionado_em.is_empty());
    }

    #[test]
    fn debug_nao_exibe_senha() {
        let r = VpsRegistro::novo(
            "t".into(),
            "h".into(),
            22,
            "u".into(),
            SecretString::from("senha-super-secreta".to_string()),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            false,
        );
        let dbg = format!("{r:?}");
        assert!(!dbg.contains("senha-super-secreta"));
        assert!(dbg.contains("redacted"));
    }

    #[test]
    #[serial_test::serial]
    fn round_trip_toml_preserva_dados() {
        // Isola cifragem at-rest de outros testes (master-key global).
        let tmp = tempfile::TempDir::new().unwrap();
        crate::secrets::definir_diretorio_config(Some(tmp.path().to_path_buf()));
        // SAFETY: teste serial; restaura opt-out de plaintext.
        // SAFETY: env mutável só em teste serial.
        unsafe {
            std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
        }
        let r = VpsRegistro::novo(
            "producao".into(),
            "srv.exemplo.com".into(),
            2222,
            "admin".into(),
            SecretString::from("senha-do-admin-longa".to_string()),
            Some("/home/u/.ssh/id_ed25519".into()),
            None,
            Some(5000),
            Some(500),
            Some(50_000),
            Some(SecretString::from("sudopass".to_string())),
            None,
            false,
        );
        let toml_str = toml::to_string(&r).expect("serializar");
        let r2: VpsRegistro = toml::from_str(&toml_str).expect("deserializar");
        assert_eq!(r2.nome, "producao");
        assert_eq!(r2.porta, 2222);
        assert_eq!(r2.senha.expose_secret(), "senha-do-admin-longa");
        assert_eq!(r2.key_path.as_deref(), Some("/home/u/.ssh/id_ed25519"));
        assert_eq!(r2.max_command_chars, 500);
        assert_eq!(r2.max_output_chars, 50_000);
        assert_eq!(
            r2.senha_sudo
                .as_ref()
                .map(|s| s.expose_secret().to_string()),
            Some("sudopass".to_string())
        );
        assert!(r2.senha_su.is_none());
        unsafe {
            std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
        }
        crate::secrets::definir_diretorio_config(None);
    }

    #[test]
    fn migra_max_chars_legado() {
        let legado = r#"
nome = "x"
host = "h"
porta = 22
usuario = "u"
senha = "s"
timeout_ms = 30000
max_chars = 4242
schema_version = 1
adicionado_em = "2020-01-01T00:00:00Z"
"#;
        let r: VpsRegistro = toml::from_str(legado).expect("deserializar legado");
        assert_eq!(r.max_output_chars, 4242);
        assert_eq!(r.max_command_chars, MAX_COMMAND_CHARS_PADRAO);
    }

    #[test]
    fn validar_credenciais_exige_password_ou_key() {
        let mut r = VpsRegistro::novo(
            "t".into(),
            "h".into(),
            22,
            "u".into(),
            SecretString::from(String::new()),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
            false,
        );
        assert!(r.validar_credenciais().is_err());
        r.key_path = Some("/tmp/k".into());
        assert!(r.validar_credenciais().is_ok());
    }

    #[test]
    fn parse_limite_none_e_zero() {
        assert_eq!(parse_limite_chars("none"), 0);
        assert_eq!(parse_limite_chars("0"), 0);
        assert_eq!(parse_limite_chars("1000"), 1000);
        assert_eq!(limite_efetivo(0), usize::MAX);
        assert_eq!(limite_efetivo(10), 10);
    }
}