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 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 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
534    #[test]
535    fn arquivo_vazio_serializa_com_schema() {
536        let arq = ArquivoConfig {
537            schema_version: modelo::SCHEMA_VERSION_ATUAL,
538            hosts: BTreeMap::new(),
539        };
540        let texto = toml::to_string(&arq).unwrap();
541        assert!(texto.contains("schema_version = 1"));
542    }
543
544    #[test]
545    fn parse_max_chars_none_retorna_usize_max() {
546        assert_eq!(parse_max_chars("none"), usize::MAX);
547        assert_eq!(parse_max_chars("0"), usize::MAX);
548        assert_eq!(parse_max_chars("1000"), 1000);
549    }
550
551    #[test]
552    fn parse_max_chars_valor_invalido() {
553        assert_eq!(parse_max_chars("abc"), modelo::MAX_CHARS_PADRAO);
554        assert_eq!(parse_max_chars("invalido"), modelo::MAX_CHARS_PADRAO);
555    }
556
557    #[test]
558    fn construir_configuracao_copia_campos_corretamente() {
559        let registro = modelo::VpsRegistro::novo(
560            "srv".into(),
561            "host.example.com".into(),
562            2222,
563            "admin".into(),
564            SecretString::from("pass".to_string()),
565            Some(60_000),
566            Some(50_000),
567            None,
568            None,
569        );
570        let cfg = construir_configuracao(&registro);
571        assert_eq!(cfg.host, "host.example.com");
572        assert_eq!(cfg.porta, 2222);
573        assert_eq!(cfg.usuario, "admin");
574        assert_eq!(cfg.timeout_ms, 60_000);
575    }
576
577    #[test]
578    fn arquivo_config_vazio_tem_schema_correto() {
579        let arq = ArquivoConfig {
580            schema_version: modelo::SCHEMA_VERSION_ATUAL,
581            hosts: BTreeMap::new(),
582        };
583        let toml_str = toml::to_string(&arq).unwrap();
584        assert!(toml_str.contains("schema_version"));
585        assert!(toml_str.contains("hosts"));
586    }
587
588    #[test]
589    fn arquivo_config_com_hosts_serializa_para_toml() {
590        let mut hosts = BTreeMap::new();
591        hosts.insert(
592            "teste".to_string(),
593            modelo::VpsRegistro::novo(
594                "teste".into(),
595                "1.2.3.4".into(),
596                22,
597                "root".into(),
598                SecretString::from("senha".to_string()),
599                None,
600                None,
601                None,
602                None,
603            ),
604        );
605        let arq = ArquivoConfig {
606            schema_version: modelo::SCHEMA_VERSION_ATUAL,
607            hosts,
608        };
609        let toml_str = toml::to_string(&arq).unwrap();
610        assert!(toml_str.contains("teste"));
611        assert!(toml_str.contains("1.2.3.4"));
612    }
613
614    #[test]
615    fn resolver_caminho_config_com_override_diretorio() {
616        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test-dir")));
617        assert!(resultado.is_ok());
618        assert_eq!(
619            resultado.unwrap(),
620            PathBuf::from("/tmp/test-dir/config.toml")
621        );
622    }
623
624    #[test]
625    fn resolver_caminho_config_com_override_arquivo_explicito() {
626        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test.toml")));
627        assert!(resultado.is_ok());
628        assert_eq!(resultado.unwrap(), PathBuf::from("/tmp/test.toml"));
629    }
630
631    #[test]
632    fn resolver_caminho_config_sem_extensao_trata_como_diretorio() {
633        let resultado = resolver_caminho_config(Some(PathBuf::from("/tmp/test")));
634        assert!(resultado.is_ok());
635        assert_eq!(resultado.unwrap(), PathBuf::from("/tmp/test/config.toml"));
636    }
637
638    #[test]
639    fn carregar_retorna_config_vazio_quando_arquivo_nao_existe() {
640        let tmp = tempfile::TempDir::new().unwrap();
641        let caminho = tmp.path().join("nao-existe.toml");
642        let resultado = carregar(&caminho);
643        assert!(resultado.is_ok());
644        let arq = resultado.unwrap();
645        assert_eq!(arq.schema_version, modelo::SCHEMA_VERSION_ATUAL);
646        assert!(arq.hosts.is_empty());
647    }
648
649    #[test]
650    fn carregar_faz_parse_de_toml_existente() {
651        let tmp = tempfile::TempDir::new().unwrap();
652        let caminho = tmp.path().join("config.toml");
653        let conteudo = r#"
654schema_version = 1
655[hosts.minha-vps]
656nome = "minha-vps"
657host = "1.2.3.4"
658porta = 22
659usuario = "root"
660senha = "senhateste"
661timeout_ms = 30000
662max_chars = 100000
663schema_version = 1
664adicionado_em = "2024-01-01T00:00:00Z"
665"#;
666        std::fs::write(&caminho, conteudo).unwrap();
667        let resultado = carregar(&caminho);
668        assert!(resultado.is_ok());
669        let arq = resultado.unwrap();
670        assert!(arq.hosts.contains_key("minha-vps"));
671    }
672
673    #[test]
674    fn ler_vps_ativa_retorna_none_quando_arquivo_nao_existe() {
675        let tmp = tempfile::TempDir::new().unwrap();
676        let config_dir = tmp.path().join("ssh-cli");
677        std::fs::create_dir_all(&config_dir).unwrap();
678        let caminho_config = config_dir.join("config.toml");
679        std::fs::write(&caminho_config, "").unwrap();
680        let resultado = ler_vps_ativa(Some(config_dir.clone()));
681        assert!(resultado.is_ok());
682        assert!(resultado.unwrap().is_none());
683    }
684
685    #[test]
686    fn ler_vps_ativa_retorna_nome_quando_arquivo_existe() {
687        let tmp = tempfile::TempDir::new().unwrap();
688        let config_dir = tmp.path().join("ssh-cli");
689        std::fs::create_dir_all(&config_dir).unwrap();
690        let caminho_config = config_dir.join("config.toml");
691        let caminho_ativo = config_dir.join("active");
692        std::fs::write(&caminho_config, "").unwrap();
693        std::fs::write(&caminho_ativo, "minha-vps\n").unwrap();
694        let resultado = ler_vps_ativa(Some(config_dir));
695        assert!(resultado.is_ok());
696        assert_eq!(resultado.unwrap(), Some("minha-vps".to_string()));
697    }
698
699    #[test]
700    fn ler_vps_ativa_com_override_diretorio() {
701        let tmp = tempfile::TempDir::new().unwrap();
702        let config_dir = tmp.path().join("minha-config");
703        std::fs::create_dir_all(&config_dir).unwrap();
704        let caminho_config = config_dir.join("config.toml");
705        let caminho_ativo = config_dir.join("active");
706        std::fs::write(&caminho_config, "").unwrap();
707        std::fs::write(&caminho_ativo, "vps-teste\n").unwrap();
708        let resultado = ler_vps_ativa(Some(config_dir));
709        assert!(resultado.is_ok());
710        assert_eq!(resultado.unwrap(), Some("vps-teste".to_string()));
711    }
712
713    #[test]
714    fn buscar_por_nome_retorna_none_quando_nao_existe() {
715        let tmp = tempfile::TempDir::new().unwrap();
716        let caminho = tmp.path().join("config.toml");
717        std::fs::write(&caminho, "").unwrap();
718        let resultado = buscar_por_nome(Some(caminho.clone()), "inexistente");
719        assert!(resultado.is_ok());
720        assert!(resultado.unwrap().is_none());
721    }
722
723    #[test]
724    fn buscar_por_nome_retorna_registro_quando_existe() {
725        let tmp = tempfile::TempDir::new().unwrap();
726        let caminho = tmp.path().join("config.toml");
727        let conteudo = r#"
728schema_version = 1
729[hosts.minha-vps]
730nome = "minha-vps"
731host = "1.2.3.4"
732porta = 22
733usuario = "root"
734senha = "senhateste"
735timeout_ms = 30000
736max_chars = 100000
737schema_version = 1
738adicionado_em = "2024-01-01T00:00:00Z"
739"#;
740        std::fs::write(&caminho, conteudo).unwrap();
741        let resultado = buscar_por_nome(Some(caminho), "minha-vps");
742        assert!(resultado.is_ok());
743        let vps = resultado.unwrap();
744        assert!(vps.is_some());
745        assert_eq!(vps.unwrap().nome, "minha-vps");
746    }
747
748    #[cfg(unix)]
749    #[test]
750    fn salvar_aplica_permissoes_600_no_unix() {
751        use std::os::unix::fs::PermissionsExt;
752        let tmp = tempfile::TempDir::new().unwrap();
753        let caminho = tmp.path().join("config.toml");
754        let arquivo = ArquivoConfig {
755            schema_version: modelo::SCHEMA_VERSION_ATUAL,
756            hosts: BTreeMap::new(),
757        };
758        let resultado = salvar(&caminho, &arquivo);
759        assert!(resultado.is_ok());
760        let metadados = std::fs::metadata(&caminho).unwrap();
761        let permissoes = metadados.permissions();
762        assert_eq!(permissoes.mode() & 0o777, 0o600);
763    }
764
765    #[test]
766    fn salvar_cria_diretorio_pai_se_nao_existir() {
767        let tmp = tempfile::TempDir::new().unwrap();
768        let caminho = tmp
769            .path()
770            .join("subdir1")
771            .join("subdir2")
772            .join("config.toml");
773        let arquivo = ArquivoConfig {
774            schema_version: modelo::SCHEMA_VERSION_ATUAL,
775            hosts: BTreeMap::new(),
776        };
777        let resultado = salvar(&caminho, &arquivo);
778        assert!(resultado.is_ok());
779        assert!(caminho.exists());
780    }
781
782    #[test]
783    fn arquivo_config_parsing_com_campos_parciais() {
784        let tmp = tempfile::TempDir::new().unwrap();
785        let caminho = tmp.path().join("config.toml");
786        let conteudo = r#"
787schema_version = 1
788[hosts.vps-minima]
789nome = "vps-minima"
790host = "5.6.7.8"
791porta = 2222
792usuario = "admin"
793senha = "senha123"
794timeout_ms = 30000
795max_chars = 100000
796schema_version = 1
797adicionado_em = "2024-01-01T00:00:00Z"
798"#;
799        std::fs::write(&caminho, conteudo).unwrap();
800        let resultado = carregar(&caminho);
801        assert!(resultado.is_ok());
802        let arq = resultado.unwrap();
803        assert!(arq.hosts.contains_key("vps-minima"));
804        let vps = arq.hosts.get("vps-minima").unwrap();
805        assert_eq!(vps.host, "5.6.7.8");
806        assert_eq!(vps.porta, 2222);
807    }
808
809    #[tokio::test]
810    async fn executar_exec_with_client_retorna_ok_quando_mock_sucesso() {
811        use crate::ssh::cliente::mocks::MockClienteSsh;
812        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
813
814        let mut mock = MockClienteSsh::new();
815        mock.expect_executar_comando()
816            .returning(|_cmd, _max_chars| {
817                Ok(SaidaExecucao {
818                    stdout: "output test".to_string(),
819                    stderr: String::new(),
820                    exit_code: Some(0),
821                    truncado_stdout: false,
822                    truncado_stderr: false,
823                    duracao_ms: 100,
824                })
825            });
826        mock.expect_desconectar().returning(|| Ok(()));
827
828        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
829        let registro = modelo::VpsRegistro::novo(
830            "teste".into(),
831            "localhost".into(),
832            22,
833            "user".into(),
834            SecretString::from("pass".to_string()),
835            None,
836            None,
837            None,
838            None,
839        );
840
841        let resultado =
842            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
843                .await;
844        assert!(resultado.is_ok());
845    }
846
847    #[tokio::test]
848    async fn executar_sudo_exec_with_client_retorna_ok_quando_mock_sucesso() {
849        use crate::ssh::cliente::mocks::MockClienteSsh;
850        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
851
852        let mut mock = MockClienteSsh::new();
853        mock.expect_executar_comando()
854            .returning(|_cmd, _max_chars| {
855                Ok(SaidaExecucao {
856                    stdout: "sudo output".to_string(),
857                    stderr: String::new(),
858                    exit_code: Some(0),
859                    truncado_stdout: false,
860                    truncado_stderr: false,
861                    duracao_ms: 100,
862                })
863            });
864        mock.expect_desconectar().returning(|| Ok(()));
865
866        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
867        let mut registro = modelo::VpsRegistro::novo(
868            "teste".into(),
869            "localhost".into(),
870            22,
871            "user".into(),
872            SecretString::from("pass".to_string()),
873            None,
874            None,
875            None,
876            None,
877        );
878        registro.senha_sudo = Some(SecretString::from("sudo_pass".to_string()));
879
880        let resultado = executar_sudo_exec_with_client(
881            &registro,
882            "echo sudo",
883            cliente,
884            FormatoSaida::Text,
885            false,
886        )
887        .await;
888        assert!(resultado.is_ok());
889    }
890
891    #[tokio::test]
892    async fn executar_sudo_exec_with_client_retorna_ok_quando_sem_senha_sudo() {
893        use crate::ssh::cliente::mocks::MockClienteSsh;
894        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
895
896        let mut mock = MockClienteSsh::new();
897        mock.expect_executar_comando()
898            .returning(|_cmd, _max_chars| {
899                Ok(SaidaExecucao {
900                    stdout: "output".to_string(),
901                    stderr: String::new(),
902                    exit_code: Some(0),
903                    truncado_stdout: false,
904                    truncado_stderr: false,
905                    duracao_ms: 100,
906                })
907            });
908        mock.expect_desconectar().returning(|| Ok(()));
909
910        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
911        let registro = modelo::VpsRegistro::novo(
912            "teste".into(),
913            "localhost".into(),
914            22,
915            "user".into(),
916            SecretString::from("pass".to_string()),
917            None,
918            None,
919            None,
920            None,
921        );
922
923        let resultado = executar_sudo_exec_with_client(
924            &registro,
925            "echo test",
926            cliente,
927            FormatoSaida::Text,
928            false,
929        )
930        .await;
931        assert!(resultado.is_ok());
932    }
933
934    #[tokio::test]
935    async fn executar_sudo_exec_with_client_retorna_erro_quando_executar_comando_falha() {
936        use crate::ssh::cliente::mocks::MockClienteSsh;
937        use crate::ssh::cliente::ClienteSshTrait;
938
939        let mut mock = MockClienteSsh::new();
940        mock.expect_executar_comando()
941            .returning(|_cmd, _max_chars| {
942                Err(crate::erros::ErroSshCli::CanalFalhou(
943                    "mock error".to_string(),
944                ))
945            });
946
947        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
948        let registro = modelo::VpsRegistro::novo(
949            "teste".into(),
950            "localhost".into(),
951            22,
952            "user".into(),
953            SecretString::from("pass".to_string()),
954            None,
955            None,
956            None,
957            None,
958        );
959
960        let resultado =
961            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
962                .await;
963        assert!(resultado.is_err());
964    }
965
966    #[tokio::test]
967    async fn executar_scp_upload_with_client_retorna_ok_quando_mock_sucesso() {
968        use crate::ssh::cliente::mocks::MockClienteSsh;
969        use crate::ssh::cliente::{ClienteSshTrait, TransferenciaResultado};
970
971        let mut mock = MockClienteSsh::new();
972        mock.expect_upload().returning(|_local, _remote| {
973            Ok(TransferenciaResultado {
974                bytes_transferidos: 1024,
975                duracao_ms: 50,
976            })
977        });
978        mock.expect_desconectar().returning(|| Ok(()));
979
980        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
981        let registro = modelo::VpsRegistro::novo(
982            "teste".into(),
983            "localhost".into(),
984            22,
985            "user".into(),
986            SecretString::from("pass".to_string()),
987            None,
988            None,
989            None,
990            None,
991        );
992
993        let resultado = crate::scp::executar_scp_upload_with_client(
994            &registro,
995            std::path::Path::new("/local/file.txt"),
996            std::path::Path::new("/remote/file.txt"),
997            cliente,
998        )
999        .await;
1000        assert!(resultado.is_ok());
1001    }
1002
1003    #[tokio::test]
1004    async fn executar_scp_download_with_client_retorna_ok_quando_mock_sucesso() {
1005        use crate::ssh::cliente::mocks::MockClienteSsh;
1006        use crate::ssh::cliente::{ClienteSshTrait, TransferenciaResultado};
1007
1008        let mut mock = MockClienteSsh::new();
1009        mock.expect_download().returning(|_remote, _local| {
1010            Ok(TransferenciaResultado {
1011                bytes_transferidos: 2048,
1012                duracao_ms: 75,
1013            })
1014        });
1015        mock.expect_desconectar().returning(|| Ok(()));
1016
1017        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1018        let registro = modelo::VpsRegistro::novo(
1019            "teste".into(),
1020            "localhost".into(),
1021            22,
1022            "user".into(),
1023            SecretString::from("pass".to_string()),
1024            None,
1025            None,
1026            None,
1027            None,
1028        );
1029
1030        let resultado = crate::scp::executar_scp_download_with_client(
1031            &registro,
1032            std::path::Path::new("/remote/file.txt"),
1033            std::path::Path::new("/local/file.txt"),
1034            cliente,
1035        )
1036        .await;
1037        assert!(resultado.is_ok());
1038    }
1039
1040    #[tokio::test]
1041    async fn executar_scp_upload_with_client_retorna_erro_quando_upload_falha() {
1042        use crate::ssh::cliente::mocks::MockClienteSsh;
1043        use crate::ssh::cliente::ClienteSshTrait;
1044
1045        let mut mock = MockClienteSsh::new();
1046        mock.expect_upload().returning(|_local, _remote| {
1047            Err(crate::erros::ErroSshCli::Generico(
1048                "falha no upload".to_string(),
1049            ))
1050        });
1051
1052        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1053        let registro = modelo::VpsRegistro::novo(
1054            "teste".into(),
1055            "localhost".into(),
1056            22,
1057            "user".into(),
1058            SecretString::from("pass".to_string()),
1059            None,
1060            None,
1061            None,
1062            None,
1063        );
1064
1065        let resultado = crate::scp::executar_scp_upload_with_client(
1066            &registro,
1067            std::path::Path::new("/local/file.txt"),
1068            std::path::Path::new("/remote/file.txt"),
1069            cliente,
1070        )
1071        .await;
1072        assert!(resultado.is_err());
1073    }
1074
1075    #[tokio::test]
1076    async fn executar_scp_download_with_client_retorna_erro_quando_download_falha() {
1077        use crate::ssh::cliente::mocks::MockClienteSsh;
1078        use crate::ssh::cliente::ClienteSshTrait;
1079
1080        let mut mock = MockClienteSsh::new();
1081        mock.expect_download().returning(|_remote, _local| {
1082            Err(crate::erros::ErroSshCli::Generico(
1083                "falha no download".to_string(),
1084            ))
1085        });
1086
1087        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1088        let registro = modelo::VpsRegistro::novo(
1089            "teste".into(),
1090            "localhost".into(),
1091            22,
1092            "user".into(),
1093            SecretString::from("pass".to_string()),
1094            None,
1095            None,
1096            None,
1097            None,
1098        );
1099
1100        let resultado = crate::scp::executar_scp_download_with_client(
1101            &registro,
1102            std::path::Path::new("/remote/file.txt"),
1103            std::path::Path::new("/local/file.txt"),
1104            cliente,
1105        )
1106        .await;
1107        assert!(resultado.is_err());
1108    }
1109
1110    #[tokio::test]
1111    async fn executar_sudo_exec_with_client_retorna_erro_quando_desconectar_falha() {
1112        use crate::ssh::cliente::mocks::MockClienteSsh;
1113        use crate::ssh::cliente::{ClienteSshTrait, SaidaExecucao};
1114
1115        let mut mock = MockClienteSsh::new();
1116        mock.expect_executar_comando()
1117            .returning(|_cmd, _max_chars| {
1118                Ok(SaidaExecucao {
1119                    stdout: "output".to_string(),
1120                    stderr: String::new(),
1121                    exit_code: Some(0),
1122                    truncado_stdout: false,
1123                    truncado_stderr: false,
1124                    duracao_ms: 100,
1125                })
1126            });
1127        mock.expect_desconectar().returning(|| {
1128            Err(crate::erros::ErroSshCli::CanalFalhou(
1129                "erro desconexão".to_string(),
1130            ))
1131        });
1132
1133        let cliente = Box::new(mock) as Box<dyn ClienteSshTrait>;
1134        let registro = modelo::VpsRegistro::novo(
1135            "teste".into(),
1136            "localhost".into(),
1137            22,
1138            "user".into(),
1139            SecretString::from("pass".to_string()),
1140            None,
1141            None,
1142            None,
1143            None,
1144        );
1145
1146        let resultado =
1147            executar_exec_with_client(&registro, "echo test", cliente, FormatoSaida::Text, false)
1148                .await;
1149        assert!(resultado.is_err());
1150    }
1151
1152    #[test]
1153    fn caminho_config_padrao_com_ssh_cli_home_retorna_path() {
1154        let tmp = tempfile::TempDir::new().unwrap();
1155        let home_dir = tmp.path().join("ssh-cli-home");
1156        std::fs::create_dir_all(&home_dir).unwrap();
1157        std::env::set_var("SSH_CLI_HOME", home_dir.to_str().unwrap());
1158        let resultado = caminho_config_padrao();
1159        std::env::remove_var("SSH_CLI_HOME");
1160        assert!(resultado.is_ok());
1161        assert!(resultado
1162            .unwrap()
1163            .to_str()
1164            .unwrap()
1165            .contains("ssh-cli-home"));
1166    }
1167
1168    #[test]
1169    fn caminho_config_padrao_com_path_traversal_retorna_erro() {
1170        std::env::set_var("SSH_CLI_HOME", "/tmp/../etc/config");
1171        let resultado = caminho_config_padrao();
1172        std::env::remove_var("SSH_CLI_HOME");
1173        assert!(resultado.is_err());
1174    }
1175
1176    #[test]
1177    fn caminho_config_padrao_sem_env_retorna_path_valido() {
1178        std::env::remove_var("SSH_CLI_HOME");
1179        let resultado = caminho_config_padrao();
1180        if let Ok(path) = resultado {
1181            assert!(path.to_str().unwrap().contains("ssh-cli"));
1182        }
1183    }
1184
1185    #[test]
1186    fn escapar_senha_shell_simples() {
1187        assert_eq!(escapar_senha_shell("abc123"), "'abc123'");
1188    }
1189
1190    #[test]
1191    fn escapar_senha_shell_com_single_quote() {
1192        assert_eq!(escapar_senha_shell("ab'cd"), "'ab'\\''cd'");
1193    }
1194
1195    #[test]
1196    fn escapar_senha_shell_com_especiais() {
1197        // $, @, ~, !, ` são seguros dentro de single quotes
1198        assert_eq!(escapar_senha_shell("p@ss$w0rd!"), "'p@ss$w0rd!'");
1199    }
1200
1201    #[test]
1202    fn escapar_senha_shell_vazia() {
1203        assert_eq!(escapar_senha_shell(""), "''");
1204    }
1205
1206    #[test]
1207    fn escapar_senha_shell_unicode() {
1208        assert_eq!(escapar_senha_shell("café☕"), "'café☕'");
1209    }
1210
1211    #[test]
1212    fn escapar_senha_shell_senha_usuario() {
1213        // Senha real do caso de uso
1214        assert_eq!(
1215            escapar_senha_shell("Ih8Tml@Ymnwku1:G@W~2"),
1216            "'Ih8Tml@Ymnwku1:G@W~2'"
1217        );
1218    }
1219
1220    #[test]
1221    fn sudo_cmd_com_senha_formato_correto() {
1222        let senha = "test123";
1223        let comando = "apt update";
1224        let escaped = escapar_senha_shell(senha);
1225        let sudo_cmd = format!("printf '%s\\n' {} | sudo -S -p '' {}", escaped, comando);
1226        assert_eq!(
1227            sudo_cmd,
1228            "printf '%s\\n' 'test123' | sudo -S -p '' apt update"
1229        );
1230    }
1231
1232    #[test]
1233    fn sudo_cmd_sem_senha_formato_correto() {
1234        let comando = "apt update";
1235        let sudo_cmd = format!("sudo {}", comando);
1236        assert_eq!(sudo_cmd, "sudo apt update");
1237    }
1238
1239    #[test]
1240    fn aplicar_overrides_com_todos_os_campos() {
1241        use secrecy::ExposeSecret;
1242        let mut vps = modelo::VpsRegistro::novo(
1243            "srv".into(),
1244            "1.2.3.4".into(),
1245            22,
1246            "root".into(),
1247            SecretString::from("senha_original".to_string()),
1248            Some(30_000),
1249            Some(50_000),
1250            None,
1251            None,
1252        );
1253        aplicar_overrides(
1254            &mut vps,
1255            Some("nova_senha".to_string()),
1256            Some("nova_sudo".to_string()),
1257            Some(60_000),
1258        );
1259        assert_eq!(vps.senha.expose_secret(), "nova_senha");
1260        assert_eq!(
1261            vps.senha_sudo.as_ref().unwrap().expose_secret(),
1262            "nova_sudo"
1263        );
1264        assert_eq!(vps.timeout_ms, 60_000);
1265    }
1266
1267    #[test]
1268    fn aplicar_overrides_preserva_campos_quando_none() {
1269        use secrecy::ExposeSecret;
1270        let mut vps = modelo::VpsRegistro::novo(
1271            "srv".into(),
1272            "1.2.3.4".into(),
1273            22,
1274            "root".into(),
1275            SecretString::from("senha_original".to_string()),
1276            Some(30_000),
1277            Some(50_000),
1278            Some(SecretString::from("sudo_original".to_string())),
1279            None,
1280        );
1281        aplicar_overrides(&mut vps, None, None, None);
1282        assert_eq!(vps.senha.expose_secret(), "senha_original");
1283        assert_eq!(
1284            vps.senha_sudo.as_ref().unwrap().expose_secret(),
1285            "sudo_original"
1286        );
1287        assert_eq!(vps.timeout_ms, 30_000);
1288    }
1289
1290    #[test]
1291    fn construir_configuracao_com_timeout_diferente() {
1292        let registro = modelo::VpsRegistro::novo(
1293            "srv".into(),
1294            "host.example.com".into(),
1295            2222,
1296            "admin".into(),
1297            SecretString::from("pass".to_string()),
1298            Some(120_000),
1299            Some(50_000),
1300            None,
1301            None,
1302        );
1303        let cfg = construir_configuracao(&registro);
1304        assert_eq!(cfg.timeout_ms, 120_000);
1305    }
1306}