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 const SCP_OK: u8 = 0;
378
379 fn basename_scp(nome_arquivo: &str) -> String {
381 nome_arquivo
382 .split(['/', '\\'])
383 .next_back()
384 .unwrap_or("file")
385 .replace(['\n', '\r', '\0'], "_")
386 }
387
388 #[cfg_attr(not(test), allow(dead_code))]
390 fn formatar_header_upload_scp(tamanho: u64, nome_arquivo: &str) -> String {
391 formatar_header_upload_scp_com_modo(0o644, tamanho, nome_arquivo)
392 }
393
394 fn formatar_header_upload_scp_com_modo(mode: u32, tamanho: u64, nome_arquivo: &str) -> String {
396 let nome = basename_scp(nome_arquivo);
397 let mode = mode & 0o7777;
398 format!("C{mode:04o} {tamanho} {nome}\n")
399 }
400
401 fn formatar_linha_t_scp(mtime_secs: u64, atime_secs: u64) -> String {
403 format!("T{mtime_secs} 0 {atime_secs} 0\n")
404 }
405
406 fn parse_linha_t_scp(linha: &str) -> ResultadoSshCli<(u64, u64)> {
408 let linha = linha.trim_end_matches(['\0', '\r', '\n']).trim();
409 if !linha.starts_with('T') {
410 return Err(ErroSshCli::CanalFalhou(format!(
411 "linha T SCP inesperada: {linha}"
412 )));
413 }
414 let resto = &linha[1..];
415 let partes: Vec<&str> = resto.split_whitespace().collect();
416 if partes.len() < 3 {
417 return Err(ErroSshCli::CanalFalhou(format!(
418 "linha T SCP mal formatada: {linha}"
419 )));
420 }
421 let mtime: u64 = partes[0].parse().map_err(|_| {
422 ErroSshCli::CanalFalhou(format!("mtime inválido na linha T: {}", partes[0]))
423 })?;
424 let atime: u64 = partes[2].parse().map_err(|_| {
425 ErroSshCli::CanalFalhou(format!("atime inválido na linha T: {}", partes[2]))
426 })?;
427 Ok((mtime, atime))
428 }
429
430 fn parse_header_scp(header: &str) -> ResultadoSshCli<(u32, u64)> {
432 let header = header.trim_end_matches(['\0', '\r', '\n']).trim();
433
434 if !header.starts_with('C') {
435 return Err(ErroSshCli::CanalFalhou(format!(
436 "header SCP inesperado: {}",
437 header
438 )));
439 }
440
441 let partes: Vec<&str> = header.split_whitespace().collect();
442 if partes.len() < 3 {
443 return Err(ErroSshCli::CanalFalhou(format!(
444 "header SCP mal formatado: {}",
445 header
446 )));
447 }
448
449 let mode_token = partes[0];
451 if mode_token.len() < 2 {
452 return Err(ErroSshCli::CanalFalhou(format!(
453 "mode SCP ausente no header: {header}"
454 )));
455 }
456 let mode_oct = &mode_token[1..];
457 let mode: u32 = u32::from_str_radix(mode_oct, 8)
458 .map_err(|_| ErroSshCli::CanalFalhou(format!("mode SCP inválido: {mode_oct}")))?;
459
460 let tamanho = partes[1].parse().map_err(|_| {
461 ErroSshCli::CanalFalhou(format!("tamanho inválido no header: {}", partes[1]))
462 })?;
463 Ok((mode & 0o7777, tamanho))
464 }
465
466 fn mode_scp_de_metadata(meta: &std::fs::Metadata) -> u32 {
468 #[cfg(unix)]
469 {
470 use std::os::unix::fs::PermissionsExt;
471 meta.permissions().mode() & 0o7777
472 }
473 #[cfg(not(unix))]
474 {
475 let _ = meta;
476 0o644
477 }
478 }
479
480 fn system_time_secs(t: std::time::SystemTime) -> u64 {
482 t.duration_since(std::time::UNIX_EPOCH)
483 .map(|d| d.as_secs())
484 .unwrap_or(0)
485 }
486
487 const SCP_PARTIAL_SUFFIX: &str = ".ssh-cli.partial";
489
490 fn caminho_parcial_download(local: &std::path::Path) -> std::path::PathBuf {
491 let mut p = local.as_os_str().to_os_string();
492 p.push(SCP_PARTIAL_SUFFIX);
493 std::path::PathBuf::from(p)
494 }
495
496 fn interpretar_status_scp(bytes: &[u8]) -> ResultadoSshCli<()> {
498 if bytes.is_empty() {
499 return Err(ErroSshCli::CanalFalhou(
500 "status SCP vazio (esperado ACK 0x00)".to_string(),
501 ));
502 }
503 match bytes[0] {
504 SCP_OK => Ok(()),
505 1 | 2 => {
506 let msg = String::from_utf8_lossy(&bytes[1..]).trim().to_string();
507 Err(ErroSshCli::CanalFalhou(if msg.is_empty() {
508 format!("SCP rejeitou a transferência (status {})", bytes[0])
509 } else {
510 format!("SCP: {msg}")
511 }))
512 }
513 other => Err(ErroSshCli::CanalFalhou(format!(
514 "status SCP inesperado: 0x{other:02x}"
515 ))),
516 }
517 }
518
519 fn comando_scp_remoto(modo: &str, remote: &std::path::Path) -> String {
525 let path = crate::ssh::packing::escapar_shell_single_quotes(&remote.display().to_string());
526 let modo_p = if modo.contains('p') {
528 modo.to_string()
529 } else {
530 format!("{modo}p")
531 };
532 format!("scp {modo_p} {path}")
534 }
535
536 fn aplicar_mode_local(path: &std::path::Path, mode: u32) -> ResultadoSshCli<()> {
538 #[cfg(unix)]
539 {
540 use std::os::unix::fs::PermissionsExt;
541 let perms = std::fs::Permissions::from_mode(mode & 0o7777);
542 std::fs::set_permissions(path, perms).map_err(ErroSshCli::Io)?;
543 }
544 #[cfg(not(unix))]
545 {
546 let _ = (path, mode);
547 }
548 Ok(())
549 }
550
551 async fn scp_ler_data<S>(canal: &mut russh::Channel<S>) -> ResultadoSshCli<Vec<u8>>
553 where
554 S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
555 {
556 use russh::ChannelMsg;
557 loop {
558 match canal.wait().await {
559 Some(ChannelMsg::Data { data }) => {
560 if data.is_empty() {
561 continue;
562 }
563 return Ok(data.to_vec());
564 }
565 Some(ChannelMsg::ExtendedData { data, .. }) => {
566 if data.is_empty() {
567 continue;
568 }
569 let msg = String::from_utf8_lossy(data.as_ref()).trim().to_string();
570 return Err(ErroSshCli::CanalFalhou(format!("SCP stderr: {msg}")));
571 }
572 Some(ChannelMsg::ExitStatus { exit_status }) if exit_status != 0 => {
573 return Err(ErroSshCli::CanalFalhou(format!(
574 "scp encerrou com exit {exit_status}"
575 )));
576 }
577 Some(ChannelMsg::Close) | None => {
578 return Err(ErroSshCli::CanalFalhou(
579 "canal SCP fechou prematuramente".to_string(),
580 ));
581 }
582 _ => continue,
583 }
584 }
585 }
586
587 async fn scp_aguardar_status<S>(canal: &mut russh::Channel<S>) -> ResultadoSshCli<()>
589 where
590 S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
591 {
592 let data = scp_ler_data(canal).await?;
593 interpretar_status_scp(&data)
594 }
595
596 async fn scp_ler_ate_newline<S>(canal: &mut russh::Channel<S>) -> ResultadoSshCli<Vec<u8>>
598 where
599 S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
600 {
601 let mut buf = Vec::new();
602 loop {
603 let chunk = scp_ler_data(canal).await?;
604 if buf.is_empty() && matches!(chunk.first().copied(), Some(1 | 2)) {
605 return Ok(chunk);
606 }
607 buf.extend_from_slice(&chunk);
608 if buf.contains(&b'\n') {
609 return Ok(buf);
610 }
611 if buf.len() > 16_384 {
612 return Err(ErroSshCli::CanalFalhou(
613 "header SCP excessivamente longo".to_string(),
614 ));
615 }
616 }
617 }
618
619 impl ClienteSsh {
620 pub async fn conectar(cfg: ConfiguracaoConexao) -> ResultadoSshCli<Self> {
630 cfg.validar()?;
631
632 let timeout = Duration::from_millis(cfg.timeout_ms);
633 let host = cfg.host.clone();
634 let porta = cfg.porta;
635 let usuario = cfg.usuario.clone();
636 let senha_segura = cfg.senha.clone();
637 let key_path = cfg.key_path.clone();
638 let key_passphrase = cfg.key_passphrase.clone();
639 let handler = ManipuladorCliente::novo(&cfg);
640
641 let config_cliente = Arc::new(russh::client::Config {
642 inactivity_timeout: Some(timeout),
643 ..Default::default()
644 });
645
646 tracing::info!(
647 host = %host,
648 porta,
649 usuario = %usuario,
650 timeout_ms = cfg.timeout_ms,
651 tem_chave = key_path.is_some(),
652 "iniciando conexão SSH"
653 );
654
655 let resultado_conexao = tokio::time::timeout(timeout, async move {
656 let mut sessao = russh::client::connect(
657 config_cliente,
658 (host.as_str(), porta),
659 handler,
660 )
661 .await
662 .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha TCP/handshake: {e}")))?;
663
664 let mut autenticado = false;
666
667 if let Some(ref kp) = key_path {
668 let pass = key_passphrase
669 .as_ref()
670 .map(|s| s.expose_secret().to_string());
671 let chave = russh::keys::load_secret_key(kp, pass.as_deref()).map_err(|e| {
672 ErroSshCli::AutenticacaoSsh(format!(
673 "falha ao carregar chave {kp}: {e}"
674 ))
675 })?;
676 let hash = sessao
677 .best_supported_rsa_hash()
678 .await
679 .map_err(|e| {
680 ErroSshCli::ConexaoFalhou(format!("rsa hash: {e}"))
681 })?
682 .flatten();
683 let auth = sessao
684 .authenticate_publickey(
685 usuario.clone(),
686 russh::keys::PrivateKeyWithHashAlg::new(Arc::new(chave), hash),
687 )
688 .await
689 .map_err(|e| {
690 ErroSshCli::ConexaoFalhou(format!("falha auth publickey: {e}"))
691 })?;
692 autenticado = auth.success();
693 if !autenticado {
694 tracing::warn!(host = %host, "auth por chave rejeitada; tentando senha se houver");
695 }
696 }
697
698 if !autenticado && !senha_segura.expose_secret().is_empty() {
699 let auth = sessao
700 .authenticate_password(usuario.clone(), senha_segura.expose_secret())
701 .await
702 .map_err(|e| {
703 ErroSshCli::ConexaoFalhou(format!("falha auth password: {e}"))
704 })?;
705 autenticado = auth.success();
706 }
707
708 if !autenticado {
709 tracing::warn!(host = %host, usuario = %usuario, "autenticação SSH rejeitada");
710 return Err(ErroSshCli::AutenticacaoFalhou);
711 }
712
713 Ok::<_, ErroSshCli>(sessao)
714 })
715 .await;
716
717 let sessao = match resultado_conexao {
718 Ok(Ok(s)) => s,
719 Ok(Err(erro)) => return Err(erro),
720 Err(_) => return Err(ErroSshCli::TimeoutSsh(cfg.timeout_ms)),
721 };
722
723 tracing::info!("conexão SSH autenticada com sucesso");
724
725 Ok(Self { sessao, cfg })
726 }
727
728 pub async fn executar_comando(
737 &mut self,
738 comando: &str,
739 max_chars: usize,
740 stdin_data: Option<Vec<u8>>,
741 ) -> ResultadoSshCli<SaidaExecucao> {
742 self.executar_comando_interno(comando, max_chars, true, stdin_data)
743 .await
744 }
745
746 async fn executar_comando_interno(
747 &mut self,
748 comando: &str,
749 max_chars: usize,
750 abort_em_timeout: bool,
751 stdin_data: Option<Vec<u8>>,
752 ) -> ResultadoSshCli<SaidaExecucao> {
753 let inicio = Instant::now();
754 let timeout = Duration::from_millis(self.cfg.timeout_ms);
755
756 let resultado = tokio::time::timeout(timeout, async {
757 let mut canal = self
758 .sessao
759 .channel_open_session()
760 .await
761 .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão: {e}")))?;
762
763 canal
764 .exec(true, comando)
765 .await
766 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec: {e}")))?;
767
768 if let Some(bytes) = stdin_data.as_ref() {
770 canal
771 .data(&bytes[..])
772 .await
773 .map_err(|e| ErroSshCli::CanalFalhou(format!("stdin canal: {e}")))?;
774 canal
775 .eof()
776 .await
777 .map_err(|e| ErroSshCli::CanalFalhou(format!("eof canal: {e}")))?;
778 }
779
780 let mut stdout_bytes: Vec<u8> = Vec::new();
781 let mut stderr_bytes: Vec<u8> = Vec::new();
782 let mut exit_code: Option<i32> = None;
783
784 while let Some(msg) = canal.wait().await {
785 if processar_mensagem_exec(
786 msg,
787 &mut stdout_bytes,
788 &mut stderr_bytes,
789 &mut exit_code,
790 ) {
791 break;
792 }
793 }
794
795 Ok::<_, ErroSshCli>((stdout_bytes, stderr_bytes, exit_code))
796 })
797 .await;
798
799 let (stdout_bytes, stderr_bytes, exit_code) = match resultado {
800 Ok(Ok(t)) => t,
801 Ok(Err(erro)) => return Err(erro),
802 Err(_) => {
803 if abort_em_timeout {
804 if let Some(padrao) = crate::ssh::packing::padrao_abort_remoto(comando) {
805 let abort_cmd = crate::ssh::packing::empacotar_abort_pkill(&padrao);
806 tracing::warn!(
807 padrao = %padrao,
808 "timeout local; tentando abort remoto best-effort"
809 );
810 let _ = self.tentar_abort_remoto(&abort_cmd).await;
811 }
812 }
813 return Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms));
814 }
815 };
816
817 let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
818 let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
819
820 let (stdout_truncado, truncado_stdout) = super::truncar_utf8(&stdout_str, max_chars);
821 let (stderr_truncado, truncado_stderr) = super::truncar_utf8(&stderr_str, max_chars);
822
823 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
824
825 Ok(SaidaExecucao {
826 stdout: stdout_truncado,
827 stderr: stderr_truncado,
828 exit_code,
829 truncado_stdout,
830 truncado_stderr,
831 duracao_ms,
832 })
833 }
834
835 pub async fn upload(
845 &mut self,
846 local: &std::path::Path,
847 remote: &std::path::Path,
848 ) -> ResultadoSshCli<TransferenciaResultado> {
849 use russh::ChannelMsg;
850 use std::io::Read;
851 use std::time::Instant;
852
853 let local_str = local.display().to_string();
854
855 if local.is_dir() {
856 return Err(ErroSshCli::ArgumentoInvalido(crate::i18n::t(
857 crate::i18n::Mensagem::ScpUploadSomenteArquivo,
858 )));
859 }
860
861 let metadados = std::fs::metadata(local).map_err(|e| {
862 if e.kind() == std::io::ErrorKind::NotFound {
863 ErroSshCli::ArquivoNaoEncontrado(local_str.clone())
864 } else {
865 ErroSshCli::Io(e)
866 }
867 })?;
868
869 if !metadados.is_file() {
870 return Err(ErroSshCli::ArgumentoInvalido(crate::i18n::t(
871 crate::i18n::Mensagem::ScpUploadSomenteArquivo,
872 )));
873 }
874
875 let tamanho = metadados.len();
876 let mode = mode_scp_de_metadata(&metadados);
877 let mtime = metadados.modified().ok().map(system_time_secs).unwrap_or(0);
878 let atime = metadados
879 .accessed()
880 .ok()
881 .map(system_time_secs)
882 .unwrap_or(mtime);
883 let nome_arquivo = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
884
885 let inicio = Instant::now();
886 let timeout = Duration::from_millis(self.cfg.timeout_ms);
887
888 let resultado =
889 tokio::time::timeout(timeout, async {
890 if crate::signals::cancelado() {
891 return Err(ErroSshCli::ArgumentoInvalido(crate::i18n::t(
892 crate::i18n::Mensagem::OperacaoCancelada,
893 )));
894 }
895
896 let mut canal =
897 self.sessao.channel_open_session().await.map_err(|e| {
898 ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}"))
899 })?;
900
901 let comando = comando_scp_remoto("-t", remote);
902 canal
903 .exec(true, comando.as_str())
904 .await
905 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
906
907 scp_aguardar_status(&mut canal).await?;
909
910 let linha_t = formatar_linha_t_scp(mtime, atime);
912 canal
913 .data(linha_t.as_bytes())
914 .await
915 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar linha T SCP: {e}")))?;
916 scp_aguardar_status(&mut canal).await?;
917
918 let header = formatar_header_upload_scp_com_modo(mode, tamanho, nome_arquivo);
919 canal
920 .data(header.as_bytes())
921 .await
922 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar header SCP: {e}")))?;
923 scp_aguardar_status(&mut canal).await?;
924
925 let mut arquivo = std::fs::File::open(local).map_err(ErroSshCli::Io)?;
927 let mut buf = vec![0u8; 32_768];
928 loop {
929 if crate::signals::cancelado() {
930 return Err(ErroSshCli::ArgumentoInvalido(crate::i18n::t(
931 crate::i18n::Mensagem::OperacaoCancelada,
932 )));
933 }
934 let n = arquivo.read(&mut buf).map_err(ErroSshCli::Io)?;
935 if n == 0 {
936 break;
937 }
938 canal.data(&buf[..n]).await.map_err(|e| {
939 ErroSshCli::CanalFalhou(format!("enviar bloco SCP: {e}"))
940 })?;
941 }
942
943 canal
945 .data([SCP_OK].as_slice())
946 .await
947 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar EOF SCP: {e}")))?;
948 scp_aguardar_status(&mut canal).await?;
949
950 let _ = canal.eof().await;
951 while let Some(msg) = canal.wait().await {
952 if let ChannelMsg::Close = msg {
953 break;
954 }
955 }
956
957 Ok::<_, ErroSshCli>(())
958 })
959 .await;
960
961 resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
962
963 let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
964
965 Ok(TransferenciaResultado {
966 bytes_transferidos: tamanho,
967 duracao_ms,
968 })
969 }
970
971 pub async fn download(
980 &mut self,
981 remote: &std::path::Path,
982 local: &std::path::Path,
983 ) -> ResultadoSshCli<TransferenciaResultado> {
984 use russh::ChannelMsg;
985 use std::io::Write;
986 use std::time::{Duration as StdDuration, Instant, UNIX_EPOCH};
987
988 if local.is_dir() {
989 return Err(ErroSshCli::ArgumentoInvalido(crate::i18n::t(
990 crate::i18n::Mensagem::ScpDownloadLocalNaoDiretorio,
991 )));
992 }
993
994 let inicio = Instant::now();
995 let timeout = Duration::from_millis(self.cfg.timeout_ms);
996 let partial = caminho_parcial_download(local);
997
998 let resultado = tokio::time::timeout(timeout, async {
999 if crate::signals::cancelado() {
1000 return Err(ErroSshCli::ArgumentoInvalido(crate::i18n::t(
1001 crate::i18n::Mensagem::OperacaoCancelada,
1002 )));
1003 }
1004
1005 let mut canal = self
1006 .sessao
1007 .channel_open_session()
1008 .await
1009 .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}")))?;
1010
1011 let comando = comando_scp_remoto("-f", remote);
1012 canal
1013 .exec(true, comando.as_str())
1014 .await
1015 .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
1016
1017 canal
1019 .data([SCP_OK].as_slice())
1020 .await
1021 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack inicial: {e}")))?;
1022
1023 let mut times: Option<(u64, u64)> = None;
1024 let mut header_bytes = scp_ler_ate_newline(&mut canal).await?;
1025 if !header_bytes.is_empty() && matches!(header_bytes[0], 1 | 2) {
1027 interpretar_status_scp(&header_bytes)?;
1028 }
1029 let mut header = String::from_utf8_lossy(&header_bytes).into_owned();
1030 if header.trim_start().starts_with('T') {
1032 times = Some(parse_linha_t_scp(&header)?);
1033 canal
1034 .data([SCP_OK].as_slice())
1035 .await
1036 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack T: {e}")))?;
1037 header_bytes = scp_ler_ate_newline(&mut canal).await?;
1038 if !header_bytes.is_empty() && matches!(header_bytes[0], 1 | 2) {
1039 interpretar_status_scp(&header_bytes)?;
1040 }
1041 header = String::from_utf8_lossy(&header_bytes).into_owned();
1042 }
1043 let (mode_remoto, tamanho) = parse_header_scp(&header)?;
1044
1045 canal
1046 .data([SCP_OK].as_slice())
1047 .await
1048 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack header: {e}")))?;
1049
1050 if let Some(pai) = local.parent() {
1051 if !pai.as_os_str().is_empty() {
1052 std::fs::create_dir_all(pai)?;
1053 }
1054 }
1055
1056 let mut arquivo = std::fs::File::create(&partial).map_err(ErroSshCli::Io)?;
1058 let mut recebidos: u64 = 0;
1059 let mut pendente: Vec<u8> = Vec::new();
1060
1061 while recebidos < tamanho {
1062 if crate::signals::cancelado() {
1063 return Err(ErroSshCli::ArgumentoInvalido(crate::i18n::t(
1064 crate::i18n::Mensagem::OperacaoCancelada,
1065 )));
1066 }
1067 if pendente.is_empty() {
1068 let chunk = scp_ler_data(&mut canal).await?;
1069 pendente.extend_from_slice(&chunk);
1070 }
1071 let falta = (tamanho - recebidos) as usize;
1072 let usar = falta.min(pendente.len());
1073 arquivo
1074 .write_all(&pendente[..usar])
1075 .map_err(ErroSshCli::Io)?;
1076 recebidos += usar as u64;
1077 pendente.drain(..usar);
1078 }
1079
1080 if pendente.is_empty() {
1082 match scp_ler_data(&mut canal).await {
1083 Ok(trail) => pendente.extend_from_slice(&trail),
1084 Err(_) if recebidos == tamanho => {}
1085 Err(e) => return Err(e),
1086 }
1087 }
1088 if pendente.first() == Some(&SCP_OK) {
1089 pendente.remove(0);
1090 } else if !pendente.is_empty() {
1091 return Err(ErroSshCli::CanalFalhou(format!(
1092 "terminador SCP inesperado após payload (0x{:02x})",
1093 pendente[0]
1094 )));
1095 }
1096
1097 arquivo.flush().map_err(ErroSshCli::Io)?;
1098 let _ = arquivo.sync_data();
1099 drop(arquivo);
1100
1101 canal
1102 .data([SCP_OK].as_slice())
1103 .await
1104 .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack final: {e}")))?;
1105
1106 let _ = canal.eof().await;
1107 while let Some(msg) = canal.wait().await {
1108 if matches!(msg, ChannelMsg::Close) {
1109 break;
1110 }
1111 }
1112
1113 aplicar_mode_local(&partial, mode_remoto)?;
1116 if let Some((mtime, atime)) = times {
1117 let mtime_st = UNIX_EPOCH + StdDuration::from_secs(mtime);
1118 let atime_st = UNIX_EPOCH + StdDuration::from_secs(atime);
1119 let ft = std::fs::FileTimes::new()
1120 .set_modified(mtime_st)
1121 .set_accessed(atime_st);
1122 if let Ok(f) = std::fs::File::options().write(true).open(&partial) {
1123 let _ = f.set_times(ft);
1124 }
1125 }
1126
1127 std::fs::rename(&partial, local).map_err(ErroSshCli::Io)?;
1128 if let Some(pai) = local.parent() {
1130 if !pai.as_os_str().is_empty() {
1131 if let Ok(dir) = std::fs::File::open(pai) {
1132 let _ = dir.sync_all();
1133 }
1134 }
1135 }
1136
1137 Ok::<_, ErroSshCli>(recebidos)
1138 })
1139 .await;
1140
1141 match resultado {
1142 Ok(Ok(recebidos)) => {
1143 let duracao_ms =
1144 u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1145 Ok(TransferenciaResultado {
1146 bytes_transferidos: recebidos,
1147 duracao_ms,
1148 })
1149 }
1150 Ok(Err(e)) => {
1151 let _ = std::fs::remove_file(&partial);
1152 Err(e)
1155 }
1156 Err(_) => {
1157 let _ = std::fs::remove_file(&partial);
1158 Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))
1159 }
1160 }
1161 }
1162
1163 async fn tentar_abort_remoto(&self, abort_cmd: &str) -> ResultadoSshCli<()> {
1165 let mut cfg_abort = self.cfg.clone();
1168 cfg_abort.timeout_ms = cfg_abort.timeout_ms.clamp(3_000, 10_000);
1169 let outro = match Self::conectar(cfg_abort).await {
1170 Ok(c) => c,
1171 Err(e) => {
1172 tracing::debug!(erro = %e, "abort remoto não pôde reconectar");
1173 return Err(e);
1174 }
1175 };
1176 let timeout = Duration::from_millis(outro.cfg.timeout_ms);
1177 let _ = tokio::time::timeout(timeout, async {
1178 let mut canal = outro
1179 .sessao
1180 .channel_open_session()
1181 .await
1182 .map_err(|e| ErroSshCli::CanalFalhou(format!("abort canal: {e}")))?;
1183 canal
1184 .exec(true, abort_cmd)
1185 .await
1186 .map_err(|e| ErroSshCli::CanalFalhou(format!("abort exec: {e}")))?;
1187 while let Some(msg) = canal.wait().await {
1188 if matches!(msg, russh::ChannelMsg::Close) {
1189 break;
1190 }
1191 }
1192 Ok::<(), ErroSshCli>(())
1193 })
1194 .await;
1195 let _ = outro.desconectar().await;
1196 Ok(())
1197 }
1198
1199 pub async fn desconectar(&self) -> ResultadoSshCli<()> {
1204 let resultado = self
1205 .sessao
1206 .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
1207 .await;
1208 match resultado {
1209 Ok(()) => {
1210 tracing::info!("sessão SSH encerrada");
1211 Ok(())
1212 }
1213 Err(e) => {
1214 tracing::warn!(erro = %e, "falha ao encerrar sessão SSH");
1215 Err(ErroSshCli::ConexaoFalhou(format!(
1216 "falha ao desconectar: {e}"
1217 )))
1218 }
1219 }
1220 }
1221
1222 pub async fn abrir_canal_tunel(
1224 &self,
1225 host_remoto: &str,
1226 porta_remota: u16,
1227 endereco_origem: &str,
1228 porta_origem: u16,
1229 ) -> ResultadoSshCli<Box<dyn CanalTunel>> {
1230 let canal = self
1231 .sessao
1232 .channel_open_direct_tcpip(
1233 host_remoto.to_string(),
1234 u32::from(porta_remota),
1235 endereco_origem.to_string(),
1236 u32::from(porta_origem),
1237 )
1238 .await
1239 .map_err(|e| {
1240 ErroSshCli::CanalFalhou(format!(
1241 "falha ao abrir canal direct-tcpip para {}:{}: {}",
1242 host_remoto, porta_remota, e
1243 ))
1244 })?;
1245
1246 Ok(Box::new(canal.into_stream()))
1247 }
1248 }
1249
1250 #[async_trait]
1251 impl ClienteSshTrait for ClienteSsh {
1252 async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
1253 Self::conectar(cfg).await.map(Box::new)
1254 }
1255
1256 async fn executar_comando(
1257 &mut self,
1258 cmd: &str,
1259 max_chars: usize,
1260 stdin_data: Option<Vec<u8>>,
1261 ) -> Result<SaidaExecucao, ErroSshCli> {
1262 Self::executar_comando(self, cmd, max_chars, stdin_data).await
1263 }
1264
1265 async fn upload(
1266 &mut self,
1267 local: &Path,
1268 remote: &Path,
1269 ) -> Result<TransferenciaResultado, ErroSshCli> {
1270 Self::upload(self, local, remote).await
1271 }
1272
1273 async fn download(
1274 &mut self,
1275 remote: &Path,
1276 local: &Path,
1277 ) -> Result<TransferenciaResultado, ErroSshCli> {
1278 Self::download(self, remote, local).await
1279 }
1280
1281 async fn abrir_canal_tunel(
1282 &self,
1283 host_remoto: &str,
1284 porta_remota: u16,
1285 endereco_origem: &str,
1286 porta_origem: u16,
1287 ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
1288 Self::abrir_canal_tunel(
1289 self,
1290 host_remoto,
1291 porta_remota,
1292 endereco_origem,
1293 porta_origem,
1294 )
1295 .await
1296 }
1297
1298 async fn desconectar(&self) -> Result<(), ErroSshCli> {
1299 Self::desconectar(self).await
1300 }
1301 }
1302
1303 #[cfg(test)]
1304 mod testes_real {
1305 use super::{
1306 caminho_parcial_download, comando_scp_remoto, formatar_header_upload_scp,
1307 formatar_header_upload_scp_com_modo, formatar_linha_t_scp, interpretar_status_scp,
1308 mapear_exit_status, parse_header_scp, parse_linha_t_scp, processar_mensagem_exec,
1309 SCP_PARTIAL_SUFFIX,
1310 };
1311
1312 #[test]
1313 fn mapear_exit_status_normal() {
1314 assert_eq!(mapear_exit_status(0), 0);
1315 assert_eq!(mapear_exit_status(255), 255);
1316 }
1317
1318 #[test]
1319 fn mapear_exit_status_overflow_retorna_menos_um() {
1320 assert_eq!(mapear_exit_status(u32::MAX), -1);
1321 }
1322
1323 #[test]
1324 fn parse_header_scp_valido_retorna_mode_e_tamanho() {
1325 let (mode, tamanho) =
1326 parse_header_scp("C0644 42 arquivo.txt\n").expect("header válido");
1327 assert_eq!(mode, 0o644);
1328 assert_eq!(tamanho, 42);
1329 let (mode2, _) = parse_header_scp("C0600 1 x\n").expect("600");
1330 assert_eq!(mode2, 0o600);
1331 }
1332
1333 #[test]
1334 fn parse_header_scp_invalido_retorna_erro() {
1335 assert!(parse_header_scp("ERRO").is_err());
1336 assert!(parse_header_scp("C0644 sem_tamanho").is_err());
1337 assert!(parse_header_scp("C0644 abc arquivo").is_err());
1338 assert!(parse_header_scp("Czzzz 1 x\n").is_err());
1339 }
1340
1341 #[test]
1342 fn processar_mensagem_exec_trata_stdout_stderr_e_close() {
1343 let mut stdout = Vec::new();
1344 let mut stderr = Vec::new();
1345 let mut exit_code = None;
1346
1347 let deve_parar = processar_mensagem_exec(
1348 russh::ChannelMsg::Data {
1349 data: b"stdout".to_vec().into(),
1350 },
1351 &mut stdout,
1352 &mut stderr,
1353 &mut exit_code,
1354 );
1355 assert!(!deve_parar);
1356 assert_eq!(stdout, b"stdout");
1357
1358 let deve_parar = processar_mensagem_exec(
1359 russh::ChannelMsg::ExtendedData {
1360 data: b"stderr".to_vec().into(),
1361 ext: 1,
1362 },
1363 &mut stdout,
1364 &mut stderr,
1365 &mut exit_code,
1366 );
1367 assert!(!deve_parar);
1368 assert_eq!(stderr, b"stderr");
1369
1370 let _ = processar_mensagem_exec(
1371 russh::ChannelMsg::ExitStatus { exit_status: 17 },
1372 &mut stdout,
1373 &mut stderr,
1374 &mut exit_code,
1375 );
1376 assert_eq!(exit_code, Some(17));
1377
1378 let deve_parar = processar_mensagem_exec(
1379 russh::ChannelMsg::Close,
1380 &mut stdout,
1381 &mut stderr,
1382 &mut exit_code,
1383 );
1384 assert!(deve_parar);
1385 }
1386
1387 #[test]
1388 fn formatar_header_upload_scp_gera_formato_esperado() {
1389 let header = formatar_header_upload_scp(123, "arquivo.txt");
1390 assert_eq!(header, "C0644 123 arquivo.txt\n");
1392 assert_eq!(header.as_bytes().last().copied(), Some(b'\n'));
1393 assert!(
1394 !header.as_bytes().windows(2).any(|w| w == *b"\\n"),
1395 "header não deve conter backslash-n literal"
1396 );
1397 }
1398
1399 #[test]
1400 fn formatar_header_upload_scp_usa_basename() {
1401 let header = formatar_header_upload_scp(1, "/tmp/dir/nome.bin");
1402 assert_eq!(header, "C0644 1 nome.bin\n");
1403 }
1404
1405 #[test]
1406 fn interpretar_status_scp_ok_e_erro() {
1407 assert!(interpretar_status_scp(&[0]).is_ok());
1408 assert!(interpretar_status_scp(&[1, b'f', b'a', b'i', b'l']).is_err());
1409 assert!(interpretar_status_scp(&[]).is_err());
1410 }
1411
1412 #[test]
1413 fn comando_scp_remoto_escapa_path_e_usa_p() {
1414 let cmd = comando_scp_remoto("-t", std::path::Path::new("/tmp/a b.txt"));
1415 assert_eq!(cmd, "scp -tp '/tmp/a b.txt'");
1416 let cmd_f = comando_scp_remoto("-f", std::path::Path::new("/var/log/a.log"));
1417 assert_eq!(cmd_f, "scp -fp '/var/log/a.log'");
1418 assert_eq!(
1420 comando_scp_remoto("-fp", std::path::Path::new("/x")),
1421 "scp -fp '/x'"
1422 );
1423 }
1424
1425 #[test]
1426 fn formatar_linha_t_scp_formato() {
1427 let t = formatar_linha_t_scp(1_700_000_000, 1_700_000_001);
1428 assert_eq!(t, "T1700000000 0 1700000001 0\n");
1429 assert_eq!(t.as_bytes().last().copied(), Some(b'\n'));
1430 }
1431
1432 #[test]
1433 fn parse_linha_t_scp_ok() {
1434 let (m, a) = parse_linha_t_scp("T100 0 200 0\n").expect("T ok");
1435 assert_eq!((m, a), (100, 200));
1436 }
1437
1438 #[test]
1439 fn formatar_header_com_modo() {
1440 let h = formatar_header_upload_scp_com_modo(0o755, 10, "x.sh");
1441 assert_eq!(h, "C0755 10 x.sh\n");
1442 }
1443
1444 #[test]
1445 fn caminho_parcial_download_sufixo() {
1446 let p = caminho_parcial_download(std::path::Path::new("/tmp/out.bin"));
1447 assert!(p.to_string_lossy().ends_with(SCP_PARTIAL_SUFFIX));
1448 assert!(p.to_string_lossy().contains("out.bin"));
1449 }
1450
1451 #[test]
1452 fn processar_mensagem_exec_ignora_extendido_com_codigo_diferente_de_stderr() {
1453 let mut stdout = Vec::new();
1454 let mut stderr = Vec::new();
1455 let mut exit_code = None;
1456
1457 let deve_parar = processar_mensagem_exec(
1458 russh::ChannelMsg::ExtendedData {
1459 data: b"nao-e-stderr".to_vec().into(),
1460 ext: 2,
1461 },
1462 &mut stdout,
1463 &mut stderr,
1464 &mut exit_code,
1465 );
1466
1467 assert!(!deve_parar);
1468 assert!(stdout.is_empty());
1469 assert!(stderr.is_empty());
1470 assert!(exit_code.is_none());
1471 }
1472
1473 #[test]
1474 fn processar_mensagem_exec_trata_exit_signal_e_eof_sem_encerrar_loop() {
1475 let mut stdout = Vec::new();
1476 let mut stderr = Vec::new();
1477 let mut exit_code = Some(7);
1478
1479 let deve_parar_signal = processar_mensagem_exec(
1480 russh::ChannelMsg::ExitSignal {
1481 signal_name: russh::Sig::TERM,
1482 core_dumped: false,
1483 error_message: "encerrado".to_string(),
1484 lang_tag: "pt-BR".to_string(),
1485 },
1486 &mut stdout,
1487 &mut stderr,
1488 &mut exit_code,
1489 );
1490
1491 let deve_parar_eof = processar_mensagem_exec(
1492 russh::ChannelMsg::Eof,
1493 &mut stdout,
1494 &mut stderr,
1495 &mut exit_code,
1496 );
1497
1498 assert!(!deve_parar_signal);
1499 assert!(!deve_parar_eof);
1500 assert_eq!(exit_code, Some(7));
1501 }
1502
1503 #[test]
1504 fn processar_mensagem_exec_ignora_variantes_sem_tratamento_especifico() {
1505 let mut stdout = Vec::new();
1506 let mut stderr = Vec::new();
1507 let mut exit_code = None;
1508
1509 let deve_parar = processar_mensagem_exec(
1510 russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
1511 &mut stdout,
1512 &mut stderr,
1513 &mut exit_code,
1514 );
1515
1516 assert!(!deve_parar);
1517 assert!(stdout.is_empty());
1518 assert!(stderr.is_empty());
1519 assert!(exit_code.is_none());
1520 }
1521 }
1522}
1523
1524#[cfg(feature = "ssh-real")]
1525pub use real::{ClienteSsh, ManipuladorCliente};
1526
1527#[cfg(not(feature = "ssh-real"))]
1532mod stub {
1533 use super::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
1534 use crate::erros::ErroSshCli;
1535 use crate::ssh::cliente::ClienteSshTrait;
1536 use async_trait::async_trait;
1537 use std::path::Path;
1538
1539 #[derive(Debug)]
1542 pub struct ClienteSsh;
1543
1544 #[async_trait]
1545 impl ClienteSshTrait for ClienteSsh {
1546 async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
1547 Err(ErroSshCli::ConexaoFalhou(
1548 "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
1549 ))
1550 }
1551
1552 async fn executar_comando(
1553 &mut self,
1554 _cmd: &str,
1555 _max_chars: usize,
1556 _stdin_data: Option<Vec<u8>>,
1557 ) -> Result<SaidaExecucao, ErroSshCli> {
1558 Err(ErroSshCli::CanalFalhou(
1559 "stub sem russh: feature `ssh-real` desabilitada".into(),
1560 ))
1561 }
1562
1563 async fn upload(
1564 &mut self,
1565 _local: &Path,
1566 _remote: &Path,
1567 ) -> Result<TransferenciaResultado, ErroSshCli> {
1568 Err(ErroSshCli::CanalFalhou(
1569 "stub sem russh: feature `ssh-real` desabilitada".into(),
1570 ))
1571 }
1572
1573 async fn download(
1574 &mut self,
1575 _remote: &Path,
1576 _local: &Path,
1577 ) -> Result<TransferenciaResultado, ErroSshCli> {
1578 Err(ErroSshCli::CanalFalhou(
1579 "stub sem russh: feature `ssh-real` desabilitada".into(),
1580 ))
1581 }
1582
1583 async fn abrir_canal_tunel(
1584 &self,
1585 _host_remoto: &str,
1586 _porta_remota: u16,
1587 _endereco_origem: &str,
1588 _porta_origem: u16,
1589 ) -> Result<Box<dyn super::CanalTunel>, ErroSshCli> {
1590 Err(ErroSshCli::CanalFalhou(
1591 "stub sem russh: feature `ssh-real` desabilitada".into(),
1592 ))
1593 }
1594
1595 async fn desconectar(&self) -> Result<(), ErroSshCli> {
1596 Ok(())
1597 }
1598 }
1599}
1600
1601#[cfg(not(feature = "ssh-real"))]
1602pub use stub::ClienteSsh;
1603
1604#[cfg(test)]
1609mod testes {
1610 use super::*;
1611 use secrecy::SecretString;
1612
1613 fn cfg_valida() -> ConfiguracaoConexao {
1614 ConfiguracaoConexao {
1615 host: "127.0.0.1".to_string(),
1616 porta: 22,
1617 usuario: "root".to_string(),
1618 senha: SecretString::from("senha-exemplo".to_string()),
1619 key_path: None,
1620 key_passphrase: None,
1621 timeout_ms: 5000,
1622 known_hosts_path: None,
1623 replace_host_key: false,
1624 }
1625 }
1626
1627 #[test]
1628 fn validar_host_vazio_retorna_erro() {
1629 let mut c = cfg_valida();
1630 c.host = String::new();
1631 let r = c.validar();
1632 assert!(r.is_err());
1633 let msg = r.unwrap_err().to_string();
1634 assert!(msg.contains("host"));
1635 }
1636
1637 #[test]
1638 fn validar_host_apenas_espacos_retorna_erro() {
1639 let mut c = cfg_valida();
1640 c.host = " ".to_string();
1641 assert!(c.validar().is_err());
1642 }
1643
1644 #[test]
1645 fn validar_porta_zero_retorna_erro() {
1646 let mut c = cfg_valida();
1647 c.porta = 0;
1648 let r = c.validar();
1649 assert!(r.is_err());
1650 let msg = r.unwrap_err().to_string();
1651 assert!(msg.contains("porta"));
1652 }
1653
1654 #[test]
1655 fn validar_usuario_vazio_retorna_erro() {
1656 let mut c = cfg_valida();
1657 c.usuario = String::new();
1658 assert!(c.validar().is_err());
1659 }
1660
1661 #[test]
1662 fn validar_configuracao_correta_retorna_ok() {
1663 assert!(cfg_valida().validar().is_ok());
1664 }
1665
1666 #[test]
1667 fn debug_nao_expoe_senha() {
1668 let c = cfg_valida();
1669 let dbg = format!("{c:?}");
1670 assert!(!dbg.contains("senha-exemplo"));
1671 assert!(dbg.contains("redacted"));
1672 }
1673
1674 #[test]
1675 fn truncar_utf8_nao_trunca_se_cabe() {
1676 let (s, t) = truncar_utf8("ola mundo", 100);
1677 assert_eq!(s, "ola mundo");
1678 assert!(!t);
1679 }
1680
1681 #[test]
1682 fn truncar_utf8_trunca_string_grande_ascii() {
1683 let entrada: String = "a".repeat(200);
1684 let (s, t) = truncar_utf8(&entrada, 50);
1685 assert_eq!(s.chars().count(), 50);
1686 assert!(t);
1687 }
1688
1689 #[test]
1690 fn truncar_utf8_preserva_grafemas_acentuados() {
1691 let entrada: String = "á".repeat(30);
1693 let (s, t) = truncar_utf8(&entrada, 10);
1694 assert_eq!(s.chars().count(), 10);
1695 assert_eq!(s.len(), 20);
1697 assert!(t);
1698 assert!(s.chars().all(|c| c == 'á'));
1700 }
1701
1702 #[test]
1703 fn truncar_utf8_com_emojis_nao_quebra() {
1704 let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1705 let (s, t) = truncar_utf8(entrada, 5);
1706 assert_eq!(s.chars().count(), 5);
1707 assert!(t);
1708 }
1709
1710 #[test]
1711 fn truncar_utf8_zero_retorna_vazio() {
1712 let (s, t) = truncar_utf8("abc", 0);
1713 assert_eq!(s, "");
1714 assert!(t);
1715 }
1716
1717 #[test]
1718 fn saida_execucao_debug_nao_crasha() {
1719 let s = SaidaExecucao {
1720 stdout: "ok".into(),
1721 stderr: String::new(),
1722 exit_code: Some(0),
1723 truncado_stdout: false,
1724 truncado_stderr: false,
1725 duracao_ms: 42,
1726 };
1727 let _ = format!("{s:?}");
1728 }
1729
1730 #[test]
1731 fn duracao_ms_tipo_compativel() {
1732 let fake: u64 = 1234;
1734 assert_eq!(fake, 1234_u64);
1735 }
1736}