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