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