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> {
346 cfg.validar()?;
347
348 let timeout = Duration::from_millis(cfg.timeout_ms);
349 let host = cfg.host.clone();
350 let porta = cfg.porta;
351 let usuario = cfg.usuario.clone();
352 let senha_segura = cfg.senha.clone();
353
354 let config_cliente = Arc::new(russh::client::Config {
355 inactivity_timeout: Some(timeout),
356 ..Default::default()
357 });
358
359 tracing::info!(
360 host = %host,
361 porta,
362 usuario = %usuario,
363 timeout_ms = cfg.timeout_ms,
364 "iniciando conexão SSH"
365 );
366
367 let resultado_conexao = tokio::time::timeout(timeout, async move {
369 let mut sessao = russh::client::connect(
370 config_cliente,
371 (host.as_str(), porta),
372 ManipuladorCliente,
373 )
374 .await
375 .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha TCP/handshake: {e}")))?;
376
377 let auth = sessao
378 .authenticate_password(usuario.clone(), senha_segura.expose_secret())
379 .await
380 .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha auth transport: {e}")))?;
381
382 if !auth.success() {
383 tracing::warn!(
384 host = %host,
385 usuario = %usuario,
386 "autenticação SSH rejeitada"
387 );
388 return Err(ErroSshCli::AutenticacaoFalhou);
389 }
390
391 Ok::<_, ErroSshCli>(sessao)
392 })
393 .await;
394
395 let sessao = match resultado_conexao {
396 Ok(Ok(s)) => s,
397 Ok(Err(erro)) => return Err(erro),
398 Err(_) => return Err(ErroSshCli::TimeoutSsh(cfg.timeout_ms)),
399 };
400
401 tracing::info!("conexão SSH autenticada com sucesso");
402
403 Ok(Self { sessao, cfg })
404 }
405
406 pub async fn executar_comando(
415 &mut self,
416 comando: &str,
417 max_chars: usize,
418 ) -> ResultadoSshCli<SaidaExecucao> {
419 let inicio = Instant::now();
420 let timeout = Duration::from_millis(self.cfg.timeout_ms);
421
422 let resultado = tokio::time::timeout(timeout, async {
423 let mut canal = self
424 .sessao
425 .channel_open_session()
426 .await
427 .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão: {e}")))?;
428
429 canal
430 .exec(true, comando)
431 .await
432 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec: {e}")))?;
433
434 let mut stdout_bytes: Vec<u8> = Vec::new();
435 let mut stderr_bytes: Vec<u8> = Vec::new();
436 let mut exit_code: Option<i32> = None;
437
438 while let Some(msg) = canal.wait().await {
439 if processar_mensagem_exec(
440 msg,
441 &mut stdout_bytes,
442 &mut stderr_bytes,
443 &mut exit_code,
444 ) {
445 break;
446 }
447 }
448
449 Ok::<_, ErroSshCli>((stdout_bytes, stderr_bytes, exit_code))
450 })
451 .await;
452
453 let (stdout_bytes, stderr_bytes, exit_code) = match resultado {
454 Ok(Ok(t)) => t,
455 Ok(Err(erro)) => return Err(erro),
456 Err(_) => return Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms)),
457 };
458
459 let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
461 let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
462
463 let (stdout_truncado, truncado_stdout) = super::truncar_utf8(&stdout_str, max_chars);
464 let (stderr_truncado, truncado_stderr) = super::truncar_utf8(&stderr_str, max_chars);
465
466 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
467
468 Ok(SaidaExecucao {
469 stdout: stdout_truncado,
470 stderr: stderr_truncado,
471 exit_code,
472 truncado_stdout,
473 truncado_stderr,
474 duracao_ms,
475 })
476 }
477
478 pub async fn upload(
485 &mut self,
486 local: &std::path::Path,
487 remote: &std::path::Path,
488 ) -> ResultadoSshCli<TransferenciaResultado> {
489 use russh::ChannelMsg;
490 use std::time::Instant;
491
492 let local_str = local.display().to_string();
493 let remote_str = remote.display().to_string();
494
495 let metadados = std::fs::metadata(local).map_err(|e| {
496 if e.kind() == std::io::ErrorKind::NotFound {
497 ErroSshCli::ArquivoNaoEncontrado(local_str.clone())
498 } else {
499 ErroSshCli::Io(e)
500 }
501 })?;
502
503 if !metadados.is_file() {
504 return Err(ErroSshCli::ArgumentoInvalido(
505 "upload só suporta arquivos regulares".to_string(),
506 ));
507 }
508
509 let tamanho = metadados.len();
510 let nome_arquivo = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
511
512 let inicio = Instant::now();
513 let timeout = Duration::from_millis(self.cfg.timeout_ms);
514
515 let resultado =
516 tokio::time::timeout(timeout, async {
517 let mut canal =
518 self.sessao.channel_open_session().await.map_err(|e| {
519 ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}"))
520 })?;
521
522 let comando = format!("scp -t -p {}", remote_str);
523 canal
524 .exec(true, comando.as_str())
525 .await
526 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
527
528 canal.wait().await.ok_or_else(|| {
529 ErroSshCli::CanalFalhou("canal fechou prematuramente".to_string())
530 })?;
531
532 let resposta = formatar_header_upload_scp(tamanho, nome_arquivo);
533 canal
534 .data(resposta.as_bytes())
535 .await
536 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar header SCP: {e}")))?;
537
538 canal.wait().await.ok_or_else(|| {
539 ErroSshCli::CanalFalhou("canal fechou durante header".to_string())
540 })?;
541
542 let conteudo = std::fs::read(local).map_err(ErroSshCli::Io)?;
543 let mut offset = 0;
544 let tamanho_bloco = 32768;
545
546 while offset < conteudo.len() {
547 let fim = std::cmp::min(offset + tamanho_bloco, conteudo.len());
548 let bloco = &conteudo[offset..fim];
549 canal.data(bloco).await.map_err(|e| {
550 ErroSshCli::CanalFalhou(format!("enviar bloco SCP: {e}"))
551 })?;
552 offset = fim;
553 }
554
555 canal
556 .data(&[] as &[u8])
557 .await
558 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar EOF SCP: {e}")))?;
559
560 canal.wait().await.ok_or_else(|| {
561 ErroSshCli::CanalFalhou("canal fechou durante transferência".to_string())
562 })?;
563
564 while let Some(msg) = canal.wait().await {
565 if let ChannelMsg::Close = msg {
566 break;
567 }
568 }
569
570 Ok::<_, ErroSshCli>(())
571 })
572 .await;
573
574 resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
575
576 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
577
578 Ok(TransferenciaResultado {
579 bytes_transferidos: tamanho,
580 duracao_ms,
581 })
582 }
583
584 pub async fn download(
591 &mut self,
592 remote: &std::path::Path,
593 local: &std::path::Path,
594 ) -> ResultadoSshCli<TransferenciaResultado> {
595 use russh::ChannelMsg;
596 use std::io::Write;
597 use std::time::Instant;
598
599 let remote_str = remote.display().to_string();
600
601 let inicio = Instant::now();
602 let timeout = Duration::from_millis(self.cfg.timeout_ms);
603
604 let resultado = tokio::time::timeout(timeout, async {
605 let mut canal = self
606 .sessao
607 .channel_open_session()
608 .await
609 .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}")))?;
610
611 let comando = format!("scp -f -p {}", remote_str);
612 canal
613 .exec(true, comando.as_str())
614 .await
615 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
616
617 canal
618 .data(&[] as &[u8])
619 .await
620 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack inicial: {e}")))?;
621
622 let mut msg = canal.wait().await.ok_or_else(|| {
623 ErroSshCli::CanalFalhou("canal fechou esperando header".to_string())
624 })?;
625
626 let ChannelMsg::Data { data } = msg else {
627 return Err(ErroSshCli::CanalFalhou(
628 "esperava dados do servidor".to_string(),
629 ));
630 };
631
632 let header = String::from_utf8_lossy(&data);
633 let tamanho = parse_header_scp(&header)?;
634
635 canal
636 .data(&[] as &[u8])
637 .await
638 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack: {e}")))?;
639
640 if let Some(pai) = local.parent() {
641 std::fs::create_dir_all(pai)?;
642 }
643
644 let mut arquivo = std::fs::File::create(local).map_err(ErroSshCli::Io)?;
645 let mut recebidos: u64 = 0;
646
647 while recebidos < tamanho {
648 msg = canal.wait().await.ok_or_else(|| {
649 ErroSshCli::CanalFalhou("canal fechou durante download".to_string())
650 })?;
651
652 let ChannelMsg::Data { data } = msg else {
653 continue;
654 };
655
656 let bytes = data.as_ref();
657 if bytes.is_empty() {
658 continue;
659 }
660
661 arquivo.write_all(bytes).map_err(ErroSshCli::Io)?;
662 recebidos += bytes.len() as u64;
663
664 canal.data(&[] as &[u8]).await.map_err(|e| {
665 ErroSshCli::CanalFalhou(format!("enviar ack durante download: {e}"))
666 })?;
667 }
668
669 while let Some(msg) = canal.wait().await {
670 if let ChannelMsg::Close = msg {
671 break;
672 }
673 }
674
675 Ok::<_, ErroSshCli>(recebidos)
676 })
677 .await;
678
679 let recebidos =
680 resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
681
682 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
683
684 Ok(TransferenciaResultado {
685 bytes_transferidos: recebidos,
686 duracao_ms,
687 })
688 }
689
690 pub async fn desconectar(&self) -> ResultadoSshCli<()> {
695 let resultado = self
696 .sessao
697 .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
698 .await;
699 match resultado {
700 Ok(()) => {
701 tracing::info!("sessão SSH encerrada");
702 Ok(())
703 }
704 Err(e) => {
705 tracing::warn!(erro = %e, "falha ao encerrar sessão SSH");
706 Err(ErroSshCli::ConexaoFalhou(format!(
707 "falha ao desconectar: {e}"
708 )))
709 }
710 }
711 }
712
713 pub async fn abrir_canal_tunel(
715 &self,
716 host_remoto: &str,
717 porta_remota: u16,
718 endereco_origem: &str,
719 porta_origem: u16,
720 ) -> ResultadoSshCli<Box<dyn CanalTunel>> {
721 let canal = self
722 .sessao
723 .channel_open_direct_tcpip(
724 host_remoto.to_string(),
725 u32::from(porta_remota),
726 endereco_origem.to_string(),
727 u32::from(porta_origem),
728 )
729 .await
730 .map_err(|e| {
731 ErroSshCli::CanalFalhou(format!(
732 "falha ao abrir canal direct-tcpip para {}:{}: {}",
733 host_remoto, porta_remota, e
734 ))
735 })?;
736
737 Ok(Box::new(canal.into_stream()))
738 }
739 }
740
741 #[async_trait]
742 impl ClienteSshTrait for ClienteSsh {
743 async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
744 Self::conectar(cfg).await.map(Box::new)
745 }
746
747 async fn executar_comando(
748 &mut self,
749 cmd: &str,
750 max_chars: usize,
751 ) -> Result<SaidaExecucao, ErroSshCli> {
752 Self::executar_comando(self, cmd, max_chars).await
753 }
754
755 async fn upload(
756 &mut self,
757 local: &Path,
758 remote: &Path,
759 ) -> Result<TransferenciaResultado, ErroSshCli> {
760 Self::upload(self, local, remote).await
761 }
762
763 async fn download(
764 &mut self,
765 remote: &Path,
766 local: &Path,
767 ) -> Result<TransferenciaResultado, ErroSshCli> {
768 Self::download(self, remote, local).await
769 }
770
771 async fn abrir_canal_tunel(
772 &self,
773 host_remoto: &str,
774 porta_remota: u16,
775 endereco_origem: &str,
776 porta_origem: u16,
777 ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
778 Self::abrir_canal_tunel(
779 self,
780 host_remoto,
781 porta_remota,
782 endereco_origem,
783 porta_origem,
784 )
785 .await
786 }
787
788 async fn desconectar(&self) -> Result<(), ErroSshCli> {
789 Self::desconectar(self).await
790 }
791 }
792
793 #[cfg(test)]
794 mod testes_real {
795 use super::{
796 formatar_header_upload_scp, mapear_exit_status, parse_header_scp,
797 processar_mensagem_exec,
798 };
799
800 #[test]
801 fn mapear_exit_status_normal() {
802 assert_eq!(mapear_exit_status(0), 0);
803 assert_eq!(mapear_exit_status(255), 255);
804 }
805
806 #[test]
807 fn mapear_exit_status_overflow_retorna_menos_um() {
808 assert_eq!(mapear_exit_status(u32::MAX), -1);
809 }
810
811 #[test]
812 fn parse_header_scp_valido_retorna_tamanho() {
813 let tamanho = parse_header_scp("C0644 42 arquivo.txt\n").expect("header válido");
814 assert_eq!(tamanho, 42);
815 }
816
817 #[test]
818 fn parse_header_scp_invalido_retorna_erro() {
819 assert!(parse_header_scp("ERRO").is_err());
820 assert!(parse_header_scp("C0644 sem_tamanho").is_err());
821 assert!(parse_header_scp("C0644 abc arquivo").is_err());
822 }
823
824 #[test]
825 fn processar_mensagem_exec_trata_stdout_stderr_e_close() {
826 let mut stdout = Vec::new();
827 let mut stderr = Vec::new();
828 let mut exit_code = None;
829
830 let deve_parar = processar_mensagem_exec(
831 russh::ChannelMsg::Data {
832 data: b"stdout".to_vec().into(),
833 },
834 &mut stdout,
835 &mut stderr,
836 &mut exit_code,
837 );
838 assert!(!deve_parar);
839 assert_eq!(stdout, b"stdout");
840
841 let deve_parar = processar_mensagem_exec(
842 russh::ChannelMsg::ExtendedData {
843 data: b"stderr".to_vec().into(),
844 ext: 1,
845 },
846 &mut stdout,
847 &mut stderr,
848 &mut exit_code,
849 );
850 assert!(!deve_parar);
851 assert_eq!(stderr, b"stderr");
852
853 let _ = processar_mensagem_exec(
854 russh::ChannelMsg::ExitStatus { exit_status: 17 },
855 &mut stdout,
856 &mut stderr,
857 &mut exit_code,
858 );
859 assert_eq!(exit_code, Some(17));
860
861 let deve_parar = processar_mensagem_exec(
862 russh::ChannelMsg::Close,
863 &mut stdout,
864 &mut stderr,
865 &mut exit_code,
866 );
867 assert!(deve_parar);
868 }
869
870 #[test]
871 fn formatar_header_upload_scp_gera_formato_esperado() {
872 let header = formatar_header_upload_scp(123, "arquivo.txt");
873 assert_eq!(header, "C0644 123 arquivo.txt\\n");
874 }
875
876 #[test]
877 fn processar_mensagem_exec_ignora_extendido_com_codigo_diferente_de_stderr() {
878 let mut stdout = Vec::new();
879 let mut stderr = Vec::new();
880 let mut exit_code = None;
881
882 let deve_parar = processar_mensagem_exec(
883 russh::ChannelMsg::ExtendedData {
884 data: b"nao-e-stderr".to_vec().into(),
885 ext: 2,
886 },
887 &mut stdout,
888 &mut stderr,
889 &mut exit_code,
890 );
891
892 assert!(!deve_parar);
893 assert!(stdout.is_empty());
894 assert!(stderr.is_empty());
895 assert!(exit_code.is_none());
896 }
897
898 #[test]
899 fn processar_mensagem_exec_trata_exit_signal_e_eof_sem_encerrar_loop() {
900 let mut stdout = Vec::new();
901 let mut stderr = Vec::new();
902 let mut exit_code = Some(7);
903
904 let deve_parar_signal = processar_mensagem_exec(
905 russh::ChannelMsg::ExitSignal {
906 signal_name: russh::Sig::TERM,
907 core_dumped: false,
908 error_message: "encerrado".to_string(),
909 lang_tag: "pt-BR".to_string(),
910 },
911 &mut stdout,
912 &mut stderr,
913 &mut exit_code,
914 );
915
916 let deve_parar_eof = processar_mensagem_exec(
917 russh::ChannelMsg::Eof,
918 &mut stdout,
919 &mut stderr,
920 &mut exit_code,
921 );
922
923 assert!(!deve_parar_signal);
924 assert!(!deve_parar_eof);
925 assert_eq!(exit_code, Some(7));
926 }
927
928 #[test]
929 fn processar_mensagem_exec_ignora_variantes_sem_tratamento_especifico() {
930 let mut stdout = Vec::new();
931 let mut stderr = Vec::new();
932 let mut exit_code = None;
933
934 let deve_parar = processar_mensagem_exec(
935 russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
936 &mut stdout,
937 &mut stderr,
938 &mut exit_code,
939 );
940
941 assert!(!deve_parar);
942 assert!(stdout.is_empty());
943 assert!(stderr.is_empty());
944 assert!(exit_code.is_none());
945 }
946 }
947}
948
949#[cfg(feature = "ssh-real")]
950pub use real::{ClienteSsh, ManipuladorCliente};
951
952#[cfg(not(feature = "ssh-real"))]
957mod stub {
958 use super::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
959 use crate::erros::ErroSshCli;
960 use crate::ssh::cliente::ClienteSshTrait;
961 use async_trait::async_trait;
962 use std::path::Path;
963
964 #[derive(Debug)]
967 pub struct ClienteSsh;
968
969 #[async_trait]
970 impl ClienteSshTrait for ClienteSsh {
971 async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
972 Err(ErroSshCli::ConexaoFalhou(
973 "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
974 ))
975 }
976
977 async fn executar_comando(
978 &mut self,
979 _cmd: &str,
980 _max_chars: usize,
981 ) -> Result<SaidaExecucao, ErroSshCli> {
982 Err(ErroSshCli::CanalFalhou(
983 "stub sem russh: feature `ssh-real` desabilitada".into(),
984 ))
985 }
986
987 async fn upload(
988 &mut self,
989 _local: &Path,
990 _remote: &Path,
991 ) -> Result<TransferenciaResultado, ErroSshCli> {
992 Err(ErroSshCli::CanalFalhou(
993 "stub sem russh: feature `ssh-real` desabilitada".into(),
994 ))
995 }
996
997 async fn download(
998 &mut self,
999 _remote: &Path,
1000 _local: &Path,
1001 ) -> Result<TransferenciaResultado, ErroSshCli> {
1002 Err(ErroSshCli::CanalFalhou(
1003 "stub sem russh: feature `ssh-real` desabilitada".into(),
1004 ))
1005 }
1006
1007 async fn abrir_canal_tunel(
1008 &self,
1009 _host_remoto: &str,
1010 _porta_remota: u16,
1011 _endereco_origem: &str,
1012 _porta_origem: u16,
1013 ) -> Result<Box<dyn super::CanalTunel>, ErroSshCli> {
1014 Err(ErroSshCli::CanalFalhou(
1015 "stub sem russh: feature `ssh-real` desabilitada".into(),
1016 ))
1017 }
1018
1019 async fn desconectar(&self) -> Result<(), ErroSshCli> {
1020 Ok(())
1021 }
1022 }
1023}
1024
1025#[cfg(not(feature = "ssh-real"))]
1026pub use stub::ClienteSsh;
1027
1028#[cfg(test)]
1033mod testes {
1034 use super::*;
1035 use secrecy::SecretString;
1036
1037 fn cfg_valida() -> ConfiguracaoConexao {
1038 ConfiguracaoConexao {
1039 host: "127.0.0.1".to_string(),
1040 porta: 22,
1041 usuario: "root".to_string(),
1042 senha: SecretString::from("senha-exemplo".to_string()),
1043 timeout_ms: 5000,
1044 }
1045 }
1046
1047 #[test]
1048 fn validar_host_vazio_retorna_erro() {
1049 let mut c = cfg_valida();
1050 c.host = String::new();
1051 let r = c.validar();
1052 assert!(r.is_err());
1053 let msg = r.unwrap_err().to_string();
1054 assert!(msg.contains("host"));
1055 }
1056
1057 #[test]
1058 fn validar_host_apenas_espacos_retorna_erro() {
1059 let mut c = cfg_valida();
1060 c.host = " ".to_string();
1061 assert!(c.validar().is_err());
1062 }
1063
1064 #[test]
1065 fn validar_porta_zero_retorna_erro() {
1066 let mut c = cfg_valida();
1067 c.porta = 0;
1068 let r = c.validar();
1069 assert!(r.is_err());
1070 let msg = r.unwrap_err().to_string();
1071 assert!(msg.contains("porta"));
1072 }
1073
1074 #[test]
1075 fn validar_usuario_vazio_retorna_erro() {
1076 let mut c = cfg_valida();
1077 c.usuario = String::new();
1078 assert!(c.validar().is_err());
1079 }
1080
1081 #[test]
1082 fn validar_configuracao_correta_retorna_ok() {
1083 assert!(cfg_valida().validar().is_ok());
1084 }
1085
1086 #[test]
1087 fn debug_nao_expoe_senha() {
1088 let c = cfg_valida();
1089 let dbg = format!("{c:?}");
1090 assert!(!dbg.contains("senha-exemplo"));
1091 assert!(dbg.contains("redacted"));
1092 }
1093
1094 #[test]
1095 fn truncar_utf8_nao_trunca_se_cabe() {
1096 let (s, t) = truncar_utf8("ola mundo", 100);
1097 assert_eq!(s, "ola mundo");
1098 assert!(!t);
1099 }
1100
1101 #[test]
1102 fn truncar_utf8_trunca_string_grande_ascii() {
1103 let entrada: String = "a".repeat(200);
1104 let (s, t) = truncar_utf8(&entrada, 50);
1105 assert_eq!(s.chars().count(), 50);
1106 assert!(t);
1107 }
1108
1109 #[test]
1110 fn truncar_utf8_preserva_grafemas_acentuados() {
1111 let entrada: String = "á".repeat(30);
1113 let (s, t) = truncar_utf8(&entrada, 10);
1114 assert_eq!(s.chars().count(), 10);
1115 assert_eq!(s.len(), 20);
1117 assert!(t);
1118 assert!(s.chars().all(|c| c == 'á'));
1120 }
1121
1122 #[test]
1123 fn truncar_utf8_com_emojis_nao_quebra() {
1124 let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1125 let (s, t) = truncar_utf8(entrada, 5);
1126 assert_eq!(s.chars().count(), 5);
1127 assert!(t);
1128 }
1129
1130 #[test]
1131 fn truncar_utf8_zero_retorna_vazio() {
1132 let (s, t) = truncar_utf8("abc", 0);
1133 assert_eq!(s, "");
1134 assert!(t);
1135 }
1136
1137 #[test]
1138 fn saida_execucao_debug_nao_crasha() {
1139 let s = SaidaExecucao {
1140 stdout: "ok".into(),
1141 stderr: String::new(),
1142 exit_code: Some(0),
1143 truncado_stdout: false,
1144 truncado_stderr: false,
1145 duracao_ms: 42,
1146 };
1147 let _ = format!("{s:?}");
1148 }
1149
1150 #[test]
1151 fn duracao_ms_tipo_compativel() {
1152 let fake: u64 = 1234;
1154 assert_eq!(fake, 1234_u64);
1155 }
1156}