Skip to main content

ssh_cli/ssh/
cliente.rs

1//! Cliente SSH real via `russh` 0.62.2.
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    ///
151    /// `stdin_data`, se presente, é escrito no canal após o `exec` e antes do loop
152    /// de leitura (GAP-SSH-SEC-001: senha sudo/su fora da argv remota).
153    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    /// Faz upload de um arquivo local para o servidor remoto via SCP.
161    async fn upload(
162        &mut self,
163        local: &Path,
164        remote: &Path,
165    ) -> Result<TransferenciaResultado, ErroSshCli>;
166
167    /// Faz download de um arquivo remoto para o sistema local via SCP.
168    async fn download(
169        &mut self,
170        remote: &Path,
171        local: &Path,
172    ) -> Result<TransferenciaResultado, ErroSshCli>;
173
174    /// Abre um canal `direct-tcpip` para forwarding de tunnel.
175    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    /// Encerra a conexão SSH de forma limpa.
184    async fn desconectar(&self) -> Result<(), ErroSshCli>;
185}
186
187#[cfg(test)]
188/// Mocks de cliente SSH usados em testes unitários.
189pub 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// =========================================================================
215// Implementação SSH REAL (feature `ssh-real`).
216// =========================================================================
217
218#[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    /// Handler russh com TOFU em known_hosts (ou always-trust se path ausente).
231    pub struct ManipuladorCliente {
232        host: String,
233        porta: u16,
234        known_hosts_path: Option<std::path::PathBuf>,
235        replace_host_key: bool,
236        /// Erro de host key capturado (russh Error não carrega nosso tipo).
237        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    /// Cliente SSH ativo com sessão autenticada.
301    pub struct ClienteSsh {
302        /// Sessão SSH autenticada para operações de baixo nível.
303        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                // ext == 1 → SSH_EXTENDED_DATA_STDERR (RFC 4254 §5.2).
336                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                // russh entrega como u32. Mantemos como i32 para acomodar
344                // convenções Unix (shells podem emitir códigos como u8 em
345                // wait-status; aqui já é o exit code aplicativo, 0..=255).
346                *exit_code = Some(mapear_exit_status(exit_status));
347                // NÃO retorna true: aguardar Eof/Close após ExitStatus.
348            }
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                // Sem exit_status → mantemos None.
362            }
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    /// Byte de ACK/OK do protocolo SCP (também usado como terminador do payload).
377    const SCP_OK: u8 = 0;
378
379    /// Basename seguro para o wire SCP (sem path separators / control chars).
380    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    /// Header `C`-line do protocolo SCP (newline real `0x0a`, nunca `\\n` literal).
389    #[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    /// Header `C` com mode octal (ex.: `0644`).
395    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    /// Linha `T` do protocolo SCP (preserve times / `-p`).
402    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    /// Parse da linha `T mtime 0 atime 0`.
407    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    /// Parse do header `C0mmm size name` → `(mode, tamanho)`.
431    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        // Campo mode: `C0644` (prefixo `C` + 4 dígitos octais).
450        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    /// Mode octal para o header `C` a partir de metadata local.
467    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    /// Segundos epoch a partir de SystemTime (best-effort).
481    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    /// Sufixo do arquivo temporário de download atômico (SCP-022).
488    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    /// Interpreta o primeiro byte de status SCP: `0`=OK, `1`/`2`=erro (+ mensagem).
497    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    /// Monta `scp -t[p]/-f[p]` com path remoto escapado para o shell remoto.
520    ///
521    /// OpenSSH: source (`-f`) só emite linha `T` e mode honesto com **`-p`**.
522    /// Sink (`-t`) com `-p` aplica mode completo (sem mascarar umask sticky).
523    /// Sempre usamos `-p` (SCP-023 bi-direcional).
524    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        // `modo` esperado: `-t` ou `-f` (sem `-p`); anexamos `p` explicitamente.
527        let modo_p = if modo.contains('p') {
528            modo.to_string()
529        } else {
530            format!("{modo}p")
531        };
532        // Path em single-quotes (sem `--` para máxima compatibilidade OpenSSH scp legado).
533        format!("scp {modo_p} {path}")
534    }
535
536    /// Aplica mode POSIX do header `C` no arquivo local (best-effort no Unix).
537    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    /// Lê o próximo `ChannelMsg::Data` não vazio do canal SCP.
552    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    /// Aguarda ACK de status SCP (`0x00`) ou propaga erro `1`/`2`.
588    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    /// Lê bytes até incluir newline (header `C`/`T`) ou status de erro `1`/`2`.
597    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        /// Conecta e autentica. Todo o fluxo (TCP + handshake + auth) respeita
621        /// o `timeout_ms` da configuração.
622        ///
623        /// # Erros
624        /// - [`ErroSshCli::ArgumentoInvalido`] se a configuração for inválida.
625        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout total.
626        /// - [`ErroSshCli::ConexaoFalhou`] em falhas TCP/handshake.
627        /// - [`ErroSshCli::AutenticacaoFalhou`] se o servidor rejeitar senha/chave
628        ///   (tente `--key`, `--password-stdin` ou `--key-passphrase-stdin`).
629        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                // Preferência: chave privada primeiro; fallback senha se ambas presentes.
665                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        /// Executa um comando shell remoto e captura stdout/stderr em paralelo.
729        ///
730        /// Trunca cada stream em `max_chars` codepoints UTF-8. Respeita o
731        /// `timeout_ms` da configuração para a execução inteira.
732        ///
733        /// # Erros
734        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal ou enviar `exec`.
735        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
736        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                // Senha sudo/su no stdin do canal — nunca na cmdline remota (SEC-001).
769                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        /// Upload de arquivo local para remote via SCP (protocolo OpenSSH sink).
836        ///
837        /// One-shot: stream em chunks (sem carregar o arquivo inteiro em RAM).
838        ///
839        /// # Erros
840        /// - [`ErroSshCli::ArquivoNaoEncontrado`] se o arquivo local não existir.
841        /// - [`ErroSshCli::ArgumentoInvalido`] se o path local não for arquivo regular.
842        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP / status remoto.
843        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
844        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                    // Sink remoto envia ACK (0x00) antes de aceitar o header.
908                    scp_aguardar_status(&mut canal).await?;
909
910                    // Preserve times (linha T) antes do header C.
911                    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                    // SCP-018: stream do disco em chunks (sem fs::read total).
926                    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                    // Terminador de arquivo = byte 0x00 (não data vazio).
944                    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        /// Download de arquivo remote para local via SCP (protocolo OpenSSH source).
972        ///
973        /// Escreve em `{local}.ssh-cli.partial` e faz rename atômico (SCP-022).
974        ///
975        /// # Erros
976        /// - [`ErroSshCli::Io`] se não conseguir escrever o arquivo local.
977        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP / status remoto.
978        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
979        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                // Source remoto só envia o header após o ACK inicial do sink local.
1018                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                // Erro remoto: status 1/2 no primeiro byte.
1026                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                // Linha T opcional (preserve times).
1031                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                // SCP-022: escrever no partial; rename só no sucesso.
1057                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                // Após payload, source envia 0x00 final (pode já estar em `pendente`).
1081                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                // SCP-022b: aplicar mode/times no partial ANTES do rename atômico.
1114                // Assim falha de metadados não deixa `local` com conteúdo de sucesso parcial.
1115                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                // GraphRAG escrita atômica: fsync do diretório pai após rename (best-effort).
1129                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                    // Se rename já ocorreu e algo falhou depois (fsync best-effort não falha),
1153                    // ainda removemos partial; `local` só existe após rename bem-sucedido.
1154                    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        /// Abort remoto best-effort: reconecta com timeout curto e executa pkill.
1164        async fn tentar_abort_remoto(&self, abort_cmd: &str) -> ResultadoSshCli<()> {
1165            // Implementação inline (sem chamar executar_comando_interno) evita
1166            // recursão async detectada pelo compilador.
1167            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        /// Encerra a sessão SSH de forma limpa.
1200        ///
1201        /// # Erros
1202        /// Propaga falha se `disconnect` retornar erro do transporte.
1203        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        /// Abre canal direct-tcpip para forwarding SSH.
1223        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            // Wire protocol: newline real (0x0a), NÃO a sequência literal '\'+'n'.
1391            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            // Idempotente se já contiver p.
1419            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// =========================================================================
1528// Stub usado quando a feature `ssh-real` está DESATIVADA.
1529// =========================================================================
1530
1531#[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    /// Stub quando `ssh-real` está desativado: sempre retorna
1540    /// [`ErroSshCli::ConexaoFalhou`].
1541    #[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// =========================================================================
1605// Testes unitários (sem rede, sem feature gate).
1606// =========================================================================
1607
1608#[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        // 10 codepoints: "á" (1 char) * 10
1692        let entrada: String = "á".repeat(30);
1693        let (s, t) = truncar_utf8(&entrada, 10);
1694        assert_eq!(s.chars().count(), 10);
1695        // Cada 'á' ocupa 2 bytes em UTF-8 → 10 chars = 20 bytes
1696        assert_eq!(s.len(), 20);
1697        assert!(t);
1698        // Não corta no meio de byte
1699        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        // Garantia estática de que instant elapsed cabe em u64.
1733        let fake: u64 = 1234;
1734        assert_eq!(fake, 1234_u64);
1735    }
1736}