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