1use crate::erros::{ErroSshCli, ResultadoSshCli};
17use secrecy::SecretString;
18use tokio::io::{AsyncRead, AsyncWrite};
19
20#[derive(Clone)]
26pub struct ConfiguracaoConexao {
27 pub host: String,
29 pub porta: u16,
31 pub usuario: String,
33 pub senha: SecretString,
35 pub timeout_ms: u64,
37}
38
39impl std::fmt::Debug for ConfiguracaoConexao {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("ConfiguracaoConexao")
42 .field("host", &self.host)
43 .field("porta", &self.porta)
44 .field("usuario", &self.usuario)
45 .field("senha", &"<redacted>")
46 .field("timeout_ms", &self.timeout_ms)
47 .finish()
48 }
49}
50
51impl ConfiguracaoConexao {
52 pub fn validar(&self) -> ResultadoSshCli<()> {
56 if self.host.trim().is_empty() {
57 return Err(ErroSshCli::ArgumentoInvalido(
58 "host vazio em ConfiguracaoConexao".to_string(),
59 ));
60 }
61 if self.porta == 0 {
62 return Err(ErroSshCli::ArgumentoInvalido(
63 "porta 0 inválida em ConfiguracaoConexao".to_string(),
64 ));
65 }
66 if self.usuario.trim().is_empty() {
67 return Err(ErroSshCli::ArgumentoInvalido(
68 "usuário vazio em ConfiguracaoConexao".to_string(),
69 ));
70 }
71 Ok(())
72 }
73}
74
75#[derive(Debug, Clone)]
77pub struct SaidaExecucao {
78 pub stdout: String,
80 pub stderr: String,
82 pub exit_code: Option<i32>,
84 pub truncado_stdout: bool,
86 pub truncado_stderr: bool,
88 pub duracao_ms: u64,
90}
91
92#[derive(Debug, Clone)]
94pub struct TransferenciaResultado {
95 pub bytes_transferidos: u64,
97 pub duracao_ms: u64,
99}
100
101#[must_use]
106pub fn truncar_utf8(conteudo: &str, max_chars: usize) -> (String, bool) {
107 let total = conteudo.chars().count();
108 if total <= max_chars {
109 return (conteudo.to_string(), false);
110 }
111 let truncado: String = conteudo.chars().take(max_chars).collect();
112 (truncado, true)
113}
114
115use async_trait::async_trait;
120use std::path::Path;
121
122pub trait CanalTunel: AsyncRead + AsyncWrite + Unpin + Send {}
124
125impl<T> CanalTunel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
126
127#[async_trait]
132pub trait ClienteSshTrait: Send + Sync + 'static {
133 async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>
135 where
136 Self: Sized;
137
138 async fn executar_comando(
140 &mut self,
141 cmd: &str,
142 max_chars: usize,
143 ) -> Result<SaidaExecucao, ErroSshCli>;
144
145 async fn upload(
147 &mut self,
148 local: &Path,
149 remote: &Path,
150 ) -> Result<TransferenciaResultado, ErroSshCli>;
151
152 async fn download(
154 &mut self,
155 remote: &Path,
156 local: &Path,
157 ) -> Result<TransferenciaResultado, ErroSshCli>;
158
159 async fn abrir_canal_tunel(
161 &self,
162 host_remoto: &str,
163 porta_remota: u16,
164 endereco_origem: &str,
165 porta_origem: u16,
166 ) -> Result<Box<dyn CanalTunel>, ErroSshCli>;
167
168 async fn desconectar(&self) -> Result<(), ErroSshCli>;
170}
171
172#[cfg(test)]
173pub mod mocks {
175 use super::*;
176 use mockall::mock;
177
178 mock! {
179 pub ClienteSsh {}
180
181 #[async_trait]
182 impl crate::ssh::cliente::ClienteSshTrait for ClienteSsh {
183 async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>;
184 async fn executar_comando(&mut self, cmd: &str, max_chars: usize) -> Result<SaidaExecucao, ErroSshCli>;
185 async fn upload(&mut self, local: &Path, remote: &Path) -> Result<TransferenciaResultado, ErroSshCli>;
186 async fn download(&mut self, remote: &Path, local: &Path) -> Result<TransferenciaResultado, ErroSshCli>;
187 async fn abrir_canal_tunel(
188 &self,
189 host_remoto: &str,
190 porta_remota: u16,
191 endereco_origem: &str,
192 porta_origem: u16,
193 ) -> Result<Box<dyn CanalTunel>, ErroSshCli>;
194 async fn desconectar(&self) -> Result<(), ErroSshCli>;
195 }
196 }
197}
198
199#[cfg(feature = "ssh-real")]
204mod real {
205 use super::{
206 CanalTunel, ClienteSshTrait, ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado,
207 };
208 use crate::erros::{ErroSshCli, ResultadoSshCli};
209 use async_trait::async_trait;
210 use secrecy::ExposeSecret;
211 use std::path::Path;
212 use std::sync::Arc;
213 use std::time::{Duration, Instant};
214
215 pub struct ManipuladorCliente;
220
221 impl russh::client::Handler for ManipuladorCliente {
222 type Error = russh::Error;
223
224 async fn check_server_key(
225 &mut self,
226 _chave_servidor: &russh::keys::ssh_key::PublicKey,
227 ) -> Result<bool, Self::Error> {
228 tracing::warn!("check_server_key aceita TODA chave (iteração 2: sem known_hosts)");
229 Ok(true)
230 }
231 }
232
233 pub struct ClienteSsh {
235 pub sessao: russh::client::Handle<ManipuladorCliente>,
237 cfg: ConfiguracaoConexao,
238 }
239
240 impl std::fmt::Debug for ClienteSsh {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 f.debug_struct("ClienteSsh")
243 .field("host", &self.cfg.host)
244 .field("porta", &self.cfg.porta)
245 .field("usuario", &self.cfg.usuario)
246 .field("timeout_ms", &self.cfg.timeout_ms)
247 .finish()
248 }
249 }
250
251 fn mapear_exit_status(exit_status: u32) -> i32 {
252 i32::try_from(exit_status).unwrap_or(-1)
253 }
254
255 fn processar_mensagem_exec(
256 msg: russh::ChannelMsg,
257 stdout_bytes: &mut Vec<u8>,
258 stderr_bytes: &mut Vec<u8>,
259 exit_code: &mut Option<i32>,
260 ) -> bool {
261 use russh::ChannelMsg;
262
263 match msg {
264 ChannelMsg::Data { data } => {
265 stdout_bytes.extend_from_slice(&data);
266 }
267 ChannelMsg::ExtendedData { data, ext } => {
268 if ext == 1 {
270 stderr_bytes.extend_from_slice(&data);
271 } else {
272 tracing::debug!(ext, "dados estendidos ignorados");
273 }
274 }
275 ChannelMsg::ExitStatus { exit_status } => {
276 *exit_code = Some(mapear_exit_status(exit_status));
280 }
282 ChannelMsg::ExitSignal {
283 signal_name,
284 core_dumped,
285 error_message,
286 ..
287 } => {
288 tracing::warn!(
289 ?signal_name,
290 core_dumped,
291 %error_message,
292 "processo remoto terminou por sinal"
293 );
294 }
296 ChannelMsg::Eof => {
297 tracing::debug!("EOF no canal SSH");
298 }
299 ChannelMsg::Close => {
300 tracing::debug!("canal SSH fechado pelo servidor");
301 return true;
302 }
303 _ => {}
304 }
305
306 false
307 }
308
309 fn formatar_header_upload_scp(tamanho: u64, nome_arquivo: &str) -> String {
310 format!("C0644 {} {}\\n", tamanho, nome_arquivo)
311 }
312
313 fn parse_header_scp(header: &str) -> ResultadoSshCli<u64> {
314 let header = header.trim();
315
316 if !header.starts_with('C') {
317 return Err(ErroSshCli::CanalFalhou(format!(
318 "header SCP inesperado: {}",
319 header
320 )));
321 }
322
323 let partes: Vec<&str> = header.split_whitespace().collect();
324 if partes.len() < 3 {
325 return Err(ErroSshCli::CanalFalhou(format!(
326 "header SCP mal formatado: {}",
327 header
328 )));
329 }
330
331 partes[1].parse().map_err(|_| {
332 ErroSshCli::CanalFalhou(format!("tamanho inválido no header: {}", partes[1]))
333 })
334 }
335
336 impl ClienteSsh {
337 pub async fn conectar(cfg: ConfiguracaoConexao) -> ResultadoSshCli<Self> {
347 cfg.validar()?;
348
349 let timeout = Duration::from_millis(cfg.timeout_ms);
350 let host = cfg.host.clone();
351 let porta = cfg.porta;
352 let usuario = cfg.usuario.clone();
353 let senha_segura = cfg.senha.clone();
354
355 let config_cliente = Arc::new(russh::client::Config {
356 inactivity_timeout: Some(timeout),
357 ..Default::default()
358 });
359
360 tracing::info!(
361 host = %host,
362 porta,
363 usuario = %usuario,
364 timeout_ms = cfg.timeout_ms,
365 "iniciando conexão SSH"
366 );
367
368 let resultado_conexao = tokio::time::timeout(timeout, async move {
370 let mut sessao = russh::client::connect(
371 config_cliente,
372 (host.as_str(), porta),
373 ManipuladorCliente,
374 )
375 .await
376 .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha TCP/handshake: {e}")))?;
377
378 let auth = sessao
379 .authenticate_password(usuario.clone(), senha_segura.expose_secret())
380 .await
381 .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha auth transport: {e}")))?;
382
383 if !auth.success() {
384 tracing::warn!(
385 host = %host,
386 usuario = %usuario,
387 "autenticação SSH rejeitada"
388 );
389 return Err(ErroSshCli::AutenticacaoFalhou);
390 }
391
392 Ok::<_, ErroSshCli>(sessao)
393 })
394 .await;
395
396 let sessao = match resultado_conexao {
397 Ok(Ok(s)) => s,
398 Ok(Err(erro)) => return Err(erro),
399 Err(_) => return Err(ErroSshCli::TimeoutSsh(cfg.timeout_ms)),
400 };
401
402 tracing::info!("conexão SSH autenticada com sucesso");
403
404 Ok(Self { sessao, cfg })
405 }
406
407 pub async fn executar_comando(
417 &mut self,
418 comando: &str,
419 max_chars: usize,
420 ) -> ResultadoSshCli<SaidaExecucao> {
421 let inicio = Instant::now();
422 let timeout = Duration::from_millis(self.cfg.timeout_ms);
423
424 let resultado = tokio::time::timeout(timeout, async {
425 let mut canal = self
426 .sessao
427 .channel_open_session()
428 .await
429 .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão: {e}")))?;
430
431 canal
432 .exec(true, comando)
433 .await
434 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec: {e}")))?;
435
436 let mut stdout_bytes: Vec<u8> = Vec::new();
437 let mut stderr_bytes: Vec<u8> = Vec::new();
438 let mut exit_code: Option<i32> = None;
439
440 while let Some(msg) = canal.wait().await {
441 if processar_mensagem_exec(
442 msg,
443 &mut stdout_bytes,
444 &mut stderr_bytes,
445 &mut exit_code,
446 ) {
447 break;
448 }
449 }
450
451 Ok::<_, ErroSshCli>((stdout_bytes, stderr_bytes, exit_code))
452 })
453 .await;
454
455 let (stdout_bytes, stderr_bytes, exit_code) = match resultado {
456 Ok(Ok(t)) => t,
457 Ok(Err(erro)) => return Err(erro),
458 Err(_) => return Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms)),
459 };
460
461 let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
463 let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
464
465 let (stdout_truncado, truncado_stdout) = super::truncar_utf8(&stdout_str, max_chars);
466 let (stderr_truncado, truncado_stderr) = super::truncar_utf8(&stderr_str, max_chars);
467
468 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
469
470 Ok(SaidaExecucao {
471 stdout: stdout_truncado,
472 stderr: stderr_truncado,
473 exit_code,
474 truncado_stdout,
475 truncado_stderr,
476 duracao_ms,
477 })
478 }
479
480 pub async fn upload(
488 &mut self,
489 local: &std::path::Path,
490 remote: &std::path::Path,
491 ) -> ResultadoSshCli<TransferenciaResultado> {
492 use russh::ChannelMsg;
493 use std::time::Instant;
494
495 let local_str = local.display().to_string();
496 let remote_str = remote.display().to_string();
497
498 let metadados = std::fs::metadata(local).map_err(|e| {
499 if e.kind() == std::io::ErrorKind::NotFound {
500 ErroSshCli::ArquivoNaoEncontrado(local_str.clone())
501 } else {
502 ErroSshCli::Io(e)
503 }
504 })?;
505
506 if !metadados.is_file() {
507 return Err(ErroSshCli::ArgumentoInvalido(
508 "upload só suporta arquivos regulares".to_string(),
509 ));
510 }
511
512 let tamanho = metadados.len();
513 let nome_arquivo = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
514
515 let inicio = Instant::now();
516 let timeout = Duration::from_millis(self.cfg.timeout_ms);
517
518 let resultado =
519 tokio::time::timeout(timeout, async {
520 let mut canal =
521 self.sessao.channel_open_session().await.map_err(|e| {
522 ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}"))
523 })?;
524
525 let comando = format!("scp -t -p {}", remote_str);
526 canal
527 .exec(true, comando.as_str())
528 .await
529 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
530
531 canal.wait().await.ok_or_else(|| {
532 ErroSshCli::CanalFalhou("canal fechou prematuramente".to_string())
533 })?;
534
535 let resposta = formatar_header_upload_scp(tamanho, nome_arquivo);
536 canal
537 .data(resposta.as_bytes())
538 .await
539 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar header SCP: {e}")))?;
540
541 canal.wait().await.ok_or_else(|| {
542 ErroSshCli::CanalFalhou("canal fechou durante header".to_string())
543 })?;
544
545 let conteudo = std::fs::read(local).map_err(ErroSshCli::Io)?;
546 let mut offset = 0;
547 let tamanho_bloco = 32768;
548
549 while offset < conteudo.len() {
550 let fim = std::cmp::min(offset + tamanho_bloco, conteudo.len());
551 let bloco = &conteudo[offset..fim];
552 canal.data(bloco).await.map_err(|e| {
553 ErroSshCli::CanalFalhou(format!("enviar bloco SCP: {e}"))
554 })?;
555 offset = fim;
556 }
557
558 canal
559 .data(&[] as &[u8])
560 .await
561 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar EOF SCP: {e}")))?;
562
563 canal.wait().await.ok_or_else(|| {
564 ErroSshCli::CanalFalhou("canal fechou durante transferência".to_string())
565 })?;
566
567 while let Some(msg) = canal.wait().await {
568 if let ChannelMsg::Close = msg {
569 break;
570 }
571 }
572
573 Ok::<_, ErroSshCli>(())
574 })
575 .await;
576
577 resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
578
579 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
580
581 Ok(TransferenciaResultado {
582 bytes_transferidos: tamanho,
583 duracao_ms,
584 })
585 }
586
587 pub async fn download(
595 &mut self,
596 remote: &std::path::Path,
597 local: &std::path::Path,
598 ) -> ResultadoSshCli<TransferenciaResultado> {
599 use russh::ChannelMsg;
600 use std::io::Write;
601 use std::time::Instant;
602
603 let remote_str = remote.display().to_string();
604
605 let inicio = Instant::now();
606 let timeout = Duration::from_millis(self.cfg.timeout_ms);
607
608 let resultado = tokio::time::timeout(timeout, async {
609 let mut canal = self
610 .sessao
611 .channel_open_session()
612 .await
613 .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}")))?;
614
615 let comando = format!("scp -f -p {}", remote_str);
616 canal
617 .exec(true, comando.as_str())
618 .await
619 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
620
621 canal
622 .data(&[] as &[u8])
623 .await
624 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack inicial: {e}")))?;
625
626 let mut msg = canal.wait().await.ok_or_else(|| {
627 ErroSshCli::CanalFalhou("canal fechou esperando header".to_string())
628 })?;
629
630 let ChannelMsg::Data { data } = msg else {
631 return Err(ErroSshCli::CanalFalhou(
632 "esperava dados do servidor".to_string(),
633 ));
634 };
635
636 let header = String::from_utf8_lossy(&data);
637 let tamanho = parse_header_scp(&header)?;
638
639 canal
640 .data(&[] as &[u8])
641 .await
642 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack: {e}")))?;
643
644 if let Some(pai) = local.parent() {
645 std::fs::create_dir_all(pai)?;
646 }
647
648 let mut arquivo = std::fs::File::create(local).map_err(ErroSshCli::Io)?;
649 let mut recebidos: u64 = 0;
650
651 while recebidos < tamanho {
652 msg = canal.wait().await.ok_or_else(|| {
653 ErroSshCli::CanalFalhou("canal fechou durante download".to_string())
654 })?;
655
656 let ChannelMsg::Data { data } = msg else {
657 continue;
658 };
659
660 let bytes = data.as_ref();
661 if bytes.is_empty() {
662 continue;
663 }
664
665 arquivo.write_all(bytes).map_err(ErroSshCli::Io)?;
666 recebidos += bytes.len() as u64;
667
668 canal.data(&[] as &[u8]).await.map_err(|e| {
669 ErroSshCli::CanalFalhou(format!("enviar ack durante download: {e}"))
670 })?;
671 }
672
673 while let Some(msg) = canal.wait().await {
674 if let ChannelMsg::Close = msg {
675 break;
676 }
677 }
678
679 Ok::<_, ErroSshCli>(recebidos)
680 })
681 .await;
682
683 let recebidos =
684 resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
685
686 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
687
688 Ok(TransferenciaResultado {
689 bytes_transferidos: recebidos,
690 duracao_ms,
691 })
692 }
693
694 pub async fn desconectar(&self) -> ResultadoSshCli<()> {
700 let resultado = self
701 .sessao
702 .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
703 .await;
704 match resultado {
705 Ok(()) => {
706 tracing::info!("sessão SSH encerrada");
707 Ok(())
708 }
709 Err(e) => {
710 tracing::warn!(erro = %e, "falha ao encerrar sessão SSH");
711 Err(ErroSshCli::ConexaoFalhou(format!(
712 "falha ao desconectar: {e}"
713 )))
714 }
715 }
716 }
717
718 pub async fn abrir_canal_tunel(
721 &self,
722 host_remoto: &str,
723 porta_remota: u16,
724 endereco_origem: &str,
725 porta_origem: u16,
726 ) -> ResultadoSshCli<Box<dyn CanalTunel>> {
727 let canal = self
728 .sessao
729 .channel_open_direct_tcpip(
730 host_remoto.to_string(),
731 u32::from(porta_remota),
732 endereco_origem.to_string(),
733 u32::from(porta_origem),
734 )
735 .await
736 .map_err(|e| {
737 ErroSshCli::CanalFalhou(format!(
738 "falha ao abrir canal direct-tcpip para {}:{}: {}",
739 host_remoto, porta_remota, e
740 ))
741 })?;
742
743 Ok(Box::new(canal.into_stream()))
744 }
745 }
746
747 #[async_trait]
748 impl ClienteSshTrait for ClienteSsh {
749 async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
751 Self::conectar(cfg).await.map(Box::new)
752 }
753
754 async fn executar_comando(
756 &mut self,
757 cmd: &str,
758 max_chars: usize,
759 ) -> Result<SaidaExecucao, ErroSshCli> {
760 Self::executar_comando(self, cmd, max_chars).await
761 }
762
763 async fn upload(
765 &mut self,
766 local: &Path,
767 remote: &Path,
768 ) -> Result<TransferenciaResultado, ErroSshCli> {
769 Self::upload(self, local, remote).await
770 }
771
772 async fn download(
774 &mut self,
775 remote: &Path,
776 local: &Path,
777 ) -> Result<TransferenciaResultado, ErroSshCli> {
778 Self::download(self, remote, local).await
779 }
780
781 async fn abrir_canal_tunel(
783 &self,
784 host_remoto: &str,
785 porta_remota: u16,
786 endereco_origem: &str,
787 porta_origem: u16,
788 ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
789 Self::abrir_canal_tunel(
790 self,
791 host_remoto,
792 porta_remota,
793 endereco_origem,
794 porta_origem,
795 )
796 .await
797 }
798
799 async fn desconectar(&self) -> Result<(), ErroSshCli> {
801 Self::desconectar(self).await
802 }
803 }
804
805 #[cfg(test)]
819 mod testes_real {
820 use super::{
821 formatar_header_upload_scp, mapear_exit_status, parse_header_scp,
822 processar_mensagem_exec,
823 };
824
825 #[test]
826 fn mapear_exit_status_normal() {
827 assert_eq!(mapear_exit_status(0), 0);
828 assert_eq!(mapear_exit_status(255), 255);
829 }
830
831 #[test]
832 fn mapear_exit_status_overflow_retorna_menos_um() {
833 assert_eq!(mapear_exit_status(u32::MAX), -1);
834 }
835
836 #[test]
837 fn parse_header_scp_valido_retorna_tamanho() {
838 let tamanho = parse_header_scp("C0644 42 arquivo.txt\n").expect("header válido");
839 assert_eq!(tamanho, 42);
840 }
841
842 #[test]
843 fn parse_header_scp_invalido_retorna_erro() {
844 assert!(parse_header_scp("ERRO").is_err());
845 assert!(parse_header_scp("C0644 sem_tamanho").is_err());
846 assert!(parse_header_scp("C0644 abc arquivo").is_err());
847 }
848
849 #[test]
850 fn processar_mensagem_exec_trata_stdout_stderr_e_close() {
851 let mut stdout = Vec::new();
852 let mut stderr = Vec::new();
853 let mut exit_code = None;
854
855 let deve_parar = processar_mensagem_exec(
856 russh::ChannelMsg::Data {
857 data: b"stdout".to_vec().into(),
858 },
859 &mut stdout,
860 &mut stderr,
861 &mut exit_code,
862 );
863 assert!(!deve_parar);
864 assert_eq!(stdout, b"stdout");
865
866 let deve_parar = processar_mensagem_exec(
867 russh::ChannelMsg::ExtendedData {
868 data: b"stderr".to_vec().into(),
869 ext: 1,
870 },
871 &mut stdout,
872 &mut stderr,
873 &mut exit_code,
874 );
875 assert!(!deve_parar);
876 assert_eq!(stderr, b"stderr");
877
878 let _ = processar_mensagem_exec(
879 russh::ChannelMsg::ExitStatus { exit_status: 17 },
880 &mut stdout,
881 &mut stderr,
882 &mut exit_code,
883 );
884 assert_eq!(exit_code, Some(17));
885
886 let deve_parar = processar_mensagem_exec(
887 russh::ChannelMsg::Close,
888 &mut stdout,
889 &mut stderr,
890 &mut exit_code,
891 );
892 assert!(deve_parar);
893 }
894
895 #[test]
896 fn formatar_header_upload_scp_gera_formato_esperado() {
897 let header = formatar_header_upload_scp(123, "arquivo.txt");
898 assert_eq!(header, "C0644 123 arquivo.txt\\n");
899 }
900
901 #[test]
902 fn processar_mensagem_exec_ignora_extendido_com_codigo_diferente_de_stderr() {
903 let mut stdout = Vec::new();
904 let mut stderr = Vec::new();
905 let mut exit_code = None;
906
907 let deve_parar = processar_mensagem_exec(
908 russh::ChannelMsg::ExtendedData {
909 data: b"nao-e-stderr".to_vec().into(),
910 ext: 2,
911 },
912 &mut stdout,
913 &mut stderr,
914 &mut exit_code,
915 );
916
917 assert!(!deve_parar);
918 assert!(stdout.is_empty());
919 assert!(stderr.is_empty());
920 assert!(exit_code.is_none());
921 }
922
923 #[test]
924 fn processar_mensagem_exec_trata_exit_signal_e_eof_sem_encerrar_loop() {
925 let mut stdout = Vec::new();
926 let mut stderr = Vec::new();
927 let mut exit_code = Some(7);
928
929 let deve_parar_signal = processar_mensagem_exec(
930 russh::ChannelMsg::ExitSignal {
931 signal_name: russh::Sig::TERM,
932 core_dumped: false,
933 error_message: "encerrado".to_string(),
934 lang_tag: "pt-BR".to_string(),
935 },
936 &mut stdout,
937 &mut stderr,
938 &mut exit_code,
939 );
940
941 let deve_parar_eof = processar_mensagem_exec(
942 russh::ChannelMsg::Eof,
943 &mut stdout,
944 &mut stderr,
945 &mut exit_code,
946 );
947
948 assert!(!deve_parar_signal);
949 assert!(!deve_parar_eof);
950 assert_eq!(exit_code, Some(7));
951 }
952
953 #[test]
954 fn processar_mensagem_exec_ignora_variantes_sem_tratamento_especifico() {
955 let mut stdout = Vec::new();
956 let mut stderr = Vec::new();
957 let mut exit_code = None;
958
959 let deve_parar = processar_mensagem_exec(
960 russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
961 &mut stdout,
962 &mut stderr,
963 &mut exit_code,
964 );
965
966 assert!(!deve_parar);
967 assert!(stdout.is_empty());
968 assert!(stderr.is_empty());
969 assert!(exit_code.is_none());
970 }
971
972 #[test]
973 fn parse_header_scp_aceita_header_com_whitespace_extra() {
974 let tamanho = parse_header_scp(" C0644 128 arquivo.bin \r\n")
976 .expect("header com espaços extras é válido");
977 assert_eq!(tamanho, 128);
978 }
979
980 #[test]
981 fn parse_header_scp_numero_muito_grande_retorna_u64_ok() {
982 let tamanho = parse_header_scp("C0644 10737418240 grande.iso\n").expect("u64 válido");
984 assert_eq!(tamanho, 10_737_418_240);
985 }
986
987 #[test]
988 fn parse_header_scp_string_vazia_retorna_erro() {
989 let resultado = parse_header_scp("");
991 assert!(resultado.is_err());
992 }
993
994 #[test]
995 fn parse_header_scp_header_apenas_c_retorna_erro() {
996 let resultado = parse_header_scp("C");
998 assert!(resultado.is_err());
999 }
1000
1001 #[test]
1002 fn formatar_header_upload_scp_com_nome_contendo_espaco() {
1003 let header = formatar_header_upload_scp(64, "meu arquivo.txt");
1004 assert!(header.starts_with("C0644 64 "));
1005 assert!(header.contains("meu arquivo.txt"));
1006 assert!(header.ends_with("\\n"));
1007 }
1008
1009 #[test]
1010 fn formatar_header_upload_scp_tamanho_zero_e_valido() {
1011 let header = formatar_header_upload_scp(0, "vazio.txt");
1012 assert_eq!(header, "C0644 0 vazio.txt\\n");
1013 }
1014
1015 #[test]
1016 fn mapear_exit_status_cobre_valores_intermediarios() {
1017 assert_eq!(mapear_exit_status(1), 1);
1018 assert_eq!(mapear_exit_status(42), 42);
1019 assert_eq!(mapear_exit_status(127), 127);
1020 assert_eq!(mapear_exit_status(128), 128);
1021 assert_eq!(mapear_exit_status(i32::MAX as u32), i32::MAX);
1022 }
1023
1024 #[test]
1025 fn processar_mensagem_exec_acumula_stdout_em_multiplas_chamadas() {
1026 let mut stdout = Vec::new();
1027 let mut stderr = Vec::new();
1028 let mut exit_code = None;
1029
1030 for parte in [b"parte1".to_vec(), b"-".to_vec(), b"parte2".to_vec()] {
1031 processar_mensagem_exec(
1032 russh::ChannelMsg::Data { data: parte.into() },
1033 &mut stdout,
1034 &mut stderr,
1035 &mut exit_code,
1036 );
1037 }
1038 assert_eq!(stdout, b"parte1-parte2");
1039 assert!(stderr.is_empty());
1040 }
1041
1042 #[tokio::test]
1043 async fn conectar_com_config_invalida_host_vazio_retorna_argumento_invalido() {
1044 use super::super::ConfiguracaoConexao;
1045 use super::ClienteSsh;
1046 use crate::erros::ErroSshCli;
1047 use secrecy::SecretString;
1048
1049 let cfg = ConfiguracaoConexao {
1050 host: String::new(),
1051 porta: 22,
1052 usuario: "root".to_string(),
1053 senha: SecretString::from("x".to_string()),
1054 timeout_ms: 500,
1055 };
1056
1057 match ClienteSsh::conectar(cfg).await {
1058 Err(ErroSshCli::ArgumentoInvalido(_)) => {}
1059 outro => panic!("esperava ArgumentoInvalido, veio {outro:?}"),
1060 }
1061 }
1062
1063 #[tokio::test]
1064 async fn conectar_com_porta_inalcançavel_retorna_erro_conexao_ou_timeout() {
1065 use super::super::ConfiguracaoConexao;
1070 use super::ClienteSsh;
1071 use crate::erros::ErroSshCli;
1072 use secrecy::SecretString;
1073
1074 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1077 .await
1078 .expect("bind efêmero");
1079 let porta = listener.local_addr().expect("addr").port();
1080
1081 let cfg = ConfiguracaoConexao {
1082 host: "127.0.0.1".to_string(),
1083 porta,
1084 usuario: "root".to_string(),
1085 senha: SecretString::from("senha".to_string()),
1086 timeout_ms: 200,
1087 };
1088
1089 let resultado = ClienteSsh::conectar(cfg).await;
1090 assert!(resultado.is_err(), "conexão deveria falhar");
1091 match resultado.unwrap_err() {
1092 ErroSshCli::TimeoutSsh(_) | ErroSshCli::ConexaoFalhou(_) => {
1093 }
1095 outro => panic!("esperava TimeoutSsh ou ConexaoFalhou, recebeu: {outro:?}"),
1096 }
1097 }
1098
1099 #[tokio::test]
1100 async fn conectar_com_porta_fechada_falha_conexao_tcp() {
1101 use super::super::ConfiguracaoConexao;
1103 use super::ClienteSsh;
1104 use secrecy::SecretString;
1105
1106 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1107 .await
1108 .expect("bind");
1109 let porta = listener.local_addr().expect("addr").port();
1110 drop(listener); let cfg = ConfiguracaoConexao {
1113 host: "127.0.0.1".to_string(),
1114 porta,
1115 usuario: "u".to_string(),
1116 senha: SecretString::from("s".to_string()),
1117 timeout_ms: 500,
1118 };
1119
1120 let resultado = ClienteSsh::conectar(cfg).await;
1121 assert!(resultado.is_err(), "conectar em porta fechada deve falhar");
1122 }
1123
1124 #[tokio::test]
1125 async fn manipulador_cliente_check_server_key_sempre_aceita() {
1126 use russh::client::Handler;
1132 use russh::keys::parse_public_key_base64;
1133
1134 let chave_base64 =
1136 "AAAAC3NzaC1lZDI1NTE5AAAAIKHEChfyk+R2N4OgRtRhnYXJYfxZqkEyiqYW7v4zj4iV";
1137 let pub_key = parse_public_key_base64(chave_base64).expect("chave base64 válida");
1138
1139 let mut handler = super::ManipuladorCliente;
1140 let resultado = handler
1141 .check_server_key(&pub_key)
1142 .await
1143 .expect("handler não falha");
1144 assert!(
1145 resultado,
1146 "handler é permissivo por design (trust-on-first-use iteração 2)"
1147 );
1148 }
1149 }
1150}
1151
1152#[cfg(feature = "ssh-real")]
1153pub use real::{ClienteSsh, ManipuladorCliente};
1154
1155#[cfg(not(feature = "ssh-real"))]
1160mod stub {
1161 use super::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
1162 use crate::erros::ErroSshCli;
1163 use crate::ssh::cliente::ClienteSshTrait;
1164 use async_trait::async_trait;
1165 use std::path::Path;
1166
1167 #[derive(Debug)]
1170 pub struct ClienteSsh;
1171
1172 #[async_trait]
1173 impl ClienteSshTrait for ClienteSsh {
1174 async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
1175 Err(ErroSshCli::ConexaoFalhou(
1176 "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
1177 ))
1178 }
1179
1180 async fn executar_comando(
1181 &mut self,
1182 _cmd: &str,
1183 _max_chars: usize,
1184 ) -> Result<SaidaExecucao, ErroSshCli> {
1185 Err(ErroSshCli::CanalFalhou(
1186 "stub sem russh: feature `ssh-real` desabilitada".into(),
1187 ))
1188 }
1189
1190 async fn upload(
1191 &mut self,
1192 _local: &Path,
1193 _remote: &Path,
1194 ) -> Result<TransferenciaResultado, ErroSshCli> {
1195 Err(ErroSshCli::CanalFalhou(
1196 "stub sem russh: feature `ssh-real` desabilitada".into(),
1197 ))
1198 }
1199
1200 async fn download(
1201 &mut self,
1202 _remote: &Path,
1203 _local: &Path,
1204 ) -> Result<TransferenciaResultado, ErroSshCli> {
1205 Err(ErroSshCli::CanalFalhou(
1206 "stub sem russh: feature `ssh-real` desabilitada".into(),
1207 ))
1208 }
1209
1210 async fn abrir_canal_tunel(
1211 &self,
1212 _host_remoto: &str,
1213 _porta_remota: u16,
1214 _endereco_origem: &str,
1215 _porta_origem: u16,
1216 ) -> Result<Box<dyn super::CanalTunel>, ErroSshCli> {
1217 Err(ErroSshCli::CanalFalhou(
1218 "stub sem russh: feature `ssh-real` desabilitada".into(),
1219 ))
1220 }
1221
1222 async fn desconectar(&self) -> Result<(), ErroSshCli> {
1223 Ok(())
1224 }
1225 }
1226}
1227
1228#[cfg(not(feature = "ssh-real"))]
1229pub use stub::ClienteSsh;
1230
1231#[cfg(test)]
1236mod testes {
1237 use super::*;
1238 use secrecy::SecretString;
1239
1240 fn cfg_valida() -> ConfiguracaoConexao {
1241 ConfiguracaoConexao {
1242 host: "127.0.0.1".to_string(),
1243 porta: 22,
1244 usuario: "root".to_string(),
1245 senha: SecretString::from("senha-exemplo".to_string()),
1246 timeout_ms: 5000,
1247 }
1248 }
1249
1250 #[test]
1251 fn validar_host_vazio_retorna_erro() {
1252 let mut c = cfg_valida();
1253 c.host = String::new();
1254 let r = c.validar();
1255 assert!(r.is_err());
1256 let msg = r.unwrap_err().to_string();
1257 assert!(msg.contains("host"));
1258 }
1259
1260 #[test]
1261 fn validar_host_apenas_espacos_retorna_erro() {
1262 let mut c = cfg_valida();
1263 c.host = " ".to_string();
1264 assert!(c.validar().is_err());
1265 }
1266
1267 #[test]
1268 fn validar_porta_zero_retorna_erro() {
1269 let mut c = cfg_valida();
1270 c.porta = 0;
1271 let r = c.validar();
1272 assert!(r.is_err());
1273 let msg = r.unwrap_err().to_string();
1274 assert!(msg.contains("porta"));
1275 }
1276
1277 #[test]
1278 fn validar_usuario_vazio_retorna_erro() {
1279 let mut c = cfg_valida();
1280 c.usuario = String::new();
1281 assert!(c.validar().is_err());
1282 }
1283
1284 #[test]
1285 fn validar_configuracao_correta_retorna_ok() {
1286 assert!(cfg_valida().validar().is_ok());
1287 }
1288
1289 #[test]
1290 fn debug_nao_expoe_senha() {
1291 let c = cfg_valida();
1292 let dbg = format!("{c:?}");
1293 assert!(!dbg.contains("senha-exemplo"));
1294 assert!(dbg.contains("redacted"));
1295 }
1296
1297 #[test]
1298 fn truncar_utf8_nao_trunca_se_cabe() {
1299 let (s, t) = truncar_utf8("ola mundo", 100);
1300 assert_eq!(s, "ola mundo");
1301 assert!(!t);
1302 }
1303
1304 #[test]
1305 fn truncar_utf8_trunca_string_grande_ascii() {
1306 let entrada: String = "a".repeat(200);
1307 let (s, t) = truncar_utf8(&entrada, 50);
1308 assert_eq!(s.chars().count(), 50);
1309 assert!(t);
1310 }
1311
1312 #[test]
1313 fn truncar_utf8_preserva_grafemas_acentuados() {
1314 let entrada: String = "á".repeat(30);
1316 let (s, t) = truncar_utf8(&entrada, 10);
1317 assert_eq!(s.chars().count(), 10);
1318 assert_eq!(s.len(), 20);
1320 assert!(t);
1321 assert!(s.chars().all(|c| c == 'á'));
1323 }
1324
1325 #[test]
1326 fn truncar_utf8_com_emojis_nao_quebra() {
1327 let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1328 let (s, t) = truncar_utf8(entrada, 5);
1329 assert_eq!(s.chars().count(), 5);
1330 assert!(t);
1331 }
1332
1333 #[test]
1334 fn truncar_utf8_zero_retorna_vazio() {
1335 let (s, t) = truncar_utf8("abc", 0);
1336 assert_eq!(s, "");
1337 assert!(t);
1338 }
1339
1340 #[test]
1341 fn saida_execucao_debug_nao_crasha() {
1342 let s = SaidaExecucao {
1343 stdout: "ok".into(),
1344 stderr: String::new(),
1345 exit_code: Some(0),
1346 truncado_stdout: false,
1347 truncado_stderr: false,
1348 duracao_ms: 42,
1349 };
1350 let _ = format!("{s:?}");
1351 }
1352
1353 #[test]
1354 fn duracao_ms_tipo_compativel() {
1355 let fake: u64 = 1234;
1357 assert_eq!(fake, 1234_u64);
1358 }
1359
1360 #[test]
1365 fn configuracao_conexao_clone_preserva_campos_visiveis() {
1366 let original = cfg_valida();
1367 let copia = original.clone();
1368 assert_eq!(copia.host, original.host);
1369 assert_eq!(copia.porta, original.porta);
1370 assert_eq!(copia.usuario, original.usuario);
1371 assert_eq!(copia.timeout_ms, original.timeout_ms);
1372 }
1373
1374 #[test]
1375 fn debug_contem_campos_principais() {
1376 let c = cfg_valida();
1377 let dbg = format!("{c:?}");
1378 assert!(dbg.contains("127.0.0.1"));
1379 assert!(dbg.contains("22"));
1380 assert!(dbg.contains("root"));
1381 assert!(dbg.contains("5000"));
1382 }
1383
1384 #[test]
1385 fn saida_execucao_clone_preserva_todos_campos() {
1386 let original = SaidaExecucao {
1387 stdout: "saida".to_string(),
1388 stderr: "erro".to_string(),
1389 exit_code: Some(7),
1390 truncado_stdout: true,
1391 truncado_stderr: false,
1392 duracao_ms: 999,
1393 };
1394 let copia = original.clone();
1395 assert_eq!(copia.stdout, "saida");
1396 assert_eq!(copia.stderr, "erro");
1397 assert_eq!(copia.exit_code, Some(7));
1398 assert!(copia.truncado_stdout);
1399 assert!(!copia.truncado_stderr);
1400 assert_eq!(copia.duracao_ms, 999);
1401 }
1402
1403 #[test]
1404 fn saida_execucao_exit_code_none_sinaliza_termino_por_sinal() {
1405 let s = SaidaExecucao {
1406 stdout: String::new(),
1407 stderr: String::new(),
1408 exit_code: None,
1409 truncado_stdout: false,
1410 truncado_stderr: false,
1411 duracao_ms: 0,
1412 };
1413 assert!(s.exit_code.is_none());
1414 let _ = format!("{s:?}");
1415 }
1416
1417 #[test]
1418 fn transferencia_resultado_clone_e_debug() {
1419 let original = TransferenciaResultado {
1420 bytes_transferidos: 1_048_576,
1421 duracao_ms: 2500,
1422 };
1423 let copia = original.clone();
1424 assert_eq!(copia.bytes_transferidos, 1_048_576);
1425 assert_eq!(copia.duracao_ms, 2500);
1426 let dbg = format!("{copia:?}");
1427 assert!(dbg.contains("1048576"));
1428 assert!(dbg.contains("2500"));
1429 }
1430
1431 #[test]
1436 fn truncar_utf8_exato_max_chars_nao_marca_truncado() {
1437 let entrada = "abcde";
1438 let (s, t) = truncar_utf8(entrada, 5);
1439 assert_eq!(s, "abcde");
1440 assert!(!t);
1441 }
1442
1443 #[test]
1444 fn truncar_utf8_com_string_vazia_retorna_vazio_sem_truncar() {
1445 let (s, t) = truncar_utf8("", 100);
1446 assert_eq!(s, "");
1447 assert!(!t);
1448 }
1449
1450 #[test]
1451 fn truncar_utf8_com_string_vazia_max_zero_nao_trunca() {
1452 let (s, t) = truncar_utf8("", 0);
1454 assert_eq!(s, "");
1455 assert!(!t);
1456 }
1457
1458 #[test]
1459 fn truncar_utf8_preserva_codepoints_mistos_cjk_emoji() {
1460 let entrada = "a中🔑b漢";
1462 assert_eq!(entrada.chars().count(), 5);
1463 let (s, t) = truncar_utf8(entrada, 3);
1464 assert_eq!(s.chars().count(), 3);
1465 let colhidos: String = entrada.chars().take(3).collect();
1467 assert_eq!(s, colhidos);
1468 assert!(t);
1469 }
1470
1471 #[test]
1472 fn truncar_utf8_max_muito_maior_que_string_nao_trunca() {
1473 let (s, t) = truncar_utf8("oi", usize::MAX);
1474 assert_eq!(s, "oi");
1475 assert!(!t);
1476 }
1477
1478 #[test]
1484 fn truncar_utf8_invariante_chars_count_sempre_le_max() {
1485 for max in [0usize, 1, 5, 10, 50, 100] {
1486 for entrada in [
1487 "",
1488 "a",
1489 "abcdef",
1490 "á".repeat(20).as_str(),
1491 "🚀",
1492 "🚀🚀🚀🚀🚀",
1493 "中文测试字符串",
1494 ] {
1495 let (s, _) = truncar_utf8(entrada, max);
1496 assert!(
1497 s.chars().count() <= max.max(0),
1498 "falha para max={max}, entrada={entrada:?}"
1499 );
1500 }
1501 }
1502 }
1503
1504 #[tokio::test]
1509 async fn mock_executar_comando_retorna_saida_configurada() {
1510 use crate::ssh::cliente::mocks::MockClienteSsh;
1511 use crate::ssh::cliente::ClienteSshTrait;
1512
1513 let mut mock = MockClienteSsh::new();
1514 mock.expect_executar_comando().times(1).returning(|cmd, _| {
1515 Ok(SaidaExecucao {
1516 stdout: format!("eco: {cmd}"),
1517 stderr: String::new(),
1518 exit_code: Some(0),
1519 truncado_stdout: false,
1520 truncado_stderr: false,
1521 duracao_ms: 10,
1522 })
1523 });
1524
1525 let saida = mock.executar_comando("echo oi", 100).await.expect("ok");
1526 assert_eq!(saida.stdout, "eco: echo oi");
1527 assert_eq!(saida.exit_code, Some(0));
1528 assert_eq!(saida.duracao_ms, 10);
1529 }
1530
1531 #[tokio::test]
1532 async fn mock_executar_comando_propaga_erro_canal() {
1533 use crate::erros::ErroSshCli;
1534 use crate::ssh::cliente::mocks::MockClienteSsh;
1535 use crate::ssh::cliente::ClienteSshTrait;
1536
1537 let mut mock = MockClienteSsh::new();
1538 mock.expect_executar_comando()
1539 .returning(|_, _| Err(ErroSshCli::CanalFalhou("erro simulado".to_string())));
1540
1541 let erro = mock.executar_comando("ls", 100).await.expect_err("erro");
1542 assert!(matches!(erro, ErroSshCli::CanalFalhou(_)));
1543 }
1544
1545 #[tokio::test]
1546 async fn mock_upload_retorna_transferencia_configurada() {
1547 use crate::ssh::cliente::mocks::MockClienteSsh;
1548 use crate::ssh::cliente::ClienteSshTrait;
1549 use std::path::PathBuf;
1550
1551 let mut mock = MockClienteSsh::new();
1552 mock.expect_upload().times(1).returning(|_, _| {
1553 Ok(TransferenciaResultado {
1554 bytes_transferidos: 4096,
1555 duracao_ms: 50,
1556 })
1557 });
1558
1559 let local = PathBuf::from("/tmp/arquivo_local");
1560 let remote = PathBuf::from("/remote/arquivo");
1561 let resultado = mock.upload(&local, &remote).await.expect("ok");
1562 assert_eq!(resultado.bytes_transferidos, 4096);
1563 assert_eq!(resultado.duracao_ms, 50);
1564 }
1565
1566 #[tokio::test]
1567 async fn mock_download_retorna_transferencia_configurada() {
1568 use crate::ssh::cliente::mocks::MockClienteSsh;
1569 use crate::ssh::cliente::ClienteSshTrait;
1570 use std::path::PathBuf;
1571
1572 let mut mock = MockClienteSsh::new();
1573 mock.expect_download().times(1).returning(|_, _| {
1574 Ok(TransferenciaResultado {
1575 bytes_transferidos: 2048,
1576 duracao_ms: 30,
1577 })
1578 });
1579
1580 let remote = PathBuf::from("/remote/x");
1581 let local = PathBuf::from("/tmp/x");
1582 let resultado = mock.download(&remote, &local).await.expect("ok");
1583 assert_eq!(resultado.bytes_transferidos, 2048);
1584 assert_eq!(resultado.duracao_ms, 30);
1585 }
1586
1587 #[tokio::test]
1588 async fn mock_download_propaga_erro_arquivo() {
1589 use crate::erros::ErroSshCli;
1590 use crate::ssh::cliente::mocks::MockClienteSsh;
1591 use crate::ssh::cliente::ClienteSshTrait;
1592 use std::path::PathBuf;
1593
1594 let mut mock = MockClienteSsh::new();
1595 mock.expect_download()
1596 .returning(|_, _| Err(ErroSshCli::ArquivoNaoEncontrado("inexistente".to_string())));
1597
1598 let erro = mock
1599 .download(&PathBuf::from("/r"), &PathBuf::from("/l"))
1600 .await
1601 .expect_err("erro");
1602 assert!(matches!(erro, ErroSshCli::ArquivoNaoEncontrado(_)));
1603 }
1604
1605 #[tokio::test]
1606 async fn mock_desconectar_ok() {
1607 use crate::ssh::cliente::mocks::MockClienteSsh;
1608 use crate::ssh::cliente::ClienteSshTrait;
1609
1610 let mut mock = MockClienteSsh::new();
1611 mock.expect_desconectar().times(1).returning(|| Ok(()));
1612
1613 assert!(mock.desconectar().await.is_ok());
1614 }
1615
1616 #[tokio::test]
1617 async fn mock_desconectar_propaga_erro() {
1618 use crate::erros::ErroSshCli;
1619 use crate::ssh::cliente::mocks::MockClienteSsh;
1620 use crate::ssh::cliente::ClienteSshTrait;
1621
1622 let mut mock = MockClienteSsh::new();
1623 mock.expect_desconectar()
1624 .returning(|| Err(ErroSshCli::ConexaoFalhou("eof".to_string())));
1625
1626 let erro = mock.desconectar().await.expect_err("erro");
1627 assert!(matches!(erro, ErroSshCli::ConexaoFalhou(_)));
1628 }
1629
1630 #[tokio::test]
1631 async fn mock_executar_comando_invocado_multiplas_vezes_respeita_times() {
1632 use crate::ssh::cliente::mocks::MockClienteSsh;
1633 use crate::ssh::cliente::ClienteSshTrait;
1634
1635 let mut mock = MockClienteSsh::new();
1636 mock.expect_executar_comando().times(3).returning(|_, _| {
1637 Ok(SaidaExecucao {
1638 stdout: "ok".to_string(),
1639 stderr: String::new(),
1640 exit_code: Some(0),
1641 truncado_stdout: false,
1642 truncado_stderr: false,
1643 duracao_ms: 1,
1644 })
1645 });
1646
1647 for _ in 0..3 {
1648 let r = mock.executar_comando("x", 10).await.expect("ok");
1649 assert_eq!(r.stdout, "ok");
1650 }
1651 }
1652
1653 #[tokio::test]
1654 async fn mock_executar_comando_com_with_matcher_filtra_argumentos() {
1655 use crate::ssh::cliente::mocks::MockClienteSsh;
1656 use crate::ssh::cliente::ClienteSshTrait;
1657 use mockall::predicate::*;
1658
1659 let mut mock = MockClienteSsh::new();
1660 mock.expect_executar_comando()
1661 .with(eq("ls -la"), eq(500usize))
1662 .times(1)
1663 .returning(|_, _| {
1664 Ok(SaidaExecucao {
1665 stdout: "listagem".to_string(),
1666 stderr: String::new(),
1667 exit_code: Some(0),
1668 truncado_stdout: false,
1669 truncado_stderr: false,
1670 duracao_ms: 5,
1671 })
1672 });
1673
1674 let r = mock.executar_comando("ls -la", 500).await.expect("ok");
1675 assert_eq!(r.stdout, "listagem");
1676 }
1677
1678 #[tokio::test]
1679 async fn mock_upload_com_predicate_caminho() {
1680 use crate::ssh::cliente::mocks::MockClienteSsh;
1681 use crate::ssh::cliente::ClienteSshTrait;
1682 use mockall::predicate::*;
1683 use std::path::{Path, PathBuf};
1684
1685 let mut mock = MockClienteSsh::new();
1686 mock.expect_upload()
1687 .with(eq(Path::new("/tmp/a")), eq(Path::new("/remote/b")))
1688 .returning(|_, _| {
1689 Ok(TransferenciaResultado {
1690 bytes_transferidos: 10,
1691 duracao_ms: 1,
1692 })
1693 });
1694
1695 let r = mock
1696 .upload(&PathBuf::from("/tmp/a"), &PathBuf::from("/remote/b"))
1697 .await
1698 .expect("ok");
1699 assert_eq!(r.bytes_transferidos, 10);
1700 }
1701
1702 #[tokio::test]
1703 async fn mock_abrir_canal_tunel_propaga_erro_canal() {
1704 use crate::erros::ErroSshCli;
1705 use crate::ssh::cliente::mocks::MockClienteSsh;
1706 use crate::ssh::cliente::ClienteSshTrait;
1707
1708 let mut mock = MockClienteSsh::new();
1709 mock.expect_abrir_canal_tunel()
1710 .returning(|_, _, _, _| Err(ErroSshCli::CanalFalhou("sem tunnel".to_string())));
1711
1712 let resultado = mock
1713 .abrir_canal_tunel("host.exemplo", 8080, "127.0.0.1", 12345)
1714 .await;
1715 match resultado {
1716 Ok(_) => panic!("esperava erro, recebeu Ok"),
1717 Err(ErroSshCli::CanalFalhou(_)) => {}
1718 Err(outro) => panic!("variante de erro inesperada: {outro:?}"),
1719 }
1720 }
1721
1722 #[tokio::test]
1723 async fn mock_fluxo_completo_conectar_exec_desconectar() {
1724 use crate::ssh::cliente::mocks::MockClienteSsh;
1727 use crate::ssh::cliente::ClienteSshTrait;
1728
1729 let mut mock = MockClienteSsh::new();
1730 mock.expect_executar_comando().returning(|_, _| {
1731 Ok(SaidaExecucao {
1732 stdout: "hostname-x".to_string(),
1733 stderr: String::new(),
1734 exit_code: Some(0),
1735 truncado_stdout: false,
1736 truncado_stderr: false,
1737 duracao_ms: 7,
1738 })
1739 });
1740 mock.expect_desconectar().returning(|| Ok(()));
1741
1742 let saida = mock.executar_comando("hostname", 200).await.expect("ok");
1743 assert_eq!(saida.stdout, "hostname-x");
1744 mock.desconectar().await.expect("desconecta");
1745 }
1746
1747 #[tokio::test]
1748 async fn mock_conectar_retorna_caixa_do_mock() {
1749 use crate::ssh::cliente::mocks::MockClienteSsh;
1753
1754 let _guard = MockClienteSsh::conectar_context();
1756 drop(_guard);
1758 }
1759
1760 #[tokio::test]
1767 async fn mock_executar_comando_com_return_once() {
1768 use crate::ssh::cliente::mocks::MockClienteSsh;
1769 use crate::ssh::cliente::ClienteSshTrait;
1770
1771 let mut mock = MockClienteSsh::new();
1772 let saida = SaidaExecucao {
1773 stdout: "único".to_string(),
1774 stderr: String::new(),
1775 exit_code: Some(0),
1776 truncado_stdout: false,
1777 truncado_stderr: false,
1778 duracao_ms: 3,
1779 };
1780 mock.expect_executar_comando()
1781 .return_once(move |_, _| Ok(saida));
1782
1783 let r = mock.executar_comando("once", 100).await.expect("ok");
1784 assert_eq!(r.stdout, "único");
1785 }
1786
1787 #[tokio::test]
1788 async fn mock_desconectar_com_returning_ok() {
1789 use crate::ssh::cliente::mocks::MockClienteSsh;
1792 use crate::ssh::cliente::ClienteSshTrait;
1793
1794 let mut mock = MockClienteSsh::new();
1795 mock.expect_desconectar().returning(|| Ok(()));
1796
1797 assert!(mock.desconectar().await.is_ok());
1798 }
1799
1800 #[tokio::test]
1801 async fn mock_upload_usado_zero_vezes_respeita_never() {
1802 use crate::ssh::cliente::mocks::MockClienteSsh;
1803
1804 let mut mock = MockClienteSsh::new();
1805 mock.expect_upload().never();
1806 drop(mock);
1808 }
1809
1810 #[tokio::test]
1811 async fn mock_download_com_times_range() {
1812 use crate::ssh::cliente::mocks::MockClienteSsh;
1813 use crate::ssh::cliente::ClienteSshTrait;
1814 use std::path::PathBuf;
1815
1816 let mut mock = MockClienteSsh::new();
1817 mock.expect_download().times(1..=2).returning(|_, _| {
1818 Ok(TransferenciaResultado {
1819 bytes_transferidos: 1,
1820 duracao_ms: 1,
1821 })
1822 });
1823
1824 let r = mock
1825 .download(&PathBuf::from("/r"), &PathBuf::from("/l"))
1826 .await
1827 .expect("ok");
1828 assert_eq!(r.bytes_transferidos, 1);
1829 }
1830
1831 #[tokio::test]
1832 async fn mock_executar_comando_com_never_e_dropa() {
1833 use crate::ssh::cliente::mocks::MockClienteSsh;
1834
1835 let mut mock = MockClienteSsh::new();
1836 mock.expect_executar_comando().never();
1837 drop(mock);
1839 }
1840
1841 #[tokio::test]
1842 async fn mock_desconectar_com_returning_sem_argumentos() {
1843 use crate::ssh::cliente::mocks::MockClienteSsh;
1844 use crate::ssh::cliente::ClienteSshTrait;
1845
1846 let mut mock = MockClienteSsh::new();
1847 mock.expect_desconectar().returning(|| Ok(()));
1848
1849 assert!(mock.desconectar().await.is_ok());
1850 let _boxed: Box<dyn ClienteSshTrait> = Box::new(mock);
1852 }
1853
1854 #[tokio::test]
1855 async fn mock_upload_com_times_exato() {
1856 use crate::ssh::cliente::mocks::MockClienteSsh;
1857 use crate::ssh::cliente::ClienteSshTrait;
1858 use std::path::PathBuf;
1859
1860 let mut mock = MockClienteSsh::new();
1861 mock.expect_upload().times(2).returning(|_, _| {
1862 Ok(TransferenciaResultado {
1863 bytes_transferidos: 512,
1864 duracao_ms: 10,
1865 })
1866 });
1867
1868 for _ in 0..2 {
1869 let r = mock
1870 .upload(&PathBuf::from("/a"), &PathBuf::from("/b"))
1871 .await
1872 .expect("ok");
1873 assert_eq!(r.bytes_transferidos, 512);
1874 }
1875 }
1876
1877 #[tokio::test]
1878 async fn mock_abrir_canal_tunel_com_returning_captura_argumentos() {
1879 use crate::erros::ErroSshCli;
1880 use crate::ssh::cliente::mocks::MockClienteSsh;
1881 use crate::ssh::cliente::ClienteSshTrait;
1882
1883 let mut mock = MockClienteSsh::new();
1884 mock.expect_abrir_canal_tunel()
1885 .returning(|host, porta, origem, porta_origem| {
1886 assert_eq!(host, "servidor.exemplo");
1887 assert_eq!(porta, 443);
1888 assert_eq!(origem, "127.0.0.1");
1889 assert_eq!(porta_origem, 8443);
1890 Err(ErroSshCli::CanalFalhou("fake".to_string()))
1891 });
1892
1893 let resultado = mock
1894 .abrir_canal_tunel("servidor.exemplo", 443, "127.0.0.1", 8443)
1895 .await;
1896 assert!(resultado.is_err());
1897 }
1898
1899 #[tokio::test]
1900 async fn mock_executar_comando_com_in_sequence() {
1901 use crate::ssh::cliente::mocks::MockClienteSsh;
1904 use crate::ssh::cliente::ClienteSshTrait;
1905 use mockall::Sequence;
1906
1907 let mut mock = MockClienteSsh::new();
1908 let mut seq = Sequence::new();
1909
1910 mock.expect_executar_comando()
1911 .times(1)
1912 .in_sequence(&mut seq)
1913 .returning(|_, _| {
1914 Ok(SaidaExecucao {
1915 stdout: "primeiro".to_string(),
1916 stderr: String::new(),
1917 exit_code: Some(0),
1918 truncado_stdout: false,
1919 truncado_stderr: false,
1920 duracao_ms: 1,
1921 })
1922 });
1923
1924 mock.expect_executar_comando()
1925 .times(1)
1926 .in_sequence(&mut seq)
1927 .returning(|_, _| {
1928 Ok(SaidaExecucao {
1929 stdout: "segundo".to_string(),
1930 stderr: String::new(),
1931 exit_code: Some(0),
1932 truncado_stdout: false,
1933 truncado_stderr: false,
1934 duracao_ms: 1,
1935 })
1936 });
1937
1938 let r1 = mock.executar_comando("a", 10).await.expect("ok");
1939 assert_eq!(r1.stdout, "primeiro");
1940 let r2 = mock.executar_comando("b", 10).await.expect("ok");
1941 assert_eq!(r2.stdout, "segundo");
1942 }
1943}