1use crate::erros::{ErroSshCli, ResultadoSshCli};
8use secrecy::{ExposeSecret, SecretString};
9use std::path::PathBuf;
10use tokio::io::{AsyncRead, AsyncWrite};
11
12#[derive(Clone)]
17pub struct ConfiguracaoConexao {
18 pub host: String,
20 pub porta: u16,
22 pub usuario: String,
24 pub senha: SecretString,
26 pub key_path: Option<String>,
28 pub key_passphrase: Option<SecretString>,
30 pub timeout_ms: u64,
32 pub known_hosts_path: Option<PathBuf>,
34 pub replace_host_key: bool,
36}
37
38impl std::fmt::Debug for ConfiguracaoConexao {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 f.debug_struct("ConfiguracaoConexao")
41 .field("host", &self.host)
42 .field("porta", &self.porta)
43 .field("usuario", &self.usuario)
44 .field("senha", &"<redacted>")
45 .field("key_path", &self.key_path)
46 .field(
47 "key_passphrase",
48 &self.key_passphrase.as_ref().map(|_| "<redacted>"),
49 )
50 .field("timeout_ms", &self.timeout_ms)
51 .field("known_hosts_path", &self.known_hosts_path)
52 .field("replace_host_key", &self.replace_host_key)
53 .finish()
54 }
55}
56
57impl ConfiguracaoConexao {
58 pub fn validar(&self) -> ResultadoSshCli<()> {
60 if self.host.trim().is_empty() {
61 return Err(ErroSshCli::ArgumentoInvalido(
62 "host vazio em ConfiguracaoConexao".to_string(),
63 ));
64 }
65 if self.porta == 0 {
66 return Err(ErroSshCli::ArgumentoInvalido(
67 "porta 0 inválida em ConfiguracaoConexao".to_string(),
68 ));
69 }
70 if self.usuario.trim().is_empty() {
71 return Err(ErroSshCli::ArgumentoInvalido(
72 "usuário vazio em ConfiguracaoConexao".to_string(),
73 ));
74 }
75 let tem_senha = !self.senha.expose_secret().is_empty();
76 let tem_key = self.key_path.as_ref().is_some_and(|p| !p.trim().is_empty());
77 if !tem_senha && !tem_key {
78 return Err(ErroSshCli::ArgumentoInvalido(
79 "auth exige senha ou key_path".to_string(),
80 ));
81 }
82 Ok(())
83 }
84}
85
86#[derive(Debug, Clone)]
88pub struct SaidaExecucao {
89 pub stdout: String,
91 pub stderr: String,
93 pub exit_code: Option<i32>,
95 pub truncado_stdout: bool,
97 pub truncado_stderr: bool,
99 pub duracao_ms: u64,
101}
102
103#[derive(Debug, Clone)]
105pub struct TransferenciaResultado {
106 pub bytes_transferidos: u64,
108 pub duracao_ms: u64,
110}
111
112#[must_use]
117pub fn truncar_utf8(conteudo: &str, max_chars: usize) -> (String, bool) {
118 let total = conteudo.chars().count();
119 if total <= max_chars {
120 return (conteudo.to_string(), false);
121 }
122 let truncado: String = conteudo.chars().take(max_chars).collect();
123 (truncado, true)
124}
125
126use async_trait::async_trait;
131use std::path::Path;
132
133pub trait CanalTunel: AsyncRead + AsyncWrite + Unpin + Send {}
135
136impl<T> CanalTunel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
137
138#[async_trait]
143pub trait ClienteSshTrait: Send + Sync + 'static {
144 async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>
146 where
147 Self: Sized;
148
149 async fn executar_comando(
154 &mut self,
155 cmd: &str,
156 max_chars: usize,
157 stdin_data: Option<Vec<u8>>,
158 ) -> Result<SaidaExecucao, ErroSshCli>;
159
160 async fn upload(
162 &mut self,
163 local: &Path,
164 remote: &Path,
165 ) -> Result<TransferenciaResultado, ErroSshCli>;
166
167 async fn download(
169 &mut self,
170 remote: &Path,
171 local: &Path,
172 ) -> Result<TransferenciaResultado, ErroSshCli>;
173
174 async fn abrir_canal_tunel(
176 &self,
177 host_remoto: &str,
178 porta_remota: u16,
179 endereco_origem: &str,
180 porta_origem: u16,
181 ) -> Result<Box<dyn CanalTunel>, ErroSshCli>;
182
183 async fn desconectar(&self) -> Result<(), ErroSshCli>;
185}
186
187#[cfg(test)]
188pub mod mocks {
190 use super::*;
191 use mockall::mock;
192
193 mock! {
194 pub ClienteSsh {}
195
196 #[async_trait]
197 impl crate::ssh::cliente::ClienteSshTrait for ClienteSsh {
198 async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>;
199 async fn executar_comando(&mut self, cmd: &str, max_chars: usize, stdin_data: Option<Vec<u8>>) -> Result<SaidaExecucao, ErroSshCli>;
200 async fn upload(&mut self, local: &Path, remote: &Path) -> Result<TransferenciaResultado, ErroSshCli>;
201 async fn download(&mut self, remote: &Path, local: &Path) -> Result<TransferenciaResultado, ErroSshCli>;
202 async fn abrir_canal_tunel(
203 &self,
204 host_remoto: &str,
205 porta_remota: u16,
206 endereco_origem: &str,
207 porta_origem: u16,
208 ) -> Result<Box<dyn CanalTunel>, ErroSshCli>;
209 async fn desconectar(&self) -> Result<(), ErroSshCli>;
210 }
211 }
212}
213
214#[cfg(feature = "ssh-real")]
219mod real {
220 use super::{
221 CanalTunel, ClienteSshTrait, ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado,
222 };
223 use crate::erros::{ErroSshCli, ResultadoSshCli};
224 use async_trait::async_trait;
225 use secrecy::ExposeSecret;
226 use std::path::Path;
227 use std::sync::Arc;
228 use std::time::{Duration, Instant};
229
230 pub struct ManipuladorCliente {
232 host: String,
233 porta: u16,
234 known_hosts_path: Option<std::path::PathBuf>,
235 replace_host_key: bool,
236 host_key_rejeitada: bool,
238 detalhe_host_key: Option<String>,
239 }
240
241 impl ManipuladorCliente {
242 fn novo(cfg: &ConfiguracaoConexao) -> Self {
243 Self {
244 host: cfg.host.clone(),
245 porta: cfg.porta,
246 known_hosts_path: cfg.known_hosts_path.clone(),
247 replace_host_key: cfg.replace_host_key,
248 host_key_rejeitada: false,
249 detalhe_host_key: None,
250 }
251 }
252 }
253
254 impl russh::client::Handler for ManipuladorCliente {
255 type Error = russh::Error;
256
257 async fn check_server_key(
258 &mut self,
259 chave_servidor: &russh::keys::ssh_key::PublicKey,
260 ) -> Result<bool, Self::Error> {
261 let fingerprint = format!(
262 "{}",
263 chave_servidor.fingerprint(russh::keys::HashAlg::Sha256)
264 );
265
266 let Some(path) = self.known_hosts_path.clone() else {
267 tracing::warn!("known_hosts ausente: aceitando host key (modo teste)");
268 return Ok(true);
269 };
270
271 let mut kh = match crate::ssh::known_hosts::KnownHosts::carregar(path) {
272 Ok(k) => k,
273 Err(e) => {
274 tracing::error!(erro = %e, "falha ao carregar known_hosts");
275 self.host_key_rejeitada = true;
276 self.detalhe_host_key = Some(e.to_string());
277 return Ok(false);
278 }
279 };
280
281 match crate::ssh::known_hosts::verificar_tofu(
282 &mut kh,
283 &self.host,
284 self.porta,
285 &fingerprint,
286 self.replace_host_key,
287 ) {
288 Ok(true) => Ok(true),
289 Ok(false) => Ok(false),
290 Err(e) => {
291 self.host_key_rejeitada = true;
292 self.detalhe_host_key = Some(e.to_string());
293 tracing::error!(erro = %e, "host key rejeitada");
294 Ok(false)
295 }
296 }
297 }
298 }
299
300 pub struct ClienteSsh {
302 pub sessao: russh::client::Handle<ManipuladorCliente>,
304 cfg: ConfiguracaoConexao,
305 }
306
307 impl std::fmt::Debug for ClienteSsh {
308 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309 f.debug_struct("ClienteSsh")
310 .field("host", &self.cfg.host)
311 .field("porta", &self.cfg.porta)
312 .field("usuario", &self.cfg.usuario)
313 .field("timeout_ms", &self.cfg.timeout_ms)
314 .finish()
315 }
316 }
317
318 fn mapear_exit_status(exit_status: u32) -> i32 {
319 i32::try_from(exit_status).unwrap_or(-1)
320 }
321
322 fn processar_mensagem_exec(
323 msg: russh::ChannelMsg,
324 stdout_bytes: &mut Vec<u8>,
325 stderr_bytes: &mut Vec<u8>,
326 exit_code: &mut Option<i32>,
327 ) -> bool {
328 use russh::ChannelMsg;
329
330 match msg {
331 ChannelMsg::Data { data } => {
332 stdout_bytes.extend_from_slice(&data);
333 }
334 ChannelMsg::ExtendedData { data, ext } => {
335 if ext == 1 {
337 stderr_bytes.extend_from_slice(&data);
338 } else {
339 tracing::debug!(ext, "dados estendidos ignorados");
340 }
341 }
342 ChannelMsg::ExitStatus { exit_status } => {
343 *exit_code = Some(mapear_exit_status(exit_status));
347 }
349 ChannelMsg::ExitSignal {
350 signal_name,
351 core_dumped,
352 error_message,
353 ..
354 } => {
355 tracing::warn!(
356 ?signal_name,
357 core_dumped,
358 %error_message,
359 "processo remoto terminou por sinal"
360 );
361 }
363 ChannelMsg::Eof => {
364 tracing::debug!("EOF no canal SSH");
365 }
366 ChannelMsg::Close => {
367 tracing::debug!("canal SSH fechado pelo servidor");
368 return true;
369 }
370 _ => {}
371 }
372
373 false
374 }
375
376 fn formatar_header_upload_scp(tamanho: u64, nome_arquivo: &str) -> String {
377 format!("C0644 {} {}\\n", tamanho, nome_arquivo)
378 }
379
380 fn parse_header_scp(header: &str) -> ResultadoSshCli<u64> {
381 let header = header.trim();
382
383 if !header.starts_with('C') {
384 return Err(ErroSshCli::CanalFalhou(format!(
385 "header SCP inesperado: {}",
386 header
387 )));
388 }
389
390 let partes: Vec<&str> = header.split_whitespace().collect();
391 if partes.len() < 3 {
392 return Err(ErroSshCli::CanalFalhou(format!(
393 "header SCP mal formatado: {}",
394 header
395 )));
396 }
397
398 partes[1].parse().map_err(|_| {
399 ErroSshCli::CanalFalhou(format!("tamanho inválido no header: {}", partes[1]))
400 })
401 }
402
403 impl ClienteSsh {
404 pub async fn conectar(cfg: ConfiguracaoConexao) -> ResultadoSshCli<Self> {
414 cfg.validar()?;
415
416 let timeout = Duration::from_millis(cfg.timeout_ms);
417 let host = cfg.host.clone();
418 let porta = cfg.porta;
419 let usuario = cfg.usuario.clone();
420 let senha_segura = cfg.senha.clone();
421 let key_path = cfg.key_path.clone();
422 let key_passphrase = cfg.key_passphrase.clone();
423 let handler = ManipuladorCliente::novo(&cfg);
424
425 let config_cliente = Arc::new(russh::client::Config {
426 inactivity_timeout: Some(timeout),
427 ..Default::default()
428 });
429
430 tracing::info!(
431 host = %host,
432 porta,
433 usuario = %usuario,
434 timeout_ms = cfg.timeout_ms,
435 tem_chave = key_path.is_some(),
436 "iniciando conexão SSH"
437 );
438
439 let resultado_conexao = tokio::time::timeout(timeout, async move {
440 let mut sessao = russh::client::connect(
441 config_cliente,
442 (host.as_str(), porta),
443 handler,
444 )
445 .await
446 .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha TCP/handshake: {e}")))?;
447
448 let mut autenticado = false;
450
451 if let Some(ref kp) = key_path {
452 let pass = key_passphrase
453 .as_ref()
454 .map(|s| s.expose_secret().to_string());
455 let chave = russh::keys::load_secret_key(kp, pass.as_deref()).map_err(|e| {
456 ErroSshCli::AutenticacaoSsh(format!(
457 "falha ao carregar chave {kp}: {e}"
458 ))
459 })?;
460 let hash = sessao
461 .best_supported_rsa_hash()
462 .await
463 .map_err(|e| {
464 ErroSshCli::ConexaoFalhou(format!("rsa hash: {e}"))
465 })?
466 .flatten();
467 let auth = sessao
468 .authenticate_publickey(
469 usuario.clone(),
470 russh::keys::PrivateKeyWithHashAlg::new(Arc::new(chave), hash),
471 )
472 .await
473 .map_err(|e| {
474 ErroSshCli::ConexaoFalhou(format!("falha auth publickey: {e}"))
475 })?;
476 autenticado = auth.success();
477 if !autenticado {
478 tracing::warn!(host = %host, "auth por chave rejeitada; tentando senha se houver");
479 }
480 }
481
482 if !autenticado && !senha_segura.expose_secret().is_empty() {
483 let auth = sessao
484 .authenticate_password(usuario.clone(), senha_segura.expose_secret())
485 .await
486 .map_err(|e| {
487 ErroSshCli::ConexaoFalhou(format!("falha auth password: {e}"))
488 })?;
489 autenticado = auth.success();
490 }
491
492 if !autenticado {
493 tracing::warn!(host = %host, usuario = %usuario, "autenticação SSH rejeitada");
494 return Err(ErroSshCli::AutenticacaoFalhou);
495 }
496
497 Ok::<_, ErroSshCli>(sessao)
498 })
499 .await;
500
501 let sessao = match resultado_conexao {
502 Ok(Ok(s)) => s,
503 Ok(Err(erro)) => return Err(erro),
504 Err(_) => return Err(ErroSshCli::TimeoutSsh(cfg.timeout_ms)),
505 };
506
507 tracing::info!("conexão SSH autenticada com sucesso");
508
509 Ok(Self { sessao, cfg })
510 }
511
512 pub async fn executar_comando(
521 &mut self,
522 comando: &str,
523 max_chars: usize,
524 stdin_data: Option<Vec<u8>>,
525 ) -> ResultadoSshCli<SaidaExecucao> {
526 self.executar_comando_interno(comando, max_chars, true, stdin_data)
527 .await
528 }
529
530 async fn executar_comando_interno(
531 &mut self,
532 comando: &str,
533 max_chars: usize,
534 abort_em_timeout: bool,
535 stdin_data: Option<Vec<u8>>,
536 ) -> ResultadoSshCli<SaidaExecucao> {
537 let inicio = Instant::now();
538 let timeout = Duration::from_millis(self.cfg.timeout_ms);
539
540 let resultado = tokio::time::timeout(timeout, async {
541 let mut canal = self
542 .sessao
543 .channel_open_session()
544 .await
545 .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão: {e}")))?;
546
547 canal
548 .exec(true, comando)
549 .await
550 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec: {e}")))?;
551
552 if let Some(bytes) = stdin_data.as_ref() {
554 canal
555 .data(&bytes[..])
556 .await
557 .map_err(|e| ErroSshCli::CanalFalhou(format!("stdin canal: {e}")))?;
558 canal
559 .eof()
560 .await
561 .map_err(|e| ErroSshCli::CanalFalhou(format!("eof canal: {e}")))?;
562 }
563
564 let mut stdout_bytes: Vec<u8> = Vec::new();
565 let mut stderr_bytes: Vec<u8> = Vec::new();
566 let mut exit_code: Option<i32> = None;
567
568 while let Some(msg) = canal.wait().await {
569 if processar_mensagem_exec(
570 msg,
571 &mut stdout_bytes,
572 &mut stderr_bytes,
573 &mut exit_code,
574 ) {
575 break;
576 }
577 }
578
579 Ok::<_, ErroSshCli>((stdout_bytes, stderr_bytes, exit_code))
580 })
581 .await;
582
583 let (stdout_bytes, stderr_bytes, exit_code) = match resultado {
584 Ok(Ok(t)) => t,
585 Ok(Err(erro)) => return Err(erro),
586 Err(_) => {
587 if abort_em_timeout {
588 if let Some(padrao) = crate::ssh::packing::padrao_abort_remoto(comando) {
589 let abort_cmd = crate::ssh::packing::empacotar_abort_pkill(&padrao);
590 tracing::warn!(
591 padrao = %padrao,
592 "timeout local; tentando abort remoto best-effort"
593 );
594 let _ = self.tentar_abort_remoto(&abort_cmd).await;
595 }
596 }
597 return Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms));
598 }
599 };
600
601 let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
602 let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
603
604 let (stdout_truncado, truncado_stdout) = super::truncar_utf8(&stdout_str, max_chars);
605 let (stderr_truncado, truncado_stderr) = super::truncar_utf8(&stderr_str, max_chars);
606
607 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
608
609 Ok(SaidaExecucao {
610 stdout: stdout_truncado,
611 stderr: stderr_truncado,
612 exit_code,
613 truncado_stdout,
614 truncado_stderr,
615 duracao_ms,
616 })
617 }
618
619 pub async fn upload(
626 &mut self,
627 local: &std::path::Path,
628 remote: &std::path::Path,
629 ) -> ResultadoSshCli<TransferenciaResultado> {
630 use russh::ChannelMsg;
631 use std::time::Instant;
632
633 let local_str = local.display().to_string();
634 let remote_str = remote.display().to_string();
635
636 let metadados = std::fs::metadata(local).map_err(|e| {
637 if e.kind() == std::io::ErrorKind::NotFound {
638 ErroSshCli::ArquivoNaoEncontrado(local_str.clone())
639 } else {
640 ErroSshCli::Io(e)
641 }
642 })?;
643
644 if !metadados.is_file() {
645 return Err(ErroSshCli::ArgumentoInvalido(
646 "upload só suporta arquivos regulares".to_string(),
647 ));
648 }
649
650 let tamanho = metadados.len();
651 let nome_arquivo = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
652
653 let inicio = Instant::now();
654 let timeout = Duration::from_millis(self.cfg.timeout_ms);
655
656 let resultado =
657 tokio::time::timeout(timeout, async {
658 let mut canal =
659 self.sessao.channel_open_session().await.map_err(|e| {
660 ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}"))
661 })?;
662
663 let comando = format!("scp -t -p {}", remote_str);
664 canal
665 .exec(true, comando.as_str())
666 .await
667 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
668
669 canal.wait().await.ok_or_else(|| {
670 ErroSshCli::CanalFalhou("canal fechou prematuramente".to_string())
671 })?;
672
673 let resposta = formatar_header_upload_scp(tamanho, nome_arquivo);
674 canal
675 .data(resposta.as_bytes())
676 .await
677 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar header SCP: {e}")))?;
678
679 canal.wait().await.ok_or_else(|| {
680 ErroSshCli::CanalFalhou("canal fechou durante header".to_string())
681 })?;
682
683 let conteudo = std::fs::read(local).map_err(ErroSshCli::Io)?;
684 let mut offset = 0;
685 let tamanho_bloco = 32768;
686
687 while offset < conteudo.len() {
688 let fim = std::cmp::min(offset + tamanho_bloco, conteudo.len());
689 let bloco = &conteudo[offset..fim];
690 canal.data(bloco).await.map_err(|e| {
691 ErroSshCli::CanalFalhou(format!("enviar bloco SCP: {e}"))
692 })?;
693 offset = fim;
694 }
695
696 canal
697 .data(&[] as &[u8])
698 .await
699 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar EOF SCP: {e}")))?;
700
701 canal.wait().await.ok_or_else(|| {
702 ErroSshCli::CanalFalhou("canal fechou durante transferência".to_string())
703 })?;
704
705 while let Some(msg) = canal.wait().await {
706 if let ChannelMsg::Close = msg {
707 break;
708 }
709 }
710
711 Ok::<_, ErroSshCli>(())
712 })
713 .await;
714
715 resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
716
717 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
718
719 Ok(TransferenciaResultado {
720 bytes_transferidos: tamanho,
721 duracao_ms,
722 })
723 }
724
725 pub async fn download(
732 &mut self,
733 remote: &std::path::Path,
734 local: &std::path::Path,
735 ) -> ResultadoSshCli<TransferenciaResultado> {
736 use russh::ChannelMsg;
737 use std::io::Write;
738 use std::time::Instant;
739
740 let remote_str = remote.display().to_string();
741
742 let inicio = Instant::now();
743 let timeout = Duration::from_millis(self.cfg.timeout_ms);
744
745 let resultado = tokio::time::timeout(timeout, async {
746 let mut canal = self
747 .sessao
748 .channel_open_session()
749 .await
750 .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}")))?;
751
752 let comando = format!("scp -f -p {}", remote_str);
753 canal
754 .exec(true, comando.as_str())
755 .await
756 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
757
758 canal
759 .data(&[] as &[u8])
760 .await
761 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack inicial: {e}")))?;
762
763 let mut msg = canal.wait().await.ok_or_else(|| {
764 ErroSshCli::CanalFalhou("canal fechou esperando header".to_string())
765 })?;
766
767 let ChannelMsg::Data { data } = msg else {
768 return Err(ErroSshCli::CanalFalhou(
769 "esperava dados do servidor".to_string(),
770 ));
771 };
772
773 let header = String::from_utf8_lossy(&data);
774 let tamanho = parse_header_scp(&header)?;
775
776 canal
777 .data(&[] as &[u8])
778 .await
779 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack: {e}")))?;
780
781 if let Some(pai) = local.parent() {
782 std::fs::create_dir_all(pai)?;
783 }
784
785 let mut arquivo = std::fs::File::create(local).map_err(ErroSshCli::Io)?;
786 let mut recebidos: u64 = 0;
787
788 while recebidos < tamanho {
789 msg = canal.wait().await.ok_or_else(|| {
790 ErroSshCli::CanalFalhou("canal fechou durante download".to_string())
791 })?;
792
793 let ChannelMsg::Data { data } = msg else {
794 continue;
795 };
796
797 let bytes = data.as_ref();
798 if bytes.is_empty() {
799 continue;
800 }
801
802 arquivo.write_all(bytes).map_err(ErroSshCli::Io)?;
803 recebidos += bytes.len() as u64;
804
805 canal.data(&[] as &[u8]).await.map_err(|e| {
806 ErroSshCli::CanalFalhou(format!("enviar ack durante download: {e}"))
807 })?;
808 }
809
810 while let Some(msg) = canal.wait().await {
811 if let ChannelMsg::Close = msg {
812 break;
813 }
814 }
815
816 Ok::<_, ErroSshCli>(recebidos)
817 })
818 .await;
819
820 let recebidos =
821 resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
822
823 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
824
825 Ok(TransferenciaResultado {
826 bytes_transferidos: recebidos,
827 duracao_ms,
828 })
829 }
830
831 async fn tentar_abort_remoto(&self, abort_cmd: &str) -> ResultadoSshCli<()> {
833 let mut cfg_abort = self.cfg.clone();
836 cfg_abort.timeout_ms = cfg_abort.timeout_ms.clamp(3_000, 10_000);
837 let outro = match Self::conectar(cfg_abort).await {
838 Ok(c) => c,
839 Err(e) => {
840 tracing::debug!(erro = %e, "abort remoto não pôde reconectar");
841 return Err(e);
842 }
843 };
844 let timeout = Duration::from_millis(outro.cfg.timeout_ms);
845 let _ = tokio::time::timeout(timeout, async {
846 let mut canal = outro
847 .sessao
848 .channel_open_session()
849 .await
850 .map_err(|e| ErroSshCli::CanalFalhou(format!("abort canal: {e}")))?;
851 canal
852 .exec(true, abort_cmd)
853 .await
854 .map_err(|e| ErroSshCli::CanalFalhou(format!("abort exec: {e}")))?;
855 while let Some(msg) = canal.wait().await {
856 if matches!(msg, russh::ChannelMsg::Close) {
857 break;
858 }
859 }
860 Ok::<(), ErroSshCli>(())
861 })
862 .await;
863 let _ = outro.desconectar().await;
864 Ok(())
865 }
866
867 pub async fn desconectar(&self) -> ResultadoSshCli<()> {
872 let resultado = self
873 .sessao
874 .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
875 .await;
876 match resultado {
877 Ok(()) => {
878 tracing::info!("sessão SSH encerrada");
879 Ok(())
880 }
881 Err(e) => {
882 tracing::warn!(erro = %e, "falha ao encerrar sessão SSH");
883 Err(ErroSshCli::ConexaoFalhou(format!(
884 "falha ao desconectar: {e}"
885 )))
886 }
887 }
888 }
889
890 pub async fn abrir_canal_tunel(
892 &self,
893 host_remoto: &str,
894 porta_remota: u16,
895 endereco_origem: &str,
896 porta_origem: u16,
897 ) -> ResultadoSshCli<Box<dyn CanalTunel>> {
898 let canal = self
899 .sessao
900 .channel_open_direct_tcpip(
901 host_remoto.to_string(),
902 u32::from(porta_remota),
903 endereco_origem.to_string(),
904 u32::from(porta_origem),
905 )
906 .await
907 .map_err(|e| {
908 ErroSshCli::CanalFalhou(format!(
909 "falha ao abrir canal direct-tcpip para {}:{}: {}",
910 host_remoto, porta_remota, e
911 ))
912 })?;
913
914 Ok(Box::new(canal.into_stream()))
915 }
916 }
917
918 #[async_trait]
919 impl ClienteSshTrait for ClienteSsh {
920 async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
921 Self::conectar(cfg).await.map(Box::new)
922 }
923
924 async fn executar_comando(
925 &mut self,
926 cmd: &str,
927 max_chars: usize,
928 stdin_data: Option<Vec<u8>>,
929 ) -> Result<SaidaExecucao, ErroSshCli> {
930 Self::executar_comando(self, cmd, max_chars, stdin_data).await
931 }
932
933 async fn upload(
934 &mut self,
935 local: &Path,
936 remote: &Path,
937 ) -> Result<TransferenciaResultado, ErroSshCli> {
938 Self::upload(self, local, remote).await
939 }
940
941 async fn download(
942 &mut self,
943 remote: &Path,
944 local: &Path,
945 ) -> Result<TransferenciaResultado, ErroSshCli> {
946 Self::download(self, remote, local).await
947 }
948
949 async fn abrir_canal_tunel(
950 &self,
951 host_remoto: &str,
952 porta_remota: u16,
953 endereco_origem: &str,
954 porta_origem: u16,
955 ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
956 Self::abrir_canal_tunel(
957 self,
958 host_remoto,
959 porta_remota,
960 endereco_origem,
961 porta_origem,
962 )
963 .await
964 }
965
966 async fn desconectar(&self) -> Result<(), ErroSshCli> {
967 Self::desconectar(self).await
968 }
969 }
970
971 #[cfg(test)]
972 mod testes_real {
973 use super::{
974 formatar_header_upload_scp, mapear_exit_status, parse_header_scp,
975 processar_mensagem_exec,
976 };
977
978 #[test]
979 fn mapear_exit_status_normal() {
980 assert_eq!(mapear_exit_status(0), 0);
981 assert_eq!(mapear_exit_status(255), 255);
982 }
983
984 #[test]
985 fn mapear_exit_status_overflow_retorna_menos_um() {
986 assert_eq!(mapear_exit_status(u32::MAX), -1);
987 }
988
989 #[test]
990 fn parse_header_scp_valido_retorna_tamanho() {
991 let tamanho = parse_header_scp("C0644 42 arquivo.txt\n").expect("header válido");
992 assert_eq!(tamanho, 42);
993 }
994
995 #[test]
996 fn parse_header_scp_invalido_retorna_erro() {
997 assert!(parse_header_scp("ERRO").is_err());
998 assert!(parse_header_scp("C0644 sem_tamanho").is_err());
999 assert!(parse_header_scp("C0644 abc arquivo").is_err());
1000 }
1001
1002 #[test]
1003 fn processar_mensagem_exec_trata_stdout_stderr_e_close() {
1004 let mut stdout = Vec::new();
1005 let mut stderr = Vec::new();
1006 let mut exit_code = None;
1007
1008 let deve_parar = processar_mensagem_exec(
1009 russh::ChannelMsg::Data {
1010 data: b"stdout".to_vec().into(),
1011 },
1012 &mut stdout,
1013 &mut stderr,
1014 &mut exit_code,
1015 );
1016 assert!(!deve_parar);
1017 assert_eq!(stdout, b"stdout");
1018
1019 let deve_parar = processar_mensagem_exec(
1020 russh::ChannelMsg::ExtendedData {
1021 data: b"stderr".to_vec().into(),
1022 ext: 1,
1023 },
1024 &mut stdout,
1025 &mut stderr,
1026 &mut exit_code,
1027 );
1028 assert!(!deve_parar);
1029 assert_eq!(stderr, b"stderr");
1030
1031 let _ = processar_mensagem_exec(
1032 russh::ChannelMsg::ExitStatus { exit_status: 17 },
1033 &mut stdout,
1034 &mut stderr,
1035 &mut exit_code,
1036 );
1037 assert_eq!(exit_code, Some(17));
1038
1039 let deve_parar = processar_mensagem_exec(
1040 russh::ChannelMsg::Close,
1041 &mut stdout,
1042 &mut stderr,
1043 &mut exit_code,
1044 );
1045 assert!(deve_parar);
1046 }
1047
1048 #[test]
1049 fn formatar_header_upload_scp_gera_formato_esperado() {
1050 let header = formatar_header_upload_scp(123, "arquivo.txt");
1051 assert_eq!(header, "C0644 123 arquivo.txt\\n");
1052 }
1053
1054 #[test]
1055 fn processar_mensagem_exec_ignora_extendido_com_codigo_diferente_de_stderr() {
1056 let mut stdout = Vec::new();
1057 let mut stderr = Vec::new();
1058 let mut exit_code = None;
1059
1060 let deve_parar = processar_mensagem_exec(
1061 russh::ChannelMsg::ExtendedData {
1062 data: b"nao-e-stderr".to_vec().into(),
1063 ext: 2,
1064 },
1065 &mut stdout,
1066 &mut stderr,
1067 &mut exit_code,
1068 );
1069
1070 assert!(!deve_parar);
1071 assert!(stdout.is_empty());
1072 assert!(stderr.is_empty());
1073 assert!(exit_code.is_none());
1074 }
1075
1076 #[test]
1077 fn processar_mensagem_exec_trata_exit_signal_e_eof_sem_encerrar_loop() {
1078 let mut stdout = Vec::new();
1079 let mut stderr = Vec::new();
1080 let mut exit_code = Some(7);
1081
1082 let deve_parar_signal = processar_mensagem_exec(
1083 russh::ChannelMsg::ExitSignal {
1084 signal_name: russh::Sig::TERM,
1085 core_dumped: false,
1086 error_message: "encerrado".to_string(),
1087 lang_tag: "pt-BR".to_string(),
1088 },
1089 &mut stdout,
1090 &mut stderr,
1091 &mut exit_code,
1092 );
1093
1094 let deve_parar_eof = processar_mensagem_exec(
1095 russh::ChannelMsg::Eof,
1096 &mut stdout,
1097 &mut stderr,
1098 &mut exit_code,
1099 );
1100
1101 assert!(!deve_parar_signal);
1102 assert!(!deve_parar_eof);
1103 assert_eq!(exit_code, Some(7));
1104 }
1105
1106 #[test]
1107 fn processar_mensagem_exec_ignora_variantes_sem_tratamento_especifico() {
1108 let mut stdout = Vec::new();
1109 let mut stderr = Vec::new();
1110 let mut exit_code = None;
1111
1112 let deve_parar = processar_mensagem_exec(
1113 russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
1114 &mut stdout,
1115 &mut stderr,
1116 &mut exit_code,
1117 );
1118
1119 assert!(!deve_parar);
1120 assert!(stdout.is_empty());
1121 assert!(stderr.is_empty());
1122 assert!(exit_code.is_none());
1123 }
1124 }
1125}
1126
1127#[cfg(feature = "ssh-real")]
1128pub use real::{ClienteSsh, ManipuladorCliente};
1129
1130#[cfg(not(feature = "ssh-real"))]
1135mod stub {
1136 use super::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
1137 use crate::erros::ErroSshCli;
1138 use crate::ssh::cliente::ClienteSshTrait;
1139 use async_trait::async_trait;
1140 use std::path::Path;
1141
1142 #[derive(Debug)]
1145 pub struct ClienteSsh;
1146
1147 #[async_trait]
1148 impl ClienteSshTrait for ClienteSsh {
1149 async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
1150 Err(ErroSshCli::ConexaoFalhou(
1151 "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
1152 ))
1153 }
1154
1155 async fn executar_comando(
1156 &mut self,
1157 _cmd: &str,
1158 _max_chars: usize,
1159 _stdin_data: Option<Vec<u8>>,
1160 ) -> Result<SaidaExecucao, ErroSshCli> {
1161 Err(ErroSshCli::CanalFalhou(
1162 "stub sem russh: feature `ssh-real` desabilitada".into(),
1163 ))
1164 }
1165
1166 async fn upload(
1167 &mut self,
1168 _local: &Path,
1169 _remote: &Path,
1170 ) -> Result<TransferenciaResultado, ErroSshCli> {
1171 Err(ErroSshCli::CanalFalhou(
1172 "stub sem russh: feature `ssh-real` desabilitada".into(),
1173 ))
1174 }
1175
1176 async fn download(
1177 &mut self,
1178 _remote: &Path,
1179 _local: &Path,
1180 ) -> Result<TransferenciaResultado, ErroSshCli> {
1181 Err(ErroSshCli::CanalFalhou(
1182 "stub sem russh: feature `ssh-real` desabilitada".into(),
1183 ))
1184 }
1185
1186 async fn abrir_canal_tunel(
1187 &self,
1188 _host_remoto: &str,
1189 _porta_remota: u16,
1190 _endereco_origem: &str,
1191 _porta_origem: u16,
1192 ) -> Result<Box<dyn super::CanalTunel>, ErroSshCli> {
1193 Err(ErroSshCli::CanalFalhou(
1194 "stub sem russh: feature `ssh-real` desabilitada".into(),
1195 ))
1196 }
1197
1198 async fn desconectar(&self) -> Result<(), ErroSshCli> {
1199 Ok(())
1200 }
1201 }
1202}
1203
1204#[cfg(not(feature = "ssh-real"))]
1205pub use stub::ClienteSsh;
1206
1207#[cfg(test)]
1212mod testes {
1213 use super::*;
1214 use secrecy::SecretString;
1215
1216 fn cfg_valida() -> ConfiguracaoConexao {
1217 ConfiguracaoConexao {
1218 host: "127.0.0.1".to_string(),
1219 porta: 22,
1220 usuario: "root".to_string(),
1221 senha: SecretString::from("senha-exemplo".to_string()),
1222 key_path: None,
1223 key_passphrase: None,
1224 timeout_ms: 5000,
1225 known_hosts_path: None,
1226 replace_host_key: false,
1227 }
1228 }
1229
1230 #[test]
1231 fn validar_host_vazio_retorna_erro() {
1232 let mut c = cfg_valida();
1233 c.host = String::new();
1234 let r = c.validar();
1235 assert!(r.is_err());
1236 let msg = r.unwrap_err().to_string();
1237 assert!(msg.contains("host"));
1238 }
1239
1240 #[test]
1241 fn validar_host_apenas_espacos_retorna_erro() {
1242 let mut c = cfg_valida();
1243 c.host = " ".to_string();
1244 assert!(c.validar().is_err());
1245 }
1246
1247 #[test]
1248 fn validar_porta_zero_retorna_erro() {
1249 let mut c = cfg_valida();
1250 c.porta = 0;
1251 let r = c.validar();
1252 assert!(r.is_err());
1253 let msg = r.unwrap_err().to_string();
1254 assert!(msg.contains("porta"));
1255 }
1256
1257 #[test]
1258 fn validar_usuario_vazio_retorna_erro() {
1259 let mut c = cfg_valida();
1260 c.usuario = String::new();
1261 assert!(c.validar().is_err());
1262 }
1263
1264 #[test]
1265 fn validar_configuracao_correta_retorna_ok() {
1266 assert!(cfg_valida().validar().is_ok());
1267 }
1268
1269 #[test]
1270 fn debug_nao_expoe_senha() {
1271 let c = cfg_valida();
1272 let dbg = format!("{c:?}");
1273 assert!(!dbg.contains("senha-exemplo"));
1274 assert!(dbg.contains("redacted"));
1275 }
1276
1277 #[test]
1278 fn truncar_utf8_nao_trunca_se_cabe() {
1279 let (s, t) = truncar_utf8("ola mundo", 100);
1280 assert_eq!(s, "ola mundo");
1281 assert!(!t);
1282 }
1283
1284 #[test]
1285 fn truncar_utf8_trunca_string_grande_ascii() {
1286 let entrada: String = "a".repeat(200);
1287 let (s, t) = truncar_utf8(&entrada, 50);
1288 assert_eq!(s.chars().count(), 50);
1289 assert!(t);
1290 }
1291
1292 #[test]
1293 fn truncar_utf8_preserva_grafemas_acentuados() {
1294 let entrada: String = "á".repeat(30);
1296 let (s, t) = truncar_utf8(&entrada, 10);
1297 assert_eq!(s.chars().count(), 10);
1298 assert_eq!(s.len(), 20);
1300 assert!(t);
1301 assert!(s.chars().all(|c| c == 'á'));
1303 }
1304
1305 #[test]
1306 fn truncar_utf8_com_emojis_nao_quebra() {
1307 let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1308 let (s, t) = truncar_utf8(entrada, 5);
1309 assert_eq!(s.chars().count(), 5);
1310 assert!(t);
1311 }
1312
1313 #[test]
1314 fn truncar_utf8_zero_retorna_vazio() {
1315 let (s, t) = truncar_utf8("abc", 0);
1316 assert_eq!(s, "");
1317 assert!(t);
1318 }
1319
1320 #[test]
1321 fn saida_execucao_debug_nao_crasha() {
1322 let s = SaidaExecucao {
1323 stdout: "ok".into(),
1324 stderr: String::new(),
1325 exit_code: Some(0),
1326 truncado_stdout: false,
1327 truncado_stderr: false,
1328 duracao_ms: 42,
1329 };
1330 let _ = format!("{s:?}");
1331 }
1332
1333 #[test]
1334 fn duracao_ms_tipo_compativel() {
1335 let fake: u64 = 1234;
1337 assert_eq!(fake, 1234_u64);
1338 }
1339}