Skip to main content

ssh_cli/vps/
mod.rs

1//! CRUD e persistência de registros de VPS.
2//!
3//! Cada VPS é armazenada em `$CONFIG_DIR/ssh-cli/config.toml` com permissões
4//! 0o600 no Unix. Toda a gestão acontece via comandos CLI — ZERO arquivo `.env`.
5//!
6//! O modelo [`modelo::VpsRegistro`] usa `SecretString` para senhas, garantindo
7//! Zeroize on Drop automático.
8
9pub mod modelo;
10
11use crate::cli::{AcaoVps, FormatoSaida};
12use crate::erros::{ErroSshCli, ResultadoSshCli};
13use crate::output;
14use crate::ssh::cliente::{ClienteSsh, ClienteSshTrait, ConfiguracaoConexao};
15use anyhow::Result;
16use modelo::VpsRegistro;
17use secrecy::SecretString;
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::path::PathBuf;
21
22/// Arquivo de configuração completo.
23#[derive(Debug, Default, Serialize, Deserialize)]
24pub struct ArquivoConfig {
25    /// Versão do schema.
26    #[serde(default)]
27    pub schema_version: u32,
28    /// Mapa de VPSs por nome.
29    #[serde(default)]
30    pub hosts: BTreeMap<String, VpsRegistro>,
31}
32
33/// Resolve o caminho do arquivo de config a partir de um override opcional.
34///
35/// - Se `override_path` for `Some` e apontar para um diretório (existente ou não),
36///   retorna `<dir>/config.toml`.
37/// - Se `override_path` for `Some` e apontar para um arquivo (terminando em `.toml`
38///   ou não sendo diretório existente), retorna o caminho como é.
39/// - Se `override_path` for `None`, usa [`caminho_config_padrao`].
40pub fn resolver_caminho_config(override_path: Option<PathBuf>) -> ResultadoSshCli<PathBuf> {
41    match override_path {
42        Some(p) => {
43            // Se já existe e é diretório, ou se o nome não tem extensão, trata como dir.
44            if p.is_dir() {
45                return Ok(p.join("config.toml"));
46            }
47            // Se terminar em .toml explicitamente, é arquivo.
48            if p.extension().and_then(|e| e.to_str()) == Some("toml") {
49                return Ok(p);
50            }
51            // Caso contrário, assume dir e adiciona config.toml.
52            Ok(p.join("config.toml"))
53        }
54        None => caminho_config_padrao(),
55    }
56}
57
58/// Retorna o caminho do arquivo de config respeitando `SSH_CLI_HOME`.
59pub fn caminho_config_padrao() -> ResultadoSshCli<PathBuf> {
60    if let Ok(home) = std::env::var("SSH_CLI_HOME") {
61        if home.contains("..") {
62            return Err(ErroSshCli::ArgumentoInvalido(
63                "SSH_CLI_HOME não pode conter '..'".to_string(),
64            ));
65        }
66        return Ok(PathBuf::from(home).join("config.toml"));
67    }
68
69    let dirs = directories::ProjectDirs::from("", "", "ssh-cli").ok_or_else(|| {
70        ErroSshCli::Generico("não foi possível resolver diretório de config".to_string())
71    })?;
72    Ok(dirs.config_dir().join("config.toml"))
73}
74
75/// Carrega o arquivo de configuração (retorna vazio se não existir).
76pub fn carregar(caminho: &PathBuf) -> ResultadoSshCli<ArquivoConfig> {
77    if !caminho.exists() {
78        return Ok(ArquivoConfig {
79            schema_version: modelo::SCHEMA_VERSION_ATUAL,
80            hosts: BTreeMap::new(),
81        });
82    }
83    let conteudo = std::fs::read_to_string(caminho)?;
84    let arquivo: ArquivoConfig = toml::from_str(&conteudo)?;
85    Ok(arquivo)
86}
87
88/// Salva o arquivo de configuração e aplica permissões 0o600 no Unix.
89pub fn salvar(caminho: &PathBuf, arquivo: &ArquivoConfig) -> ResultadoSshCli<()> {
90    if let Some(pai) = caminho.parent() {
91        std::fs::create_dir_all(pai)?;
92    }
93    let texto = toml::to_string_pretty(arquivo)
94        .map_err(|e| ErroSshCli::Generico(format!("falha serializando TOML: {e}")))?;
95    std::fs::write(caminho, texto)?;
96    aplicar_permissoes_600(caminho)?;
97    Ok(())
98}
99
100#[cfg(unix)]
101fn aplicar_permissoes_600(caminho: &PathBuf) -> ResultadoSshCli<()> {
102    use std::os::unix::fs::PermissionsExt;
103    let mut permissoes = std::fs::metadata(caminho)?.permissions();
104    permissoes.set_mode(0o600);
105    std::fs::set_permissions(caminho, permissoes)?;
106    Ok(())
107}
108
109#[cfg(not(unix))]
110fn aplicar_permissoes_600(_caminho: &PathBuf) -> ResultadoSshCli<()> {
111    Ok(())
112}
113
114/// Escapa uma string para uso seguro dentro de single quotes no shell.
115///
116/// Estratégia: envolve em single quotes e escapa single quotes internas
117/// com a sequência `'\''` (fecha quote, backslash-quote, abre quote).
118fn escapar_senha_shell(valor: &str) -> String {
119    let mut resultado = String::with_capacity(valor.len() + 2);
120    resultado.push('\'');
121    for ch in valor.chars() {
122        if ch == '\'' {
123            resultado.push_str("'\\''");
124        } else {
125            resultado.push(ch);
126        }
127    }
128    resultado.push('\'');
129    resultado
130}
131
132/// Aplica overrides de runtime sobre um VpsRegistro clonado.
133///
134/// Campos fornecidos pelo CLI em runtime PREVALECEM sobre valores armazenados.
135fn aplicar_overrides(
136    vps: &mut VpsRegistro,
137    password_override: Option<String>,
138    sudo_password_override: Option<String>,
139    timeout_override: Option<u64>,
140) {
141    if let Some(pwd) = password_override {
142        vps.senha = secrecy::SecretString::from(pwd);
143    }
144    if let Some(spwd) = sudo_password_override {
145        vps.senha_sudo = Some(secrecy::SecretString::from(spwd));
146    }
147    if let Some(t) = timeout_override {
148        vps.timeout_ms = t;
149    }
150}
151
152/// Dispatcher dos subcomandos `vps`.
153pub async fn executar_comando_vps(
154    acao: AcaoVps,
155    config_override: Option<PathBuf>,
156    formato: FormatoSaida,
157) -> Result<()> {
158    let caminho = resolver_caminho_config(config_override)?;
159
160    match acao {
161        AcaoVps::Add {
162            name,
163            host,
164            port,
165            user,
166            password,
167            timeout,
168            max_chars,
169            sudo_password,
170            su_password,
171        } => {
172            let name = crate::paths::normalizar_nfc(&name);
173            let mut arquivo = carregar(&caminho)?;
174            if arquivo.hosts.contains_key(&name) {
175                return Err(ErroSshCli::VpsDuplicada(name).into());
176            }
177            let senha = SecretString::from(password.unwrap_or_default());
178            let max_chars_num: usize = parse_max_chars(&max_chars);
179            let registro = VpsRegistro::novo(
180                name.clone(),
181                host,
182                port,
183                user,
184                senha,
185                Some(timeout),
186                Some(max_chars_num),
187                sudo_password.map(SecretString::from),
188                su_password.map(SecretString::from),
189            );
190            arquivo.hosts.insert(name.clone(), registro);
191            arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
192            salvar(&caminho, &arquivo)?;
193            crate::output::imprimir_sucesso(&format!("VPS '{name}' adicionada ao registro"));
194        }
195        AcaoVps::List { json } => {
196            let arquivo = carregar(&caminho)?;
197            let registros: Vec<_> = arquivo.hosts.values().cloned().collect();
198            if formato == FormatoSaida::Json || json {
199                crate::output::imprimir_lista_json(&registros);
200            } else {
201                crate::output::imprimir_lista_texto(&registros);
202            }
203        }
204        AcaoVps::Remove { nome } => {
205            let mut arquivo = carregar(&caminho)?;
206            if arquivo.hosts.remove(&nome).is_none() {
207                return Err(ErroSshCli::VpsNaoEncontrada(nome).into());
208            }
209            salvar(&caminho, &arquivo)?;
210            crate::output::imprimir_sucesso(&format!("VPS '{nome}' removida"));
211        }
212        AcaoVps::Edit {
213            nome,
214            host,
215            port,
216            user,
217            password,
218            timeout,
219            max_chars,
220            sudo_password,
221            su_password,
222        } => {
223            let mut arquivo = carregar(&caminho)?;
224            let registro = arquivo
225                .hosts
226                .get_mut(&nome)
227                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
228            if let Some(h) = host {
229                registro.host = h;
230            }
231            if let Some(p) = port {
232                registro.porta = p;
233            }
234            if let Some(u) = user {
235                registro.usuario = u;
236            }
237            if let Some(pw) = password {
238                registro.senha = SecretString::from(pw);
239            }
240            if let Some(t) = timeout {
241                registro.timeout_ms = t;
242            }
243            if let Some(m) = max_chars {
244                registro.max_chars = parse_max_chars(&m);
245            }
246            if let Some(sp) = sudo_password {
247                registro.senha_sudo = Some(SecretString::from(sp));
248            }
249            if let Some(sp) = su_password {
250                registro.senha_su = Some(SecretString::from(sp));
251            }
252            salvar(&caminho, &arquivo)?;
253            crate::output::imprimir_sucesso(&format!("VPS '{nome}' editada"));
254        }
255        AcaoVps::Show { nome, json } => {
256            let arquivo = carregar(&caminho)?;
257            let registro = arquivo
258                .hosts
259                .get(&nome)
260                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
261            if formato == FormatoSaida::Json || json {
262                crate::output::imprimir_detalhes_json(registro);
263            } else {
264                crate::output::imprimir_detalhes_texto(registro);
265            }
266        }
267        AcaoVps::Path => {
268            crate::output::escrever_linha(&caminho.display().to_string())?;
269        }
270    }
271    Ok(())
272}
273
274/// Define a VPS ativa gravando seu nome em `<config_dir>/active`.
275///
276/// Esta função é chamada pelo subcomando `connect <nome>` e valida que a VPS
277/// existe no registro antes de gravar.
278pub async fn executar_connect(nome: &str, config_override: Option<PathBuf>) -> Result<()> {
279    let caminho = resolver_caminho_config(config_override)?;
280    let arquivo = carregar(&caminho)?;
281    if !arquivo.hosts.contains_key(nome) {
282        return Err(ErroSshCli::VpsNaoEncontrada(nome.to_string()).into());
283    }
284
285    let arquivo_ativo = caminho
286        .parent()
287        .map(|p| p.join("active"))
288        .unwrap_or_else(|| PathBuf::from("active"));
289    if let Some(pai) = arquivo_ativo.parent() {
290        std::fs::create_dir_all(pai)?;
291    }
292    std::fs::write(&arquivo_ativo, nome)?;
293    crate::output::imprimir_sucesso(&format!("VPS ativa definida: '{nome}'"));
294    Ok(())
295}
296
297/// Busca um registro de VPS por nome.
298///
299/// Retorna `Ok(None)` se a VPS não existir (para que o caller decida o tratamento).
300pub fn buscar_por_nome(
301    config_override: Option<PathBuf>,
302    nome: &str,
303) -> ResultadoSshCli<Option<VpsRegistro>> {
304    let caminho = resolver_caminho_config(config_override)?;
305    let arquivo = carregar(&caminho)?;
306    Ok(arquivo.hosts.get(nome).cloned())
307}
308
309/// Lê o nome da VPS ativa.
310pub fn ler_vps_ativa(config_override: Option<PathBuf>) -> ResultadoSshCli<Option<String>> {
311    let caminho = resolver_caminho_config(config_override)?;
312    let arquivo_ativo = caminho
313        .parent()
314        .map(|p| p.join("active"))
315        .unwrap_or_else(|| PathBuf::from("active"));
316    if !arquivo_ativo.exists() {
317        return Ok(None);
318    }
319    let nome = std::fs::read_to_string(&arquivo_ativo)?;
320    Ok(Some(nome.trim().to_string()))
321}
322
323fn parse_max_chars(s: &str) -> usize {
324    if s == "none" || s == "0" {
325        usize::MAX
326    } else {
327        s.parse().unwrap_or(modelo::MAX_CHARS_PADRAO)
328    }
329}
330
331/// Constrói `ConfiguracaoConexao` a partir de um `VpsRegistro`.
332pub fn construir_configuracao(vps: &VpsRegistro) -> ConfiguracaoConexao {
333    ConfiguracaoConexao {
334        host: vps.host.clone(),
335        porta: vps.porta,
336        usuario: vps.usuario.clone(),
337        senha: vps.senha.clone(),
338        timeout_ms: vps.timeout_ms,
339    }
340}
341
342/// Executa um comando em uma VPS via SSH.
343pub async fn executar_exec(
344    vps_nome: &str,
345    comando: &str,
346    config_override: Option<PathBuf>,
347    formato: FormatoSaida,
348    json: bool,
349    password_override: Option<String>,
350    timeout_override: Option<u64>,
351) -> Result<()> {
352    if crate::signals::cancelado() || crate::signals::terminado() {
353        return Err(anyhow::anyhow!(crate::i18n::t(
354            crate::i18n::Mensagem::OperacaoCancelada
355        )));
356    }
357    let caminho = resolver_caminho_config(config_override)?;
358    let arquivo = carregar(&caminho)?;
359    let vps_base = arquivo
360        .hosts
361        .get(vps_nome)
362        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
363
364    let mut vps = vps_base.clone();
365    aplicar_overrides(&mut vps, password_override, None, timeout_override);
366    let cfg = construir_configuracao(&vps);
367    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
368    executar_exec_with_client(&vps, comando, cliente, formato, json).await
369}
370
371/// Versão testável de executar_exec que aceita o cliente como parâmetro.
372pub async fn executar_exec_with_client(
373    vps: &VpsRegistro,
374    comando: &str,
375    mut cliente: Box<dyn ClienteSshTrait>,
376    formato: FormatoSaida,
377    json: bool,
378) -> Result<()> {
379    if crate::signals::cancelado() || crate::signals::terminado() {
380        return Err(anyhow::anyhow!(crate::i18n::t(
381            crate::i18n::Mensagem::OperacaoCancelada
382        )));
383    }
384    let saida = cliente.executar_comando(comando, vps.max_chars).await?;
385    cliente.desconectar().await?;
386    if formato == FormatoSaida::Json || json {
387        output::imprimir_saida_execucao_json(&saida);
388    } else {
389        output::imprimir_saida_execucao(&saida);
390    }
391    if let Some(code) = saida.exit_code {
392        if code != 0 {
393            return Err(ErroSshCli::ComandoFalhou {
394                exit_code: code,
395                stderr: saida.stderr.clone(),
396            }
397            .into());
398        }
399    }
400    Ok(())
401}
402
403/// Executa um comando com `sudo` em uma VPS via SSH.
404///
405/// Se a VPS tiver `senha_sudo` definida (ou `sudo_password_override` for fornecido),
406/// o comando é executado via `printf '%s\n' <senha> | sudo -S -p '' <cmd>`,
407/// que injeta a senha no stdin do sudo sem expô-la nos argumentos do processo.
408/// Caso contrário, usa `sudo <cmd>` assumindo NOPASSWD configurado.
409#[allow(clippy::too_many_arguments)]
410pub async fn executar_sudo_exec(
411    vps_nome: &str,
412    comando: &str,
413    config_override: Option<PathBuf>,
414    formato: FormatoSaida,
415    json: bool,
416    password_override: Option<String>,
417    sudo_password_override: Option<String>,
418    timeout_override: Option<u64>,
419) -> Result<()> {
420    if crate::signals::cancelado() || crate::signals::terminado() {
421        return Err(anyhow::anyhow!(crate::i18n::t(
422            crate::i18n::Mensagem::OperacaoCancelada
423        )));
424    }
425    let caminho = resolver_caminho_config(config_override)?;
426    let arquivo = carregar(&caminho)?;
427    let vps_base = arquivo
428        .hosts
429        .get(vps_nome)
430        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
431
432    let mut vps = vps_base.clone();
433    aplicar_overrides(
434        &mut vps,
435        password_override,
436        sudo_password_override,
437        timeout_override,
438    );
439    let cfg = construir_configuracao(&vps);
440    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
441    executar_sudo_exec_with_client(&vps, comando, cliente, formato, json).await
442}
443
444/// Versão testável de executar_sudo_exec que aceita o cliente como parâmetro.
445pub async fn executar_sudo_exec_with_client(
446    vps: &VpsRegistro,
447    comando: &str,
448    mut cliente: Box<dyn ClienteSshTrait>,
449    formato: FormatoSaida,
450    json: bool,
451) -> Result<()> {
452    if crate::signals::cancelado() || crate::signals::terminado() {
453        return Err(anyhow::anyhow!(crate::i18n::t(
454            crate::i18n::Mensagem::OperacaoCancelada
455        )));
456    }
457    let sudo_cmd = if let Some(ref senha) = vps.senha_sudo {
458        use secrecy::ExposeSecret;
459        let escaped = escapar_senha_shell(senha.expose_secret());
460        format!("printf '%s\\n' {} | sudo -S -p '' {}", escaped, comando)
461    } else {
462        format!("sudo {}", comando)
463    };
464
465    let saida = cliente.executar_comando(&sudo_cmd, vps.max_chars).await?;
466    cliente.desconectar().await?;
467    if formato == FormatoSaida::Json || json {
468        output::imprimir_saida_execucao_json(&saida);
469    } else {
470        output::imprimir_saida_execucao(&saida);
471    }
472    if let Some(code) = saida.exit_code {
473        if code != 0 {
474            return Err(ErroSshCli::ComandoFalhou {
475                exit_code: code,
476                stderr: saida.stderr.clone(),
477            }
478            .into());
479        }
480    }
481    Ok(())
482}
483
484/// Executa um health-check (ping SSH) em uma VPS e imprime a latência.
485///
486/// Se `vps_nome` for `None`, usa a VPS ativa registrada.
487pub async fn executar_health_check(
488    vps_nome: Option<&str>,
489    config_override: Option<PathBuf>,
490    formato: FormatoSaida,
491    password_override: Option<String>,
492) -> Result<()> {
493    if crate::signals::cancelado() || crate::signals::terminado() {
494        return Err(anyhow::anyhow!(crate::i18n::t(
495            crate::i18n::Mensagem::OperacaoCancelada
496        )));
497    }
498    let nome_resolvido: String = match vps_nome {
499        Some(n) => n.to_string(),
500        None => {
501            let ativa = ler_vps_ativa(config_override.clone())?;
502            ativa.ok_or_else(|| {
503                anyhow::anyhow!(crate::i18n::t(crate::i18n::Mensagem::HealthCheckSemVps))
504            })?
505        }
506    };
507    let caminho = resolver_caminho_config(config_override)?;
508    let arquivo = carregar(&caminho)?;
509    let vps_base = arquivo
510        .hosts
511        .get(&nome_resolvido)
512        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome_resolvido.clone()))?;
513
514    let mut vps = vps_base.clone();
515    aplicar_overrides(&mut vps, password_override, None, None);
516    let cfg = construir_configuracao(&vps);
517    let inicio = std::time::Instant::now();
518    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
519    let latencia_ms = inicio.elapsed().as_millis() as u64;
520    cliente.desconectar().await?;
521
522    if formato == FormatoSaida::Json {
523        output::imprimir_health_check_json(&nome_resolvido, latencia_ms);
524    } else {
525        output::imprimir_health_check(&nome_resolvido, latencia_ms);
526    }
527    Ok(())
528}
529
530#[cfg(test)]
531mod testes {
532    use super::*;
533    use serial_test::serial;
534
535    #[test]
536    fn arquivo_vazio_serializa_com_schema() {
537        let arq = ArquivoConfig {
538            schema_version: modelo::SCHEMA_VERSION_ATUAL,
539            hosts: BTreeMap::new(),
540        };
541        let texto = toml::to_string(&arq).unwrap();
542        assert!(texto.contains("schema_version = 1"));
543    }
544
545    #[test]
546    fn parse_max_chars_none_retorna_usize_max() {
547        assert_eq!(parse_max_chars("none"), usize::MAX);
548        assert_eq!(parse_max_chars("0"), usize::MAX);
549        assert_eq!(parse_max_chars("1000"), 1000);
550    }
551
552    #[test]
553    fn parse_max_chars_valor_invalido() {
554        assert_eq!(parse_max_chars("abc"), modelo::MAX_CHARS_PADRAO);
555        assert_eq!(parse_max_chars("invalido"), modelo::MAX_CHARS_PADRAO);
556    }
557
558    #[test]
559    fn construir_configuracao_copia_campos_corretamente() {
560        let registro = modelo::VpsRegistro::novo(
561            "srv".into(),
562            "host.example.com".into(),
563            2222,
564            "admin".into(),
565            SecretString::from("pass".to_string()),
566            Some(60_000),
567            Some(50_000),
568            None,
569            None,
570        );
571        let cfg = construir_configuracao(&registro);
572        assert_eq!(cfg.host, "host.example.com");
573        assert_eq!(cfg.porta, 2222);
574        assert_eq!(cfg.usuario, "admin");
575        assert_eq!(cfg.timeout_ms, 60_000);
576    }
577
578    #[test]
579    fn arquivo_config_vazio_tem_schema_correto() {
580        let arq = ArquivoConfig {
581            schema_version: modelo::SCHEMA_VERSION_ATUAL,
582            hosts: BTreeMap::new(),
583        };
584        let toml_str = toml::to_string(&arq).unwrap();
585        assert!(toml_str.contains("schema_version"));
586        assert!(toml_str.contains("hosts"));
587    }
588
589    #[test]
590    fn arquivo_config_com_hosts_serializa_para_toml() {
591        let mut hosts = BTreeMap::new();
592        hosts.insert(
593            "teste".to_string(),
594            modelo::VpsRegistro::novo(
595                "teste".into(),
596                "1.2.3.4".into(),
597                22,
598                "root".into(),
599                SecretString::from("senha".to_string()),
600                None,
601                None,
602                None,
603                None,
604            ),
605        );
606        let arq = ArquivoConfig {
607            schema_version: modelo::SCHEMA_VERSION_ATUAL,
608            hosts,
609        };
610        let toml_str = toml::to_string(&arq).unwrap();
611        assert!(toml_str.contains("teste"));
612        assert!(toml_str.contains("1.2.3.4"));
613    }
614
615    #[test]
616    fn resolver_caminho_config_com_override_diretorio() {
617        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
618        assert!(resultado.is_ok());
619        assert_eq!(
620            resultado.unwrap(),
621            PathBuf::from("/tmp/test-dir/config.toml")
622        );
623    }
624
625    #[test]
626    fn resolver_caminho_config_com_override_arquivo_explicito() {
627        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test.toml")));
628        assert!(resultado.is_ok());
629        assert_eq!(resultado.unwrap(), PathBuf::from("/tmp/test.toml"));
630    }
631
632    #[test]
633    fn resolver_caminho_config_sem_extensao_trata_como_diretorio() {
634        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test")));
635        assert!(resultado.is_ok());
636        assert_eq!(resultado.unwrap(), PathBuf::from("/tmp/test/config.toml"));
637    }
638
639    #[test]
640    fn carregar_retorna_config_vazio_quando_arquivo_nao_existe() {
641        let tmp = tempfile::TempDir::new().unwrap();
642        let caminho = tmp.path().join("nao-existe.toml");
643        let resultado = carregar(&caminho);
644        assert!(resultado.is_ok());
645        let arq = resultado.unwrap();
646        assert_eq!(arq.schema_version, modelo::SCHEMA_VERSION_ATUAL);
647        assert!(arq.hosts.is_empty());
648    }
649
650    #[test]
651    fn carregar_faz_parse_de_toml_existente() {
652        let tmp = tempfile::TempDir::new().unwrap();
653        let caminho = tmp.path().join("config.toml");
654        let conteudo = r#"
655schema_version = 1
656[hosts.minha-vps]
657nome = "minha-vps"
658host = "1.2.3.4"
659porta = 22
660usuario = "root"
661senha = "senhateste"
662timeout_ms = 30000
663max_chars = 100000
664schema_version = 1
665adicionado_em = "2024-01-01T00:00:00Z"
666"#;
667        std::fs::write(&caminho, conteudo).unwrap();
668        let resultado = carregar(&caminho);
669        assert!(resultado.is_ok());
670        let arq = resultado.unwrap();
671        assert!(arq.hosts.contains_key("minha-vps"));
672    }
673
674    #[test]
675    fn ler_vps_ativa_retorna_none_quando_arquivo_nao_existe() {
676        let tmp = tempfile::TempDir::new().unwrap();
677        let config_dir = tmp.path().join("ssh-cli");
678        std::fs::create_dir_all(&config_dir).unwrap();
679        let caminho_config = config_dir.join("config.toml");
680        std::fs::write(&caminho_config, "").unwrap();
681        let resultado = ler_vps_ativa(Some(config_dir.clone()));
682        assert!(resultado.is_ok());
683        assert!(resultado.unwrap().is_none());
684    }
685
686    #[test]
687    fn ler_vps_ativa_retorna_nome_quando_arquivo_existe() {
688        let tmp = tempfile::TempDir::new().unwrap();
689        let config_dir = tmp.path().join("ssh-cli");
690        std::fs::create_dir_all(&config_dir).unwrap();
691        let caminho_config = config_dir.join("config.toml");
692        let caminho_ativo = config_dir.join("active");
693        std::fs::write(&caminho_config, "").unwrap();
694        std::fs::write(&caminho_ativo, "minha-vps\n").unwrap();
695        let resultado = ler_vps_ativa(Some(config_dir));
696        assert!(resultado.is_ok());
697        assert_eq!(resultado.unwrap(), Some("minha-vps".to_string()));
698    }
699
700    #[test]
701    fn ler_vps_ativa_com_override_diretorio() {
702        let tmp = tempfile::TempDir::new().unwrap();
703        let config_dir = tmp.path().join("minha-config");
704        std::fs::create_dir_all(&config_dir).unwrap();
705        let caminho_config = config_dir.join("config.toml");
706        let caminho_ativo = config_dir.join("active");
707        std::fs::write(&caminho_config, "").unwrap();
708        std::fs::write(&caminho_ativo, "vps-teste\n").unwrap();
709        let resultado = ler_vps_ativa(Some(config_dir));
710        assert!(resultado.is_ok());
711        assert_eq!(resultado.unwrap(), Some("vps-teste".to_string()));
712    }
713
714    #[test]
715    fn buscar_por_nome_retorna_none_quando_nao_existe() {
716        let tmp = tempfile::TempDir::new().unwrap();
717        let caminho = tmp.path().join("config.toml");
718        std::fs::write(&caminho, "").unwrap();
719        let resultado = buscar_por_nome(Some(caminho.clone()), "inexistente");
720        assert!(resultado.is_ok());
721        assert!(resultado.unwrap().is_none());
722    }
723
724    #[test]
725    fn buscar_por_nome_retorna_registro_quando_existe() {
726        let tmp = tempfile::TempDir::new().unwrap();
727        let caminho = tmp.path().join("config.toml");
728        let conteudo = r#"
729schema_version = 1
730[hosts.minha-vps]
731nome = "minha-vps"
732host = "1.2.3.4"
733porta = 22
734usuario = "root"
735senha = "senhateste"
736timeout_ms = 30000
737max_chars = 100000
738schema_version = 1
739adicionado_em = "2024-01-01T00:00:00Z"
740"#;
741        std::fs::write(&caminho, conteudo).unwrap();
742        let resultado = buscar_por_nome(Some(caminho), "minha-vps");
743        assert!(resultado.is_ok());
744        let vps = resultado.unwrap();
745        assert!(vps.is_some());
746        assert_eq!(vps.unwrap().nome, "minha-vps");
747    }
748
749    #[cfg(unix)]
750    #[test]
751    fn salvar_aplica_permissoes_600_no_unix() {
752        use std::os::unix::fs::PermissionsExt;
753        let tmp = tempfile::TempDir::new().unwrap();
754        let caminho = tmp.path().join("config.toml");
755        let arquivo = ArquivoConfig {
756            schema_version: modelo::SCHEMA_VERSION_ATUAL,
757            hosts: BTreeMap::new(),
758        };
759        let resultado = salvar(&caminho, &arquivo);
760        assert!(resultado.is_ok());
761        let metadados = std::fs::metadata(&caminho).unwrap();
762        let permissoes = metadados.permissions();
763        assert_eq!(permissoes.mode() & 0o777, 0o600);
764    }
765
766    #[test]
767    fn salvar_cria_diretorio_pai_se_nao_existir() {
768        let tmp = tempfile::TempDir::new().unwrap();
769        let caminho = tmp
770            .path()
771            .join("subdir1")
772            .join("subdir2")
773            .join("config.toml");
774        let arquivo = ArquivoConfig {
775            schema_version: modelo::SCHEMA_VERSION_ATUAL,
776            hosts: BTreeMap::new(),
777        };
778        let resultado = salvar(&caminho, &arquivo);
779        assert!(resultado.is_ok());
780        assert!(caminho.exists());
781    }
782
783    #[test]
784    fn arquivo_config_parsing_com_campos_parciais() {
785        let tmp = tempfile::TempDir::new().unwrap();
786        let caminho = tmp.path().join("config.toml");
787        let conteudo = r#"
788schema_version = 1
789[hosts.vps-minima]
790nome = "vps-minima"
791host = "5.6.7.8"
792porta = 2222
793usuario = "admin"
794senha = "senha123"
795timeout_ms = 30000
796max_chars = 100000
797schema_version = 1
798adicionado_em = "2024-01-01T00:00:00Z"
799"#;
800        std::fs::write(&caminho, conteudo).unwrap();
801        let resultado = carregar(&caminho);
802        assert!(resultado.is_ok());
803        let arq = resultado.unwrap();
804        assert!(arq.hosts.contains_key("vps-minima"));
805        let vps = arq.hosts.get("vps-minima").unwrap();
806        assert_eq!(vps.host, "5.6.7.8");
807        assert_eq!(vps.porta, 2222);
808    }
809
810    #[tokio::test]
811    #[serial]
812    async fn executar_exec_with_client_retorna_ok_quando_mock_sucesso() {
813        use crate::ssh::cliente::mocks::MockClienteSsh;
814        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
815
816        let mut mock = MockClienteSsh::new();
817        mock.expect_executar_comando()
818            .returning(|_cmd, _max_chars| {
819                Ok(SaidaExecucao {
820                    stdout: "output test".to_string(),
821                    stderr: String::new(),
822                    exit_code: Some(0),
823                    truncado_stdout: false,
824                    truncado_stderr: false,
825                    duracao_ms: 100,
826                })
827            });
828        mock.expect_desconectar().returning(|| Ok(()));
829
830        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
831        let registro = modelo::VpsRegistro::novo(
832            "teste".into(),
833            "localhost".into(),
834            22,
835            "user".into(),
836            SecretString::from("pass".to_string()),
837            None,
838            None,
839            None,
840            None,
841        );
842
843        let resultado =
844            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
845                .await;
846        assert!(resultado.is_ok());
847    }
848
849    #[tokio::test]
850    #[serial]
851    async fn executar_sudo_exec_with_client_retorna_ok_quando_mock_sucesso() {
852        use crate::ssh::cliente::mocks::MockClienteSsh;
853        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
854
855        let mut mock = MockClienteSsh::new();
856        mock.expect_executar_comando()
857            .returning(|_cmd, _max_chars| {
858                Ok(SaidaExecucao {
859                    stdout: "sudo output".to_string(),
860                    stderr: String::new(),
861                    exit_code: Some(0),
862                    truncado_stdout: false,
863                    truncado_stderr: false,
864                    duracao_ms: 100,
865                })
866            });
867        mock.expect_desconectar().returning(|| Ok(()));
868
869        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
870        let mut registro = modelo::VpsRegistro::novo(
871            "teste".into(),
872            "localhost".into(),
873            22,
874            "user".into(),
875            SecretString::from("pass".to_string()),
876            None,
877            None,
878            None,
879            None,
880        );
881        registro.senha_sudo = Some(SecretString::from("sudo_pass".to_string()));
882
883        let resultado = executar_sudo_exec_with_client(
884            &registro,
885            "echo sudo",
886            cliente,
887            FormatoSaida::Text,
888            false,
889        )
890        .await;
891        assert!(resultado.is_ok());
892    }
893
894    #[tokio::test]
895    #[serial]
896    async fn executar_sudo_exec_with_client_retorna_ok_quando_sem_senha_sudo() {
897        use crate::ssh::cliente::mocks::MockClienteSsh;
898        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
899
900        let mut mock = MockClienteSsh::new();
901        mock.expect_executar_comando()
902            .returning(|_cmd, _max_chars| {
903                Ok(SaidaExecucao {
904                    stdout: "output".to_string(),
905                    stderr: String::new(),
906                    exit_code: Some(0),
907                    truncado_stdout: false,
908                    truncado_stderr: false,
909                    duracao_ms: 100,
910                })
911            });
912        mock.expect_desconectar().returning(|| Ok(()));
913
914        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
915        let registro = modelo::VpsRegistro::novo(
916            "teste".into(),
917            "localhost".into(),
918            22,
919            "user".into(),
920            SecretString::from("pass".to_string()),
921            None,
922            None,
923            None,
924            None,
925        );
926
927        let resultado = executar_sudo_exec_with_client(
928            &registro,
929            "echo test",
930            cliente,
931            FormatoSaida::Text,
932            false,
933        )
934        .await;
935        assert!(resultado.is_ok());
936    }
937
938    #[tokio::test]
939    async fn executar_sudo_exec_with_client_retorna_erro_quando_executar_comando_falha() {
940        use crate::ssh::cliente::mocks::MockClienteSsh;
941        use crate::ssh::cliente::ClienteSshTrait;
942
943        let mut mock = MockClienteSsh::new();
944        mock.expect_executar_comando()
945            .returning(|_cmd, _max_chars| {
946                Err(crate::erros::ErroSshCli::CanalFalhou(
947                    "mock error".to_string(),
948                ))
949            });
950
951        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
952        let registro = modelo::VpsRegistro::novo(
953            "teste".into(),
954            "localhost".into(),
955            22,
956            "user".into(),
957            SecretString::from("pass".to_string()),
958            None,
959            None,
960            None,
961            None,
962        );
963
964        let resultado =
965            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
966                .await;
967        assert!(resultado.is_err());
968    }
969
970    #[tokio::test]
971    async fn executar_scp_upload_with_client_retorna_ok_quando_mock_sucesso() {
972        use crate::ssh::cliente::mocks::MockClienteSsh;
973        use crate::ssh::cliente::{ClienteSshTrait, TransferenciaResultado};
974
975        let mut mock = MockClienteSsh::new();
976        mock.expect_upload().returning(|_local, _remote| {
977            Ok(TransferenciaResultado {
978                bytes_transferidos: 1024,
979                duracao_ms: 50,
980            })
981        });
982        mock.expect_desconectar().returning(|| Ok(()));
983
984        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
985        let registro = modelo::VpsRegistro::novo(
986            "teste".into(),
987            "localhost".into(),
988            22,
989            "user".into(),
990            SecretString::from("pass".to_string()),
991            None,
992            None,
993            None,
994            None,
995        );
996
997        let resultado = crate::scp::executar_scp_upload_with_client(
998            &registro,
999            std::path::Path::new("/local/file.txt"),
1000            std::path::Path::new("/remote/file.txt"),
1001            cliente,
1002        )
1003        .await;
1004        assert!(resultado.is_ok());
1005    }
1006
1007    #[tokio::test]
1008    async fn executar_scp_download_with_client_retorna_ok_quando_mock_sucesso() {
1009        use crate::ssh::cliente::mocks::MockClienteSsh;
1010        use crate::ssh::cliente::{ClienteSshTrait, TransferenciaResultado};
1011
1012        let mut mock = MockClienteSsh::new();
1013        mock.expect_download().returning(|_remote, _local| {
1014            Ok(TransferenciaResultado {
1015                bytes_transferidos: 2048,
1016                duracao_ms: 75,
1017            })
1018        });
1019        mock.expect_desconectar().returning(|| Ok(()));
1020
1021        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1022        let registro = modelo::VpsRegistro::novo(
1023            "teste".into(),
1024            "localhost".into(),
1025            22,
1026            "user".into(),
1027            SecretString::from("pass".to_string()),
1028            None,
1029            None,
1030            None,
1031            None,
1032        );
1033
1034        let resultado = crate::scp::executar_scp_download_with_client(
1035            &registro,
1036            std::path::Path::new("/remote/file.txt"),
1037            std::path::Path::new("/local/file.txt"),
1038            cliente,
1039        )
1040        .await;
1041        assert!(resultado.is_ok());
1042    }
1043
1044    #[tokio::test]
1045    async fn executar_scp_upload_with_client_retorna_erro_quando_upload_falha() {
1046        use crate::ssh::cliente::mocks::MockClienteSsh;
1047        use crate::ssh::cliente::ClienteSshTrait;
1048
1049        let mut mock = MockClienteSsh::new();
1050        mock.expect_upload().returning(|_local, _remote| {
1051            Err(crate::erros::ErroSshCli::Generico(
1052                "falha no upload".to_string(),
1053            ))
1054        });
1055
1056        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1057        let registro = modelo::VpsRegistro::novo(
1058            "teste".into(),
1059            "localhost".into(),
1060            22,
1061            "user".into(),
1062            SecretString::from("pass".to_string()),
1063            None,
1064            None,
1065            None,
1066            None,
1067        );
1068
1069        let resultado = crate::scp::executar_scp_upload_with_client(
1070            &registro,
1071            std::path::Path::new("/local/file.txt"),
1072            std::path::Path::new("/remote/file.txt"),
1073            cliente,
1074        )
1075        .await;
1076        assert!(resultado.is_err());
1077    }
1078
1079    #[tokio::test]
1080    async fn executar_scp_download_with_client_retorna_erro_quando_download_falha() {
1081        use crate::ssh::cliente::mocks::MockClienteSsh;
1082        use crate::ssh::cliente::ClienteSshTrait;
1083
1084        let mut mock = MockClienteSsh::new();
1085        mock.expect_download().returning(|_remote, _local| {
1086            Err(crate::erros::ErroSshCli::Generico(
1087                "falha no download".to_string(),
1088            ))
1089        });
1090
1091        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1092        let registro = modelo::VpsRegistro::novo(
1093            "teste".into(),
1094            "localhost".into(),
1095            22,
1096            "user".into(),
1097            SecretString::from("pass".to_string()),
1098            None,
1099            None,
1100            None,
1101            None,
1102        );
1103
1104        let resultado = crate::scp::executar_scp_download_with_client(
1105            &registro,
1106            std::path::Path::new("/remote/file.txt"),
1107            std::path::Path::new("/local/file.txt"),
1108            cliente,
1109        )
1110        .await;
1111        assert!(resultado.is_err());
1112    }
1113
1114    #[tokio::test]
1115    async fn executar_sudo_exec_with_client_retorna_erro_quando_desconectar_falha() {
1116        use crate::ssh::cliente::mocks::MockClienteSsh;
1117        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
1118
1119        let mut mock = MockClienteSsh::new();
1120        mock.expect_executar_comando()
1121            .returning(|_cmd, _max_chars| {
1122                Ok(SaidaExecucao {
1123                    stdout: "output".to_string(),
1124                    stderr: String::new(),
1125                    exit_code: Some(0),
1126                    truncado_stdout: false,
1127                    truncado_stderr: false,
1128                    duracao_ms: 100,
1129                })
1130            });
1131        mock.expect_desconectar().returning(|| {
1132            Err(crate::erros::ErroSshCli::CanalFalhou(
1133                "erro desconexão".to_string(),
1134            ))
1135        });
1136
1137        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1138        let registro = modelo::VpsRegistro::novo(
1139            "teste".into(),
1140            "localhost".into(),
1141            22,
1142            "user".into(),
1143            SecretString::from("pass".to_string()),
1144            None,
1145            None,
1146            None,
1147            None,
1148        );
1149
1150        let resultado =
1151            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
1152                .await;
1153        assert!(resultado.is_err());
1154    }
1155
1156    #[test]
1157    #[serial]
1158    fn caminho_config_padrao_com_ssh_cli_home_retorna_path() {
1159        let tmp = tempfile::TempDir::new().unwrap();
1160        let home_dir = tmp.path().join("ssh-cli-home");
1161        std::fs::create_dir_all(&home_dir).unwrap();
1162        std::env::set_var("SSH_CLI_HOME", home_dir.to_str().unwrap());
1163        let resultado = caminho_config_padrao();
1164        std::env::remove_var("SSH_CLI_HOME");
1165        assert!(resultado.is_ok());
1166        assert!(resultado
1167            .unwrap()
1168            .to_str()
1169            .unwrap()
1170            .contains("ssh-cli-home"));
1171    }
1172
1173    #[test]
1174    #[serial]
1175    fn caminho_config_padrao_com_path_traversal_retorna_erro() {
1176        std::env::set_var("SSH_CLI_HOME", "/tmp/../etc/config");
1177        let resultado = caminho_config_padrao();
1178        std::env::remove_var("SSH_CLI_HOME");
1179        assert!(resultado.is_err());
1180    }
1181
1182    #[test]
1183    #[serial]
1184    fn caminho_config_padrao_sem_env_retorna_path_valido() {
1185        std::env::remove_var("SSH_CLI_HOME");
1186        let resultado = caminho_config_padrao();
1187        if let Ok(path) = resultado {
1188            assert!(path.to_str().unwrap().contains("ssh-cli"));
1189        }
1190    }
1191
1192    #[test]
1193    fn escapar_senha_shell_simples() {
1194        assert_eq!(escapar_senha_shell("abc123"), "'abc123'");
1195    }
1196
1197    #[test]
1198    fn escapar_senha_shell_com_single_quote() {
1199        assert_eq!(escapar_senha_shell("ab'cd"), "'ab'\\''cd'");
1200    }
1201
1202    #[test]
1203    fn escapar_senha_shell_com_especiais() {
1204        // $, @, ~, !, ` são seguros dentro de single quotes
1205        assert_eq!(escapar_senha_shell("p@ss$w0rd!"), "'p@ss$w0rd!'");
1206    }
1207
1208    #[test]
1209    fn escapar_senha_shell_vazia() {
1210        assert_eq!(escapar_senha_shell(""), "''");
1211    }
1212
1213    #[test]
1214    fn escapar_senha_shell_unicode() {
1215        assert_eq!(escapar_senha_shell("café☕"), "'café☕'");
1216    }
1217
1218    #[test]
1219    fn escapar_senha_shell_senha_usuario() {
1220        // Senha real do caso de uso
1221        assert_eq!(
1222            escapar_senha_shell("Ih8Tml@Ymnwku1:G@W~2"),
1223            "'Ih8Tml@Ymnwku1:G@W~2'"
1224        );
1225    }
1226
1227    #[test]
1228    fn sudo_cmd_com_senha_formato_correto() {
1229        let senha = "test123";
1230        let comando = "apt update";
1231        let escaped = escapar_senha_shell(senha);
1232        let sudo_cmd = format!("printf '%s\\n' {} | sudo -S -p '' {}", escaped, comando);
1233        assert_eq!(
1234            sudo_cmd,
1235            "printf '%s\\n' 'test123' | sudo -S -p '' apt update"
1236        );
1237    }
1238
1239    #[test]
1240    fn sudo_cmd_sem_senha_formato_correto() {
1241        let comando = "apt update";
1242        let sudo_cmd = format!("sudo {}", comando);
1243        assert_eq!(sudo_cmd, "sudo apt update");
1244    }
1245
1246    #[test]
1247    fn aplicar_overrides_com_todos_os_campos() {
1248        use secrecy::ExposeSecret;
1249        let mut vps = modelo::VpsRegistro::novo(
1250            "srv".into(),
1251            "1.2.3.4".into(),
1252            22,
1253            "root".into(),
1254            SecretString::from("senha_original".to_string()),
1255            Some(30_000),
1256            Some(50_000),
1257            None,
1258            None,
1259        );
1260        aplicar_overrides(
1261            &mut vps,
1262            Some("nova_senha".to_string()),
1263            Some("nova_sudo".to_string()),
1264            Some(60_000),
1265        );
1266        assert_eq!(vps.senha.expose_secret(), "nova_senha");
1267        assert_eq!(
1268            vps.senha_sudo.as_ref().unwrap().expose_secret(),
1269            "nova_sudo"
1270        );
1271        assert_eq!(vps.timeout_ms, 60_000);
1272    }
1273
1274    #[test]
1275    fn aplicar_overrides_preserva_campos_quando_none() {
1276        use secrecy::ExposeSecret;
1277        let mut vps = modelo::VpsRegistro::novo(
1278            "srv".into(),
1279            "1.2.3.4".into(),
1280            22,
1281            "root".into(),
1282            SecretString::from("senha_original".to_string()),
1283            Some(30_000),
1284            Some(50_000),
1285            Some(SecretString::from("sudo_original".to_string())),
1286            None,
1287        );
1288        aplicar_overrides(&mut vps, None, None, None);
1289        assert_eq!(vps.senha.expose_secret(), "senha_original");
1290        assert_eq!(
1291            vps.senha_sudo.as_ref().unwrap().expose_secret(),
1292            "sudo_original"
1293        );
1294        assert_eq!(vps.timeout_ms, 30_000);
1295    }
1296
1297    #[test]
1298    fn construir_configuracao_com_timeout_diferente() {
1299        let registro = modelo::VpsRegistro::novo(
1300            "srv".into(),
1301            "host.example.com".into(),
1302            2222,
1303            "admin".into(),
1304            SecretString::from("pass".to_string()),
1305            Some(120_000),
1306            Some(50_000),
1307            None,
1308            None,
1309        );
1310        let cfg = construir_configuracao(&registro);
1311        assert_eq!(cfg.timeout_ms, 120_000);
1312    }
1313}