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