Skip to main content

ssh_cli/ssh/
cliente.rs

1//! Cliente SSH real via `russh` 0.60.x.
2//!
3//! Conexão one-shot: TCP + handshake + auth (senha e/ou chave) + exec com
4//! timeout, truncagem de saída e abort remoto best-effort.
5//! Host keys: TOFU em `known_hosts` XDG (ver [`super::known_hosts`]).
6
7use crate::erros::{ErroSshCli, ResultadoSshCli};
8use secrecy::{ExposeSecret, SecretString};
9use std::path::PathBuf;
10use tokio::io::{AsyncRead, AsyncWrite};
11
12/// Configuração de uma conexão SSH.
13///
14/// Construída a partir de um [`crate::vps::modelo::VpsRegistro`] no momento
15/// da chamada. Auth: chave privada (preferida) e/ou senha.
16#[derive(Clone)]
17pub struct ConfiguracaoConexao {
18    /// Hostname ou IP do servidor SSH.
19    pub host: String,
20    /// Porta TCP do servidor SSH (padrão 22).
21    pub porta: u16,
22    /// Nome de usuário SSH.
23    pub usuario: String,
24    /// Senha SSH (`SecretString` para zeroize automático); pode ser vazia se key-only.
25    pub senha: SecretString,
26    /// Caminho da chave privada OpenSSH (opcional).
27    pub key_path: Option<String>,
28    /// Passphrase da chave (opcional).
29    pub key_passphrase: Option<SecretString>,
30    /// Timeout total para conexão + handshake + autenticação + exec, em ms.
31    pub timeout_ms: u64,
32    /// Caminho do arquivo known_hosts (TOFU). `None` = always-trust (só testes).
33    pub known_hosts_path: Option<PathBuf>,
34    /// Se true, permite substituir fingerprint divergente.
35    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    /// Valida os campos básicos da configuração.
59    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/// Saída da execução de um comando SSH remoto.
87#[derive(Debug, Clone)]
88pub struct SaidaExecucao {
89    /// Stdout capturado (possivelmente truncado a `max_chars` codepoints).
90    pub stdout: String,
91    /// Stderr capturado (possivelmente truncado a `max_chars` codepoints).
92    pub stderr: String,
93    /// Código de saída. `None` quando o comando foi terminado por sinal ou timeout.
94    pub exit_code: Option<i32>,
95    /// `true` se `stdout` foi truncado em `max_chars`.
96    pub truncado_stdout: bool,
97    /// `true` se `stderr` foi truncado em `max_chars`.
98    pub truncado_stderr: bool,
99    /// Duração total da execução, em milissegundos.
100    pub duracao_ms: u64,
101}
102
103/// Resultado de uma operação de transferência de arquivo via SCP.
104#[derive(Debug, Clone)]
105pub struct TransferenciaResultado {
106    /// Número de bytes transferidos.
107    pub bytes_transferidos: u64,
108    /// Duração total em milissegundos.
109    pub duracao_ms: u64,
110}
111
112/// Trunca uma string UTF-8 a no máximo `max_chars` codepoints.
113///
114/// Retorna `(string_truncada, truncou)`. Se `max_chars == 0` retorna string vazia.
115/// Unicode-safe: opera sobre codepoints via `chars()`, nunca quebra no meio.
116#[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
126// =========================================================================
127// Trait ClienteSshTrait para permitir mocks em teste.
128// =========================================================================
129
130use async_trait::async_trait;
131use std::path::Path;
132
133/// Stream bidirecional usado para tunnel SSH (direct-tcpip).
134pub trait CanalTunel: AsyncRead + AsyncWrite + Unpin + Send {}
135
136impl<T> CanalTunel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
137
138/// Trait para cliente SSH que permite implementação real (russh) ou mock para testes.
139///
140/// Este trait abstrai as operações de conexão SSH para permitir testes unitários
141/// sem necessidade de conexão de rede real.
142#[async_trait]
143pub trait ClienteSshTrait: Send + Sync + 'static {
144    /// Conecta a um servidor SSH e autentica com as credenciais fornecidas.
145    async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>
146    where
147        Self: Sized;
148
149    /// Executa um comando shell remoto e retorna a saída capturada.
150    async fn executar_comando(
151        &mut self,
152        cmd: &str,
153        max_chars: usize,
154    ) -> Result<SaidaExecucao, ErroSshCli>;
155
156    /// Faz upload de um arquivo local para o servidor remoto via SCP.
157    async fn upload(
158        &mut self,
159        local: &Path,
160        remote: &Path,
161    ) -> Result<TransferenciaResultado, ErroSshCli>;
162
163    /// Faz download de um arquivo remoto para o sistema local via SCP.
164    async fn download(
165        &mut self,
166        remote: &Path,
167        local: &Path,
168    ) -> Result<TransferenciaResultado, ErroSshCli>;
169
170    /// Abre um canal `direct-tcpip` para forwarding de tunnel.
171    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    /// Encerra a conexão SSH de forma limpa.
180    async fn desconectar(&self) -> Result<(), ErroSshCli>;
181}
182
183#[cfg(test)]
184/// Mocks de cliente SSH usados em testes unitários.
185pub 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// =========================================================================
211// Implementação SSH REAL (feature `ssh-real`).
212// =========================================================================
213
214#[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    /// Handler russh com TOFU em known_hosts (ou always-trust se path ausente).
227    pub struct ManipuladorCliente {
228        host: String,
229        porta: u16,
230        known_hosts_path: Option<std::path::PathBuf>,
231        replace_host_key: bool,
232        /// Erro de host key capturado (russh Error não carrega nosso tipo).
233        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    /// Cliente SSH ativo com sessão autenticada.
297    pub struct ClienteSsh {
298        /// Sessão SSH autenticada para operações de baixo nível.
299        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                // ext == 1 → SSH_EXTENDED_DATA_STDERR (RFC 4254 §5.2).
332                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                // russh entrega como u32. Mantemos como i32 para acomodar
340                // convenções Unix (shells podem emitir códigos como u8 em
341                // wait-status; aqui já é o exit code aplicativo, 0..=255).
342                *exit_code = Some(mapear_exit_status(exit_status));
343                // NÃO retorna true: aguardar Eof/Close após ExitStatus.
344            }
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                // Sem exit_status → mantemos None.
358            }
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        /// Conecta e autentica. Todo o fluxo (TCP + handshake + auth) respeita
401        /// o `timeout_ms` da configuração.
402        ///
403        /// # Erros
404        /// - [`ErroSshCli::ArgumentoInvalido`] se a configuração for inválida.
405        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout total.
406        /// - [`ErroSshCli::ConexaoFalhou`] em falhas TCP/handshake.
407        /// - [`ErroSshCli::AutenticacaoFalhou`] se o servidor rejeitar senha/chave
408        ///   (tente `--key`, `--password-stdin` ou `--key-passphrase-stdin`).
409        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                // Preferência: chave privada primeiro; fallback senha se ambas presentes.
445                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        /// Executa um comando shell remoto e captura stdout/stderr em paralelo.
509        ///
510        /// Trunca cada stream em `max_chars` codepoints UTF-8. Respeita o
511        /// `timeout_ms` da configuração para a execução inteira.
512        ///
513        /// # Erros
514        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal ou enviar `exec`.
515        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
516        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        /// Upload de arquivo local para remote via SCP.
602        ///
603        /// # Erros
604        /// - [`ErroSshCli::ArquivoNaoEncontrado`] se o arquivo local não existir.
605        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP.
606        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
607        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        /// Download de arquivo remote para local via SCP.
708        ///
709        /// # Erros
710        /// - [`ErroSshCli::Io`] se não conseguir escrever o arquivo local.
711        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP.
712        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
713        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        /// Abort remoto best-effort: reconecta com timeout curto e executa pkill.
814        async fn tentar_abort_remoto(&self, abort_cmd: &str) -> ResultadoSshCli<()> {
815            // Implementação inline (sem chamar executar_comando_interno) evita
816            // recursão async detectada pelo compilador.
817            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        /// Encerra a sessão SSH de forma limpa.
850        ///
851        /// # Erros
852        /// Propaga falha se `disconnect` retornar erro do transporte.
853        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        /// Abre canal direct-tcpip para forwarding SSH.
873        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// =========================================================================
1112// Stub usado quando a feature `ssh-real` está DESATIVADA.
1113// =========================================================================
1114
1115#[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    /// Stub quando `ssh-real` está desativado: sempre retorna
1124    /// [`ErroSshCli::ConexaoFalhou`].
1125    #[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// =========================================================================
1188// Testes unitários (sem rede, sem feature gate).
1189// =========================================================================
1190
1191#[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        // 10 codepoints: "á" (1 char) * 10
1275        let entrada: String = "á".repeat(30);
1276        let (s, t) = truncar_utf8(&entrada, 10);
1277        assert_eq!(s.chars().count(), 10);
1278        // Cada 'á' ocupa 2 bytes em UTF-8 → 10 chars = 20 bytes
1279        assert_eq!(s.len(), 20);
1280        assert!(t);
1281        // Não corta no meio de byte
1282        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        // Garantia estática de que instant elapsed cabe em u64.
1316        let fake: u64 = 1234;
1317        assert_eq!(fake, 1234_u64);
1318    }
1319}