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/// Dispatcher dos subcomandos `vps`.
115pub async fn executar_comando_vps(
116    acao: AcaoVps,
117    config_override: Option<PathBuf>,
118    _formato: FormatoSaida,
119) -> Result<()> {
120    let caminho = resolver_caminho_config(config_override)?;
121
122    match acao {
123        AcaoVps::Add {
124            name,
125            host,
126            port,
127            user,
128            password,
129            timeout,
130            max_chars,
131            sudo_password,
132            su_password,
133        } => {
134            let name = crate::paths::normalizar_nfc(&name);
135            let mut arquivo = carregar(&caminho)?;
136            if arquivo.hosts.contains_key(&name) {
137                return Err(ErroSshCli::VpsDuplicada(name).into());
138            }
139            let senha = SecretString::from(password.unwrap_or_default());
140            let max_chars_num: usize = parse_max_chars(&max_chars);
141            let registro = VpsRegistro::novo(
142                name.clone(),
143                host,
144                port,
145                user,
146                senha,
147                Some(timeout),
148                Some(max_chars_num),
149                sudo_password.map(SecretString::from),
150                su_password.map(SecretString::from),
151            );
152            arquivo.hosts.insert(name.clone(), registro);
153            arquivo.schema_version = modelo::SCHEMA_VERSION_ATUAL;
154            salvar(&caminho, &arquivo)?;
155            crate::output::imprimir_sucesso(&format!("VPS '{name}' adicionada ao registro"));
156        }
157        AcaoVps::List { json } => {
158            let arquivo = carregar(&caminho)?;
159            let registros: Vec<_> = arquivo.hosts.values().cloned().collect();
160            if json {
161                crate::output::imprimir_lista_json(&registros);
162            } else {
163                crate::output::imprimir_lista_texto(&registros);
164            }
165        }
166        AcaoVps::Remove { nome } => {
167            let mut arquivo = carregar(&caminho)?;
168            if arquivo.hosts.remove(&nome).is_none() {
169                return Err(ErroSshCli::VpsNaoEncontrada(nome).into());
170            }
171            salvar(&caminho, &arquivo)?;
172            crate::output::imprimir_sucesso(&format!("VPS '{nome}' removida"));
173        }
174        AcaoVps::Edit {
175            nome,
176            host,
177            port,
178            user,
179            password,
180            timeout,
181            max_chars,
182            sudo_password,
183            su_password,
184        } => {
185            let mut arquivo = carregar(&caminho)?;
186            let registro = arquivo
187                .hosts
188                .get_mut(&nome)
189                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
190            if let Some(h) = host {
191                registro.host = h;
192            }
193            if let Some(p) = port {
194                registro.porta = p;
195            }
196            if let Some(u) = user {
197                registro.usuario = u;
198            }
199            if let Some(pw) = password {
200                registro.senha = SecretString::from(pw);
201            }
202            if let Some(t) = timeout {
203                registro.timeout_ms = t;
204            }
205            if let Some(m) = max_chars {
206                registro.max_chars = parse_max_chars(&m);
207            }
208            if let Some(sp) = sudo_password {
209                registro.senha_sudo = Some(SecretString::from(sp));
210            }
211            if let Some(sp) = su_password {
212                registro.senha_su = Some(SecretString::from(sp));
213            }
214            salvar(&caminho, &arquivo)?;
215            crate::output::imprimir_sucesso(&format!("VPS '{nome}' editada"));
216        }
217        AcaoVps::Show { nome, json } => {
218            let arquivo = carregar(&caminho)?;
219            let registro = arquivo
220                .hosts
221                .get(&nome)
222                .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome.clone()))?;
223            if json {
224                crate::output::imprimir_detalhes_json(registro);
225            } else {
226                crate::output::imprimir_detalhes_texto(registro);
227            }
228        }
229        AcaoVps::Path => {
230            crate::output::escrever_linha(&caminho.display().to_string())?;
231        }
232    }
233    Ok(())
234}
235
236/// Define a VPS ativa gravando seu nome em `<config_dir>/active`.
237///
238/// Esta função é chamada pelo subcomando `connect <nome>` e valida que a VPS
239/// existe no registro antes de gravar.
240pub async fn executar_connect(nome: &str, config_override: Option<PathBuf>) -> Result<()> {
241    let caminho = resolver_caminho_config(config_override)?;
242    let arquivo = carregar(&caminho)?;
243    if !arquivo.hosts.contains_key(nome) {
244        return Err(ErroSshCli::VpsNaoEncontrada(nome.to_string()).into());
245    }
246
247    let arquivo_ativo = caminho
248        .parent()
249        .map(|p| p.join("active"))
250        .unwrap_or_else(|| PathBuf::from("active"));
251    if let Some(pai) = arquivo_ativo.parent() {
252        std::fs::create_dir_all(pai)?;
253    }
254    std::fs::write(&arquivo_ativo, nome)?;
255    crate::output::imprimir_sucesso(&format!("VPS ativa definida: '{nome}'"));
256    Ok(())
257}
258
259/// Busca um registro de VPS por nome.
260///
261/// Retorna `Ok(None)` se a VPS não existir (para que o caller decida o tratamento).
262pub fn buscar_por_nome(
263    config_override: Option<PathBuf>,
264    nome: &str,
265) -> ResultadoSshCli<Option<VpsRegistro>> {
266    let caminho = resolver_caminho_config(config_override)?;
267    let arquivo = carregar(&caminho)?;
268    Ok(arquivo.hosts.get(nome).cloned())
269}
270
271/// Lê o nome da VPS ativa.
272pub fn ler_vps_ativa(config_override: Option<PathBuf>) -> ResultadoSshCli<Option<String>> {
273    let caminho = resolver_caminho_config(config_override)?;
274    let arquivo_ativo = caminho
275        .parent()
276        .map(|p| p.join("active"))
277        .unwrap_or_else(|| PathBuf::from("active"));
278    if !arquivo_ativo.exists() {
279        return Ok(None);
280    }
281    let nome = std::fs::read_to_string(&arquivo_ativo)?;
282    Ok(Some(nome.trim().to_string()))
283}
284
285fn parse_max_chars(s: &str) -> usize {
286    if s == "none" || s == "0" {
287        usize::MAX
288    } else {
289        s.parse().unwrap_or(modelo::MAX_CHARS_PADRAO)
290    }
291}
292
293/// Constrói `ConfiguracaoConexao` a partir de um `VpsRegistro`.
294fn construir_configuracao(vps: &VpsRegistro) -> ConfiguracaoConexao {
295    ConfiguracaoConexao {
296        host: vps.host.clone(),
297        porta: vps.porta,
298        usuario: vps.usuario.clone(),
299        senha: vps.senha.clone(),
300        timeout_ms: vps.timeout_ms,
301    }
302}
303
304/// Executa um comando em uma VPS via SSH.
305pub async fn executar_exec(
306    vps_nome: &str,
307    comando: &str,
308    config_override: Option<PathBuf>,
309    formato: FormatoSaida,
310    json: bool,
311) -> Result<()> {
312    if crate::signals::cancelado() || crate::signals::terminado() {
313        return Err(anyhow::anyhow!(crate::i18n::t(
314            crate::i18n::Mensagem::OperacaoCancelada
315        )));
316    }
317    let caminho = resolver_caminho_config(config_override)?;
318    let arquivo = carregar(&caminho)?;
319    let vps = arquivo
320        .hosts
321        .get(vps_nome)
322        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
323
324    let cfg = construir_configuracao(vps);
325    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
326    executar_exec_with_client(vps, comando, cliente, formato, json).await
327}
328
329/// Versão testável de executar_exec que aceita o cliente como parâmetro.
330pub async fn executar_exec_with_client(
331    vps: &VpsRegistro,
332    comando: &str,
333    mut cliente: Box<dyn ClienteSshTrait>,
334    formato: FormatoSaida,
335    json: bool,
336) -> Result<()> {
337    if crate::signals::cancelado() || crate::signals::terminado() {
338        return Err(anyhow::anyhow!(crate::i18n::t(
339            crate::i18n::Mensagem::OperacaoCancelada
340        )));
341    }
342    let saida = cliente.executar_comando(comando, vps.max_chars).await?;
343    cliente.desconectar().await?;
344    if formato == FormatoSaida::Json || json {
345        output::imprimir_saida_execucao_json(&saida);
346    } else {
347        output::imprimir_saida_execucao(&saida);
348    }
349    if let Some(code) = saida.exit_code {
350        if code != 0 {
351            return Err(ErroSshCli::ComandoFalhou {
352                exit_code: code,
353                stderr: saida.stderr.clone(),
354            }
355            .into());
356        }
357    }
358    Ok(())
359}
360
361/// Executa um comando com `sudo` em uma VPS via SSH.
362///
363/// Se a VPS tiver `senha_sudo` definida, o comando será executado prefixado com
364/// `sudo -S` que tenta ler a senha do stdin. Caso contrário, usa `sudo -k -s`.
365/// Nota: a implementação atual de stdin do russh é limitada; esta função
366/// funciona melhor quando sudo está configurado como NOPASSWD ou quando a senha
367/// sudo é fornecida via variável de ambiente do remote shell.
368pub async fn executar_sudo_exec(
369    vps_nome: &str,
370    comando: &str,
371    config_override: Option<PathBuf>,
372    formato: FormatoSaida,
373    json: bool,
374) -> Result<()> {
375    if crate::signals::cancelado() || crate::signals::terminado() {
376        return Err(anyhow::anyhow!(crate::i18n::t(
377            crate::i18n::Mensagem::OperacaoCancelada
378        )));
379    }
380    let caminho = resolver_caminho_config(config_override)?;
381    let arquivo = carregar(&caminho)?;
382    let vps = arquivo
383        .hosts
384        .get(vps_nome)
385        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(vps_nome.to_string()))?;
386
387    let cfg = construir_configuracao(vps);
388    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
389    executar_sudo_exec_with_client(vps, comando, cliente, formato, json).await
390}
391
392/// Versão testável de executar_sudo_exec que aceita o cliente como parâmetro.
393pub async fn executar_sudo_exec_with_client(
394    vps: &VpsRegistro,
395    comando: &str,
396    mut cliente: Box<dyn ClienteSshTrait>,
397    formato: FormatoSaida,
398    json: bool,
399) -> Result<()> {
400    if crate::signals::cancelado() || crate::signals::terminado() {
401        return Err(anyhow::anyhow!(crate::i18n::t(
402            crate::i18n::Mensagem::OperacaoCancelada
403        )));
404    }
405    let sudo_cmd = if vps.senha_sudo.is_some() {
406        format!(
407            "sudo -S {} 2>/dev/null || sudo {} 2>/dev/null || {}",
408            comando, comando, comando
409        )
410    } else {
411        format!("sudo -k -s {} 2>/dev/null || {}", comando, comando)
412    };
413
414    let saida = cliente.executar_comando(&sudo_cmd, vps.max_chars).await?;
415    cliente.desconectar().await?;
416    if formato == FormatoSaida::Json || json {
417        output::imprimir_saida_execucao_json(&saida);
418    } else {
419        output::imprimir_saida_execucao(&saida);
420    }
421    if let Some(code) = saida.exit_code {
422        if code != 0 {
423            return Err(ErroSshCli::ComandoFalhou {
424                exit_code: code,
425                stderr: saida.stderr.clone(),
426            }
427            .into());
428        }
429    }
430    Ok(())
431}
432
433/// Executa um health-check (ping SSH) em uma VPS e imprime a latência.
434///
435/// Se `vps_nome` for `None`, usa a VPS ativa registrada.
436pub async fn executar_health_check(
437    vps_nome: Option<&str>,
438    config_override: Option<PathBuf>,
439    formato: FormatoSaida,
440) -> Result<()> {
441    if crate::signals::cancelado() || crate::signals::terminado() {
442        return Err(anyhow::anyhow!(crate::i18n::t(
443            crate::i18n::Mensagem::OperacaoCancelada
444        )));
445    }
446    let nome_resolvido: String = match vps_nome {
447        Some(n) => n.to_string(),
448        None => {
449            let ativa = ler_vps_ativa(config_override.clone())?;
450            ativa.ok_or_else(|| {
451                anyhow::anyhow!(crate::i18n::t(crate::i18n::Mensagem::HealthCheckSemVps))
452            })?
453        }
454    };
455    let caminho = resolver_caminho_config(config_override)?;
456    let arquivo = carregar(&caminho)?;
457    let vps = arquivo
458        .hosts
459        .get(&nome_resolvido)
460        .ok_or_else(|| ErroSshCli::VpsNaoEncontrada(nome_resolvido.clone()))?;
461
462    let cfg = construir_configuracao(vps);
463    let inicio = std::time::Instant::now();
464    let cliente: Box<dyn ClienteSshTrait> = <ClienteSsh as ClienteSshTrait>::conectar(cfg).await?;
465    let latencia_ms = inicio.elapsed().as_millis() as u64;
466    cliente.desconectar().await?;
467
468    if formato == FormatoSaida::Json {
469        output::imprimir_health_check_json(&nome_resolvido, latencia_ms);
470    } else {
471        output::imprimir_health_check(&nome_resolvido, latencia_ms);
472    }
473    Ok(())
474}
475
476#[cfg(test)]
477mod testes {
478    use super::*;
479
480    #[test]
481    fn arquivo_vazio_serializa_com_schema() {
482        let arq = ArquivoConfig {
483            schema_version: modelo::SCHEMA_VERSION_ATUAL,
484            hosts: BTreeMap::new(),
485        };
486        let texto = toml::to_string(&arq).unwrap();
487        assert!(texto.contains("schema_version = 1"));
488    }
489
490    #[test]
491    fn parse_max_chars_none_retorna_usize_max() {
492        assert_eq!(parse_max_chars("none"), usize::MAX);
493        assert_eq!(parse_max_chars("0"), usize::MAX);
494        assert_eq!(parse_max_chars("1000"), 1000);
495    }
496
497    #[test]
498    fn parse_max_chars_valor_invalido() {
499        assert_eq!(parse_max_chars("abc"), modelo::MAX_CHARS_PADRAO);
500        assert_eq!(parse_max_chars("invalido"), modelo::MAX_CHARS_PADRAO);
501    }
502
503    #[test]
504    fn construir_configuracao_copia_campos_corretamente() {
505        let registro = modelo::VpsRegistro::novo(
506            "srv".into(),
507            "host.example.com".into(),
508            2222,
509            "admin".into(),
510            SecretString::from("pass".to_string()),
511            Some(60_000),
512            Some(50_000),
513            None,
514            None,
515        );
516        let cfg = construir_configuracao(&registro);
517        assert_eq!(cfg.host, "host.example.com");
518        assert_eq!(cfg.porta, 2222);
519        assert_eq!(cfg.usuario, "admin");
520        assert_eq!(cfg.timeout_ms, 60_000);
521    }
522
523    #[test]
524    fn arquivo_config_vazio_tem_schema_correto() {
525        let arq = ArquivoConfig {
526            schema_version: modelo::SCHEMA_VERSION_ATUAL,
527            hosts: BTreeMap::new(),
528        };
529        let toml_str = toml::to_string(&arq).unwrap();
530        assert!(toml_str.contains("schema_version"));
531        assert!(toml_str.contains("hosts"));
532    }
533
534    #[test]
535    fn arquivo_config_com_hosts_serializa_para_toml() {
536        let mut hosts = BTreeMap::new();
537        hosts.insert(
538            "teste".to_string(),
539            modelo::VpsRegistro::novo(
540                "teste".into(),
541                "1.2.3.4".into(),
542                22,
543                "root".into(),
544                SecretString::from("senha".to_string()),
545                None,
546                None,
547                None,
548                None,
549            ),
550        );
551        let arq = ArquivoConfig {
552            schema_version: modelo::SCHEMA_VERSION_ATUAL,
553            hosts,
554        };
555        let toml_str = toml::to_string(&arq).unwrap();
556        assert!(toml_str.contains("teste"));
557        assert!(toml_str.contains("1.2.3.4"));
558    }
559
560    #[test]
561    fn resolver_caminho_config_com_override_diretorio() {
562        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
563        assert!(resultado.is_ok());
564        assert_eq!(
565            resultado.unwrap(),
566            PathBuf::from("/tmp/test-dir/config.toml")
567        );
568    }
569
570    #[test]
571    fn resolver_caminho_config_com_override_arquivo_explicito() {
572        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test.toml")));
573        assert!(resultado.is_ok());
574        assert_eq!(resultado.unwrap(), PathBuf::from("/tmp/test.toml"));
575    }
576
577    #[test]
578    fn resolver_caminho_config_sem_extensao_trata_como_diretorio() {
579        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test")));
580        assert!(resultado.is_ok());
581        assert_eq!(resultado.unwrap(), PathBuf::from("/tmp/test/config.toml"));
582    }
583
584    #[test]
585    fn carregar_retorna_config_vazio_quando_arquivo_nao_existe() {
586        let tmp = tempfile::TempDir::new().unwrap();
587        let caminho = tmp.path().join("nao-existe.toml");
588        let resultado = carregar(&caminho);
589        assert!(resultado.is_ok());
590        let arq = resultado.unwrap();
591        assert_eq!(arq.schema_version, modelo::SCHEMA_VERSION_ATUAL);
592        assert!(arq.hosts.is_empty());
593    }
594
595    #[test]
596    fn carregar_faz_parse_de_toml_existente() {
597        let tmp = tempfile::TempDir::new().unwrap();
598        let caminho = tmp.path().join("config.toml");
599        let conteudo = r#"
600schema_version = 1
601[hosts.minha-vps]
602nome = "minha-vps"
603host = "1.2.3.4"
604porta = 22
605usuario = "root"
606senha = "senhateste"
607timeout_ms = 30000
608max_chars = 100000
609schema_version = 1
610adicionado_em = "2024-01-01T00:00:00Z"
611"#;
612        std::fs::write(&caminho, conteudo).unwrap();
613        let resultado = carregar(&caminho);
614        assert!(resultado.is_ok());
615        let arq = resultado.unwrap();
616        assert!(arq.hosts.contains_key("minha-vps"));
617    }
618
619    #[test]
620    fn ler_vps_ativa_retorna_none_quando_arquivo_nao_existe() {
621        let tmp = tempfile::TempDir::new().unwrap();
622        let config_dir = tmp.path().join("ssh-cli");
623        std::fs::create_dir_all(&config_dir).unwrap();
624        let caminho_config = config_dir.join("config.toml");
625        std::fs::write(&caminho_config, "").unwrap();
626        let resultado = ler_vps_ativa(Some(config_dir.clone()));
627        assert!(resultado.is_ok());
628        assert!(resultado.unwrap().is_none());
629    }
630
631    #[test]
632    fn ler_vps_ativa_retorna_nome_quando_arquivo_existe() {
633        let tmp = tempfile::TempDir::new().unwrap();
634        let config_dir = tmp.path().join("ssh-cli");
635        std::fs::create_dir_all(&config_dir).unwrap();
636        let caminho_config = config_dir.join("config.toml");
637        let caminho_ativo = config_dir.join("active");
638        std::fs::write(&caminho_config, "").unwrap();
639        std::fs::write(&caminho_ativo, "minha-vps\n").unwrap();
640        let resultado = ler_vps_ativa(Some(config_dir));
641        assert!(resultado.is_ok());
642        assert_eq!(resultado.unwrap(), Some("minha-vps".to_string()));
643    }
644
645    #[test]
646    fn ler_vps_ativa_com_override_diretorio() {
647        let tmp = tempfile::TempDir::new().unwrap();
648        let config_dir = tmp.path().join("minha-config");
649        std::fs::create_dir_all(&config_dir).unwrap();
650        let caminho_config = config_dir.join("config.toml");
651        let caminho_ativo = config_dir.join("active");
652        std::fs::write(&caminho_config, "").unwrap();
653        std::fs::write(&caminho_ativo, "vps-teste\n").unwrap();
654        let resultado = ler_vps_ativa(Some(config_dir));
655        assert!(resultado.is_ok());
656        assert_eq!(resultado.unwrap(), Some("vps-teste".to_string()));
657    }
658
659    #[test]
660    fn buscar_por_nome_retorna_none_quando_nao_existe() {
661        let tmp = tempfile::TempDir::new().unwrap();
662        let caminho = tmp.path().join("config.toml");
663        std::fs::write(&caminho, "").unwrap();
664        let resultado = buscar_por_nome(Some(caminho.clone()), "inexistente");
665        assert!(resultado.is_ok());
666        assert!(resultado.unwrap().is_none());
667    }
668
669    #[test]
670    fn buscar_por_nome_retorna_registro_quando_existe() {
671        let tmp = tempfile::TempDir::new().unwrap();
672        let caminho = tmp.path().join("config.toml");
673        let conteudo = r#"
674schema_version = 1
675[hosts.minha-vps]
676nome = "minha-vps"
677host = "1.2.3.4"
678porta = 22
679usuario = "root"
680senha = "senhateste"
681timeout_ms = 30000
682max_chars = 100000
683schema_version = 1
684adicionado_em = "2024-01-01T00:00:00Z"
685"#;
686        std::fs::write(&caminho, conteudo).unwrap();
687        let resultado = buscar_por_nome(Some(caminho), "minha-vps");
688        assert!(resultado.is_ok());
689        let vps = resultado.unwrap();
690        assert!(vps.is_some());
691        assert_eq!(vps.unwrap().nome, "minha-vps");
692    }
693
694    #[cfg(unix)]
695    #[test]
696    fn salvar_aplica_permissoes_600_no_unix() {
697        use std::os::unix::fs::PermissionsExt;
698        let tmp = tempfile::TempDir::new().unwrap();
699        let caminho = tmp.path().join("config.toml");
700        let arquivo = ArquivoConfig {
701            schema_version: modelo::SCHEMA_VERSION_ATUAL,
702            hosts: BTreeMap::new(),
703        };
704        let resultado = salvar(&caminho, &arquivo);
705        assert!(resultado.is_ok());
706        let metadados = std::fs::metadata(&caminho).unwrap();
707        let permissoes = metadados.permissions();
708        assert_eq!(permissoes.mode() & 0o777, 0o600);
709    }
710
711    #[test]
712    fn salvar_cria_diretorio_pai_se_nao_existir() {
713        let tmp = tempfile::TempDir::new().unwrap();
714        let caminho = tmp
715            .path()
716            .join("subdir1")
717            .join("subdir2")
718            .join("config.toml");
719        let arquivo = ArquivoConfig {
720            schema_version: modelo::SCHEMA_VERSION_ATUAL,
721            hosts: BTreeMap::new(),
722        };
723        let resultado = salvar(&caminho, &arquivo);
724        assert!(resultado.is_ok());
725        assert!(caminho.exists());
726    }
727
728    #[test]
729    fn arquivo_config_parsing_com_campos_parciais() {
730        let tmp = tempfile::TempDir::new().unwrap();
731        let caminho = tmp.path().join("config.toml");
732        let conteudo = r#"
733schema_version = 1
734[hosts.vps-minima]
735nome = "vps-minima"
736host = "5.6.7.8"
737porta = 2222
738usuario = "admin"
739senha = "senha123"
740timeout_ms = 30000
741max_chars = 100000
742schema_version = 1
743adicionado_em = "2024-01-01T00:00:00Z"
744"#;
745        std::fs::write(&caminho, conteudo).unwrap();
746        let resultado = carregar(&caminho);
747        assert!(resultado.is_ok());
748        let arq = resultado.unwrap();
749        assert!(arq.hosts.contains_key("vps-minima"));
750        let vps = arq.hosts.get("vps-minima").unwrap();
751        assert_eq!(vps.host, "5.6.7.8");
752        assert_eq!(vps.porta, 2222);
753    }
754
755    #[tokio::test]
756    async fn executar_exec_with_client_retorna_ok_quando_mock_sucesso() {
757        use crate::ssh::cliente::mocks::MockClienteSsh;
758        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
759
760        let mut mock = MockClienteSsh::new();
761        mock.expect_executar_comando()
762            .returning(|_cmd, _max_chars| {
763                Ok(SaidaExecucao {
764                    stdout: "output test".to_string(),
765                    stderr: String::new(),
766                    exit_code: Some(0),
767                    truncado_stdout: false,
768                    truncado_stderr: false,
769                    duracao_ms: 100,
770                })
771            });
772        mock.expect_desconectar().returning(|| Ok(()));
773
774        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
775        let registro = modelo::VpsRegistro::novo(
776            "teste".into(),
777            "localhost".into(),
778            22,
779            "user".into(),
780            SecretString::from("pass".to_string()),
781            None,
782            None,
783            None,
784            None,
785        );
786
787        let resultado =
788            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
789                .await;
790        assert!(resultado.is_ok());
791    }
792
793    #[tokio::test]
794    async fn executar_sudo_exec_with_client_retorna_ok_quando_mock_sucesso() {
795        use crate::ssh::cliente::mocks::MockClienteSsh;
796        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
797
798        let mut mock = MockClienteSsh::new();
799        mock.expect_executar_comando()
800            .returning(|_cmd, _max_chars| {
801                Ok(SaidaExecucao {
802                    stdout: "sudo output".to_string(),
803                    stderr: String::new(),
804                    exit_code: Some(0),
805                    truncado_stdout: false,
806                    truncado_stderr: false,
807                    duracao_ms: 100,
808                })
809            });
810        mock.expect_desconectar().returning(|| Ok(()));
811
812        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
813        let mut registro = modelo::VpsRegistro::novo(
814            "teste".into(),
815            "localhost".into(),
816            22,
817            "user".into(),
818            SecretString::from("pass".to_string()),
819            None,
820            None,
821            None,
822            None,
823        );
824        registro.senha_sudo = Some(SecretString::from("sudo_pass".to_string()));
825
826        let resultado = executar_sudo_exec_with_client(
827            &registro,
828            "echo sudo",
829            cliente,
830            FormatoSaida::Text,
831            false,
832        )
833        .await;
834        assert!(resultado.is_ok());
835    }
836
837    #[tokio::test]
838    async fn executar_sudo_exec_with_client_retorna_ok_quando_sem_senha_sudo() {
839        use crate::ssh::cliente::mocks::MockClienteSsh;
840        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
841
842        let mut mock = MockClienteSsh::new();
843        mock.expect_executar_comando()
844            .returning(|_cmd, _max_chars| {
845                Ok(SaidaExecucao {
846                    stdout: "output".to_string(),
847                    stderr: String::new(),
848                    exit_code: Some(0),
849                    truncado_stdout: false,
850                    truncado_stderr: false,
851                    duracao_ms: 100,
852                })
853            });
854        mock.expect_desconectar().returning(|| Ok(()));
855
856        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
857        let registro = modelo::VpsRegistro::novo(
858            "teste".into(),
859            "localhost".into(),
860            22,
861            "user".into(),
862            SecretString::from("pass".to_string()),
863            None,
864            None,
865            None,
866            None,
867        );
868
869        let resultado = executar_sudo_exec_with_client(
870            &registro,
871            "echo test",
872            cliente,
873            FormatoSaida::Text,
874            false,
875        )
876        .await;
877        assert!(resultado.is_ok());
878    }
879
880    #[tokio::test]
881    async fn executar_sudo_exec_with_client_retorna_erro_quando_executar_comando_falha() {
882        use crate::ssh::cliente::mocks::MockClienteSsh;
883        use crate::ssh::cliente::ClienteSshTrait;
884
885        let mut mock = MockClienteSsh::new();
886        mock.expect_executar_comando()
887            .returning(|_cmd, _max_chars| {
888                Err(crate::erros::ErroSshCli::CanalFalhou(
889                    "mock error".to_string(),
890                ))
891            });
892
893        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
894        let registro = modelo::VpsRegistro::novo(
895            "teste".into(),
896            "localhost".into(),
897            22,
898            "user".into(),
899            SecretString::from("pass".to_string()),
900            None,
901            None,
902            None,
903            None,
904        );
905
906        let resultado =
907            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
908                .await;
909        assert!(resultado.is_err());
910    }
911
912    #[tokio::test]
913    async fn executar_scp_upload_with_client_retorna_ok_quando_mock_sucesso() {
914        use crate::ssh::cliente::mocks::MockClienteSsh;
915        use crate::ssh::cliente::{ClienteSshTrait, TransferenciaResultado};
916
917        let mut mock = MockClienteSsh::new();
918        mock.expect_upload().returning(|_local, _remote| {
919            Ok(TransferenciaResultado {
920                bytes_transferidos: 1024,
921                duracao_ms: 50,
922            })
923        });
924        mock.expect_desconectar().returning(|| Ok(()));
925
926        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
927        let registro = modelo::VpsRegistro::novo(
928            "teste".into(),
929            "localhost".into(),
930            22,
931            "user".into(),
932            SecretString::from("pass".to_string()),
933            None,
934            None,
935            None,
936            None,
937        );
938
939        let resultado = crate::scp::executar_scp_upload_with_client(
940            &registro,
941            std::path::Path::new("/local/file.txt"),
942            std::path::Path::new("/remote/file.txt"),
943            cliente,
944        )
945        .await;
946        assert!(resultado.is_ok());
947    }
948
949    #[tokio::test]
950    async fn executar_scp_download_with_client_retorna_ok_quando_mock_sucesso() {
951        use crate::ssh::cliente::mocks::MockClienteSsh;
952        use crate::ssh::cliente::{ClienteSshTrait, TransferenciaResultado};
953
954        let mut mock = MockClienteSsh::new();
955        mock.expect_download().returning(|_remote, _local| {
956            Ok(TransferenciaResultado {
957                bytes_transferidos: 2048,
958                duracao_ms: 75,
959            })
960        });
961        mock.expect_desconectar().returning(|| Ok(()));
962
963        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
964        let registro = modelo::VpsRegistro::novo(
965            "teste".into(),
966            "localhost".into(),
967            22,
968            "user".into(),
969            SecretString::from("pass".to_string()),
970            None,
971            None,
972            None,
973            None,
974        );
975
976        let resultado = crate::scp::executar_scp_download_with_client(
977            &registro,
978            std::path::Path::new("/remote/file.txt"),
979            std::path::Path::new("/local/file.txt"),
980            cliente,
981        )
982        .await;
983        assert!(resultado.is_ok());
984    }
985
986    #[tokio::test]
987    async fn executar_scp_upload_with_client_retorna_erro_quando_upload_falha() {
988        use crate::ssh::cliente::mocks::MockClienteSsh;
989        use crate::ssh::cliente::ClienteSshTrait;
990
991        let mut mock = MockClienteSsh::new();
992        mock.expect_upload().returning(|_local, _remote| {
993            Err(crate::erros::ErroSshCli::Generico(
994                "falha no upload".to_string(),
995            ))
996        });
997
998        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
999        let registro = modelo::VpsRegistro::novo(
1000            "teste".into(),
1001            "localhost".into(),
1002            22,
1003            "user".into(),
1004            SecretString::from("pass".to_string()),
1005            None,
1006            None,
1007            None,
1008            None,
1009        );
1010
1011        let resultado = crate::scp::executar_scp_upload_with_client(
1012            &registro,
1013            std::path::Path::new("/local/file.txt"),
1014            std::path::Path::new("/remote/file.txt"),
1015            cliente,
1016        )
1017        .await;
1018        assert!(resultado.is_err());
1019    }
1020
1021    #[tokio::test]
1022    async fn executar_scp_download_with_client_retorna_erro_quando_download_falha() {
1023        use crate::ssh::cliente::mocks::MockClienteSsh;
1024        use crate::ssh::cliente::ClienteSshTrait;
1025
1026        let mut mock = MockClienteSsh::new();
1027        mock.expect_download().returning(|_remote, _local| {
1028            Err(crate::erros::ErroSshCli::Generico(
1029                "falha no download".to_string(),
1030            ))
1031        });
1032
1033        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1034        let registro = modelo::VpsRegistro::novo(
1035            "teste".into(),
1036            "localhost".into(),
1037            22,
1038            "user".into(),
1039            SecretString::from("pass".to_string()),
1040            None,
1041            None,
1042            None,
1043            None,
1044        );
1045
1046        let resultado = crate::scp::executar_scp_download_with_client(
1047            &registro,
1048            std::path::Path::new("/remote/file.txt"),
1049            std::path::Path::new("/local/file.txt"),
1050            cliente,
1051        )
1052        .await;
1053        assert!(resultado.is_err());
1054    }
1055
1056    #[tokio::test]
1057    async fn executar_sudo_exec_with_client_retorna_erro_quando_desconectar_falha() {
1058        use crate::ssh::cliente::mocks::MockClienteSsh;
1059        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
1060
1061        let mut mock = MockClienteSsh::new();
1062        mock.expect_executar_comando()
1063            .returning(|_cmd, _max_chars| {
1064                Ok(SaidaExecucao {
1065                    stdout: "output".to_string(),
1066                    stderr: String::new(),
1067                    exit_code: Some(0),
1068                    truncado_stdout: false,
1069                    truncado_stderr: false,
1070                    duracao_ms: 100,
1071                })
1072            });
1073        mock.expect_desconectar().returning(|| {
1074            Err(crate::erros::ErroSshCli::CanalFalhou(
1075                "erro desconexão".to_string(),
1076            ))
1077        });
1078
1079        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1080        let registro = modelo::VpsRegistro::novo(
1081            "teste".into(),
1082            "localhost".into(),
1083            22,
1084            "user".into(),
1085            SecretString::from("pass".to_string()),
1086            None,
1087            None,
1088            None,
1089            None,
1090        );
1091
1092        let resultado =
1093            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
1094                .await;
1095        assert!(resultado.is_err());
1096    }
1097
1098    #[test]
1099    fn caminho_config_padrao_com_ssh_cli_home_retorna_path() {
1100        let tmp = tempfile::TempDir::new().unwrap();
1101        let home_dir = tmp.path().join("ssh-cli-home");
1102        std::fs::create_dir_all(&home_dir).unwrap();
1103        std::env::set_var("SSH_CLI_HOME", home_dir.to_str().unwrap());
1104        let resultado = caminho_config_padrao();
1105        std::env::remove_var("SSH_CLI_HOME");
1106        assert!(resultado.is_ok());
1107        assert!(resultado
1108            .unwrap()
1109            .to_str()
1110            .unwrap()
1111            .contains("ssh-cli-home"));
1112    }
1113
1114    #[test]
1115    fn caminho_config_padrao_com_path_traversal_retorna_erro() {
1116        std::env::set_var("SSH_CLI_HOME", "/tmp/../etc/config");
1117        let resultado = caminho_config_padrao();
1118        std::env::remove_var("SSH_CLI_HOME");
1119        assert!(resultado.is_err());
1120    }
1121
1122    #[test]
1123    fn caminho_config_padrao_sem_env_retorna_path_valido() {
1124        std::env::remove_var("SSH_CLI_HOME");
1125        let resultado = caminho_config_padrao();
1126        if let Ok(path) = resultado {
1127            assert!(path.to_str().unwrap().contains("ssh-cli"));
1128        }
1129    }
1130
1131    #[test]
1132    fn construir_configuracao_com_timeout_diferente() {
1133        let registro = modelo::VpsRegistro::novo(
1134            "srv".into(),
1135            "host.example.com".into(),
1136            2222,
1137            "admin".into(),
1138            SecretString::from("pass".to_string()),
1139            Some(120_000),
1140            Some(50_000),
1141            None,
1142            None,
1143        );
1144        let cfg = construir_configuracao(&registro);
1145        assert_eq!(cfg.timeout_ms, 120_000);
1146    }
1147}