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