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