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    ///
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    fn formatar_header_upload_scp(tamanho: u64, nome_arquivo: &str) -> String {
377        format!("C0644 {} {}\\n", tamanho, nome_arquivo)
378    }
379
380    fn parse_header_scp(header: &str) -> ResultadoSshCli<u64> {
381        let header = header.trim();
382
383        if !header.starts_with('C') {
384            return Err(ErroSshCli::CanalFalhou(format!(
385                "header SCP inesperado: {}",
386                header
387            )));
388        }
389
390        let partes: Vec<&str> = header.split_whitespace().collect();
391        if partes.len() < 3 {
392            return Err(ErroSshCli::CanalFalhou(format!(
393                "header SCP mal formatado: {}",
394                header
395            )));
396        }
397
398        partes[1].parse().map_err(|_| {
399            ErroSshCli::CanalFalhou(format!("tamanho inválido no header: {}", partes[1]))
400        })
401    }
402
403    impl ClienteSsh {
404        /// Conecta e autentica. Todo o fluxo (TCP + handshake + auth) respeita
405        /// o `timeout_ms` da configuração.
406        ///
407        /// # Erros
408        /// - [`ErroSshCli::ArgumentoInvalido`] se a configuração for inválida.
409        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout total.
410        /// - [`ErroSshCli::ConexaoFalhou`] em falhas TCP/handshake.
411        /// - [`ErroSshCli::AutenticacaoFalhou`] se o servidor rejeitar senha/chave
412        ///   (tente `--key`, `--password-stdin` ou `--key-passphrase-stdin`).
413        pub async fn conectar(cfg: ConfiguracaoConexao) -> ResultadoSshCli<Self> {
414            cfg.validar()?;
415
416            let timeout = Duration::from_millis(cfg.timeout_ms);
417            let host = cfg.host.clone();
418            let porta = cfg.porta;
419            let usuario = cfg.usuario.clone();
420            let senha_segura = cfg.senha.clone();
421            let key_path = cfg.key_path.clone();
422            let key_passphrase = cfg.key_passphrase.clone();
423            let handler = ManipuladorCliente::novo(&cfg);
424
425            let config_cliente = Arc::new(russh::client::Config {
426                inactivity_timeout: Some(timeout),
427                ..Default::default()
428            });
429
430            tracing::info!(
431                host = %host,
432                porta,
433                usuario = %usuario,
434                timeout_ms = cfg.timeout_ms,
435                tem_chave = key_path.is_some(),
436                "iniciando conexão SSH"
437            );
438
439            let resultado_conexao = tokio::time::timeout(timeout, async move {
440                let mut sessao = russh::client::connect(
441                    config_cliente,
442                    (host.as_str(), porta),
443                    handler,
444                )
445                .await
446                .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha TCP/handshake: {e}")))?;
447
448                // Preferência: chave privada primeiro; fallback senha se ambas presentes.
449                let mut autenticado = false;
450
451                if let Some(ref kp) = key_path {
452                    let pass = key_passphrase
453                        .as_ref()
454                        .map(|s| s.expose_secret().to_string());
455                    let chave = russh::keys::load_secret_key(kp, pass.as_deref()).map_err(|e| {
456                        ErroSshCli::AutenticacaoSsh(format!(
457                            "falha ao carregar chave {kp}: {e}"
458                        ))
459                    })?;
460                    let hash = sessao
461                        .best_supported_rsa_hash()
462                        .await
463                        .map_err(|e| {
464                            ErroSshCli::ConexaoFalhou(format!("rsa hash: {e}"))
465                        })?
466                        .flatten();
467                    let auth = sessao
468                        .authenticate_publickey(
469                            usuario.clone(),
470                            russh::keys::PrivateKeyWithHashAlg::new(Arc::new(chave), hash),
471                        )
472                        .await
473                        .map_err(|e| {
474                            ErroSshCli::ConexaoFalhou(format!("falha auth publickey: {e}"))
475                        })?;
476                    autenticado = auth.success();
477                    if !autenticado {
478                        tracing::warn!(host = %host, "auth por chave rejeitada; tentando senha se houver");
479                    }
480                }
481
482                if !autenticado && !senha_segura.expose_secret().is_empty() {
483                    let auth = sessao
484                        .authenticate_password(usuario.clone(), senha_segura.expose_secret())
485                        .await
486                        .map_err(|e| {
487                            ErroSshCli::ConexaoFalhou(format!("falha auth password: {e}"))
488                        })?;
489                    autenticado = auth.success();
490                }
491
492                if !autenticado {
493                    tracing::warn!(host = %host, usuario = %usuario, "autenticação SSH rejeitada");
494                    return Err(ErroSshCli::AutenticacaoFalhou);
495                }
496
497                Ok::<_, ErroSshCli>(sessao)
498            })
499            .await;
500
501            let sessao = match resultado_conexao {
502                Ok(Ok(s)) => s,
503                Ok(Err(erro)) => return Err(erro),
504                Err(_) => return Err(ErroSshCli::TimeoutSsh(cfg.timeout_ms)),
505            };
506
507            tracing::info!("conexão SSH autenticada com sucesso");
508
509            Ok(Self { sessao, cfg })
510        }
511
512        /// Executa um comando shell remoto e captura stdout/stderr em paralelo.
513        ///
514        /// Trunca cada stream em `max_chars` codepoints UTF-8. Respeita o
515        /// `timeout_ms` da configuração para a execução inteira.
516        ///
517        /// # Erros
518        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal ou enviar `exec`.
519        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
520        pub async fn executar_comando(
521            &mut self,
522            comando: &str,
523            max_chars: usize,
524            stdin_data: Option<Vec<u8>>,
525        ) -> ResultadoSshCli<SaidaExecucao> {
526            self.executar_comando_interno(comando, max_chars, true, stdin_data)
527                .await
528        }
529
530        async fn executar_comando_interno(
531            &mut self,
532            comando: &str,
533            max_chars: usize,
534            abort_em_timeout: bool,
535            stdin_data: Option<Vec<u8>>,
536        ) -> ResultadoSshCli<SaidaExecucao> {
537            let inicio = Instant::now();
538            let timeout = Duration::from_millis(self.cfg.timeout_ms);
539
540            let resultado = tokio::time::timeout(timeout, async {
541                let mut canal = self
542                    .sessao
543                    .channel_open_session()
544                    .await
545                    .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão: {e}")))?;
546
547                canal
548                    .exec(true, comando)
549                    .await
550                    .map_err(|e| ErroSshCli::CanalFalhou(format!("exec: {e}")))?;
551
552                // Senha sudo/su no stdin do canal — nunca na cmdline remota (SEC-001).
553                if let Some(bytes) = stdin_data.as_ref() {
554                    canal
555                        .data(&bytes[..])
556                        .await
557                        .map_err(|e| ErroSshCli::CanalFalhou(format!("stdin canal: {e}")))?;
558                    canal
559                        .eof()
560                        .await
561                        .map_err(|e| ErroSshCli::CanalFalhou(format!("eof canal: {e}")))?;
562                }
563
564                let mut stdout_bytes: Vec<u8> = Vec::new();
565                let mut stderr_bytes: Vec<u8> = Vec::new();
566                let mut exit_code: Option<i32> = None;
567
568                while let Some(msg) = canal.wait().await {
569                    if processar_mensagem_exec(
570                        msg,
571                        &mut stdout_bytes,
572                        &mut stderr_bytes,
573                        &mut exit_code,
574                    ) {
575                        break;
576                    }
577                }
578
579                Ok::<_, ErroSshCli>((stdout_bytes, stderr_bytes, exit_code))
580            })
581            .await;
582
583            let (stdout_bytes, stderr_bytes, exit_code) = match resultado {
584                Ok(Ok(t)) => t,
585                Ok(Err(erro)) => return Err(erro),
586                Err(_) => {
587                    if abort_em_timeout {
588                        if let Some(padrao) = crate::ssh::packing::padrao_abort_remoto(comando) {
589                            let abort_cmd = crate::ssh::packing::empacotar_abort_pkill(&padrao);
590                            tracing::warn!(
591                                padrao = %padrao,
592                                "timeout local; tentando abort remoto best-effort"
593                            );
594                            let _ = self.tentar_abort_remoto(&abort_cmd).await;
595                        }
596                    }
597                    return Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms));
598                }
599            };
600
601            let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
602            let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
603
604            let (stdout_truncado, truncado_stdout) = super::truncar_utf8(&stdout_str, max_chars);
605            let (stderr_truncado, truncado_stderr) = super::truncar_utf8(&stderr_str, max_chars);
606
607            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
608
609            Ok(SaidaExecucao {
610                stdout: stdout_truncado,
611                stderr: stderr_truncado,
612                exit_code,
613                truncado_stdout,
614                truncado_stderr,
615                duracao_ms,
616            })
617        }
618
619        /// Upload de arquivo local para remote via SCP.
620        ///
621        /// # Erros
622        /// - [`ErroSshCli::ArquivoNaoEncontrado`] se o arquivo local não existir.
623        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP.
624        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
625        pub async fn upload(
626            &mut self,
627            local: &std::path::Path,
628            remote: &std::path::Path,
629        ) -> ResultadoSshCli<TransferenciaResultado> {
630            use russh::ChannelMsg;
631            use std::time::Instant;
632
633            let local_str = local.display().to_string();
634            let remote_str = remote.display().to_string();
635
636            let metadados = std::fs::metadata(local).map_err(|e| {
637                if e.kind() == std::io::ErrorKind::NotFound {
638                    ErroSshCli::ArquivoNaoEncontrado(local_str.clone())
639                } else {
640                    ErroSshCli::Io(e)
641                }
642            })?;
643
644            if !metadados.is_file() {
645                return Err(ErroSshCli::ArgumentoInvalido(
646                    "upload só suporta arquivos regulares".to_string(),
647                ));
648            }
649
650            let tamanho = metadados.len();
651            let nome_arquivo = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
652
653            let inicio = Instant::now();
654            let timeout = Duration::from_millis(self.cfg.timeout_ms);
655
656            let resultado =
657                tokio::time::timeout(timeout, async {
658                    let mut canal =
659                        self.sessao.channel_open_session().await.map_err(|e| {
660                            ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}"))
661                        })?;
662
663                    let comando = format!("scp -t -p {}", remote_str);
664                    canal
665                        .exec(true, comando.as_str())
666                        .await
667                        .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
668
669                    canal.wait().await.ok_or_else(|| {
670                        ErroSshCli::CanalFalhou("canal fechou prematuramente".to_string())
671                    })?;
672
673                    let resposta = formatar_header_upload_scp(tamanho, nome_arquivo);
674                    canal
675                        .data(resposta.as_bytes())
676                        .await
677                        .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar header SCP: {e}")))?;
678
679                    canal.wait().await.ok_or_else(|| {
680                        ErroSshCli::CanalFalhou("canal fechou durante header".to_string())
681                    })?;
682
683                    let conteudo = std::fs::read(local).map_err(ErroSshCli::Io)?;
684                    let mut offset = 0;
685                    let tamanho_bloco = 32768;
686
687                    while offset < conteudo.len() {
688                        let fim = std::cmp::min(offset + tamanho_bloco, conteudo.len());
689                        let bloco = &conteudo[offset..fim];
690                        canal.data(bloco).await.map_err(|e| {
691                            ErroSshCli::CanalFalhou(format!("enviar bloco SCP: {e}"))
692                        })?;
693                        offset = fim;
694                    }
695
696                    canal
697                        .data(&[] as &[u8])
698                        .await
699                        .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar EOF SCP: {e}")))?;
700
701                    canal.wait().await.ok_or_else(|| {
702                        ErroSshCli::CanalFalhou("canal fechou durante transferência".to_string())
703                    })?;
704
705                    while let Some(msg) = canal.wait().await {
706                        if let ChannelMsg::Close = msg {
707                            break;
708                        }
709                    }
710
711                    Ok::<_, ErroSshCli>(())
712                })
713                .await;
714
715            resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
716
717            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
718
719            Ok(TransferenciaResultado {
720                bytes_transferidos: tamanho,
721                duracao_ms,
722            })
723        }
724
725        /// Download de arquivo remote para local via SCP.
726        ///
727        /// # Erros
728        /// - [`ErroSshCli::Io`] se não conseguir escrever o arquivo local.
729        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP.
730        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
731        pub async fn download(
732            &mut self,
733            remote: &std::path::Path,
734            local: &std::path::Path,
735        ) -> ResultadoSshCli<TransferenciaResultado> {
736            use russh::ChannelMsg;
737            use std::io::Write;
738            use std::time::Instant;
739
740            let remote_str = remote.display().to_string();
741
742            let inicio = Instant::now();
743            let timeout = Duration::from_millis(self.cfg.timeout_ms);
744
745            let resultado = tokio::time::timeout(timeout, async {
746                let mut canal = self
747                    .sessao
748                    .channel_open_session()
749                    .await
750                    .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}")))?;
751
752                let comando = format!("scp -f -p {}", remote_str);
753                canal
754                    .exec(true, comando.as_str())
755                    .await
756                    .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
757
758                canal
759                    .data(&[] as &[u8])
760                    .await
761                    .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack inicial: {e}")))?;
762
763                let mut msg = canal.wait().await.ok_or_else(|| {
764                    ErroSshCli::CanalFalhou("canal fechou esperando header".to_string())
765                })?;
766
767                let ChannelMsg::Data { data } = msg else {
768                    return Err(ErroSshCli::CanalFalhou(
769                        "esperava dados do servidor".to_string(),
770                    ));
771                };
772
773                let header = String::from_utf8_lossy(&data);
774                let tamanho = parse_header_scp(&header)?;
775
776                canal
777                    .data(&[] as &[u8])
778                    .await
779                    .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack: {e}")))?;
780
781                if let Some(pai) = local.parent() {
782                    std::fs::create_dir_all(pai)?;
783                }
784
785                let mut arquivo = std::fs::File::create(local).map_err(ErroSshCli::Io)?;
786                let mut recebidos: u64 = 0;
787
788                while recebidos < tamanho {
789                    msg = canal.wait().await.ok_or_else(|| {
790                        ErroSshCli::CanalFalhou("canal fechou durante download".to_string())
791                    })?;
792
793                    let ChannelMsg::Data { data } = msg else {
794                        continue;
795                    };
796
797                    let bytes = data.as_ref();
798                    if bytes.is_empty() {
799                        continue;
800                    }
801
802                    arquivo.write_all(bytes).map_err(ErroSshCli::Io)?;
803                    recebidos += bytes.len() as u64;
804
805                    canal.data(&[] as &[u8]).await.map_err(|e| {
806                        ErroSshCli::CanalFalhou(format!("enviar ack durante download: {e}"))
807                    })?;
808                }
809
810                while let Some(msg) = canal.wait().await {
811                    if let ChannelMsg::Close = msg {
812                        break;
813                    }
814                }
815
816                Ok::<_, ErroSshCli>(recebidos)
817            })
818            .await;
819
820            let recebidos =
821                resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
822
823            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
824
825            Ok(TransferenciaResultado {
826                bytes_transferidos: recebidos,
827                duracao_ms,
828            })
829        }
830
831        /// Abort remoto best-effort: reconecta com timeout curto e executa pkill.
832        async fn tentar_abort_remoto(&self, abort_cmd: &str) -> ResultadoSshCli<()> {
833            // Implementação inline (sem chamar executar_comando_interno) evita
834            // recursão async detectada pelo compilador.
835            let mut cfg_abort = self.cfg.clone();
836            cfg_abort.timeout_ms = cfg_abort.timeout_ms.clamp(3_000, 10_000);
837            let outro = match Self::conectar(cfg_abort).await {
838                Ok(c) => c,
839                Err(e) => {
840                    tracing::debug!(erro = %e, "abort remoto não pôde reconectar");
841                    return Err(e);
842                }
843            };
844            let timeout = Duration::from_millis(outro.cfg.timeout_ms);
845            let _ = tokio::time::timeout(timeout, async {
846                let mut canal = outro
847                    .sessao
848                    .channel_open_session()
849                    .await
850                    .map_err(|e| ErroSshCli::CanalFalhou(format!("abort canal: {e}")))?;
851                canal
852                    .exec(true, abort_cmd)
853                    .await
854                    .map_err(|e| ErroSshCli::CanalFalhou(format!("abort exec: {e}")))?;
855                while let Some(msg) = canal.wait().await {
856                    if matches!(msg, russh::ChannelMsg::Close) {
857                        break;
858                    }
859                }
860                Ok::<(), ErroSshCli>(())
861            })
862            .await;
863            let _ = outro.desconectar().await;
864            Ok(())
865        }
866
867        /// Encerra a sessão SSH de forma limpa.
868        ///
869        /// # Erros
870        /// Propaga falha se `disconnect` retornar erro do transporte.
871        pub async fn desconectar(&self) -> ResultadoSshCli<()> {
872            let resultado = self
873                .sessao
874                .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
875                .await;
876            match resultado {
877                Ok(()) => {
878                    tracing::info!("sessão SSH encerrada");
879                    Ok(())
880                }
881                Err(e) => {
882                    tracing::warn!(erro = %e, "falha ao encerrar sessão SSH");
883                    Err(ErroSshCli::ConexaoFalhou(format!(
884                        "falha ao desconectar: {e}"
885                    )))
886                }
887            }
888        }
889
890        /// Abre canal direct-tcpip para forwarding SSH.
891        pub async fn abrir_canal_tunel(
892            &self,
893            host_remoto: &str,
894            porta_remota: u16,
895            endereco_origem: &str,
896            porta_origem: u16,
897        ) -> ResultadoSshCli<Box<dyn CanalTunel>> {
898            let canal = self
899                .sessao
900                .channel_open_direct_tcpip(
901                    host_remoto.to_string(),
902                    u32::from(porta_remota),
903                    endereco_origem.to_string(),
904                    u32::from(porta_origem),
905                )
906                .await
907                .map_err(|e| {
908                    ErroSshCli::CanalFalhou(format!(
909                        "falha ao abrir canal direct-tcpip para {}:{}: {}",
910                        host_remoto, porta_remota, e
911                    ))
912                })?;
913
914            Ok(Box::new(canal.into_stream()))
915        }
916    }
917
918    #[async_trait]
919    impl ClienteSshTrait for ClienteSsh {
920        async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
921            Self::conectar(cfg).await.map(Box::new)
922        }
923
924        async fn executar_comando(
925            &mut self,
926            cmd: &str,
927            max_chars: usize,
928            stdin_data: Option<Vec<u8>>,
929        ) -> Result<SaidaExecucao, ErroSshCli> {
930            Self::executar_comando(self, cmd, max_chars, stdin_data).await
931        }
932
933        async fn upload(
934            &mut self,
935            local: &Path,
936            remote: &Path,
937        ) -> Result<TransferenciaResultado, ErroSshCli> {
938            Self::upload(self, local, remote).await
939        }
940
941        async fn download(
942            &mut self,
943            remote: &Path,
944            local: &Path,
945        ) -> Result<TransferenciaResultado, ErroSshCli> {
946            Self::download(self, remote, local).await
947        }
948
949        async fn abrir_canal_tunel(
950            &self,
951            host_remoto: &str,
952            porta_remota: u16,
953            endereco_origem: &str,
954            porta_origem: u16,
955        ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
956            Self::abrir_canal_tunel(
957                self,
958                host_remoto,
959                porta_remota,
960                endereco_origem,
961                porta_origem,
962            )
963            .await
964        }
965
966        async fn desconectar(&self) -> Result<(), ErroSshCli> {
967            Self::desconectar(self).await
968        }
969    }
970
971    #[cfg(test)]
972    mod testes_real {
973        use super::{
974            formatar_header_upload_scp, mapear_exit_status, parse_header_scp,
975            processar_mensagem_exec,
976        };
977
978        #[test]
979        fn mapear_exit_status_normal() {
980            assert_eq!(mapear_exit_status(0), 0);
981            assert_eq!(mapear_exit_status(255), 255);
982        }
983
984        #[test]
985        fn mapear_exit_status_overflow_retorna_menos_um() {
986            assert_eq!(mapear_exit_status(u32::MAX), -1);
987        }
988
989        #[test]
990        fn parse_header_scp_valido_retorna_tamanho() {
991            let tamanho = parse_header_scp("C0644 42 arquivo.txt\n").expect("header válido");
992            assert_eq!(tamanho, 42);
993        }
994
995        #[test]
996        fn parse_header_scp_invalido_retorna_erro() {
997            assert!(parse_header_scp("ERRO").is_err());
998            assert!(parse_header_scp("C0644 sem_tamanho").is_err());
999            assert!(parse_header_scp("C0644 abc arquivo").is_err());
1000        }
1001
1002        #[test]
1003        fn processar_mensagem_exec_trata_stdout_stderr_e_close() {
1004            let mut stdout = Vec::new();
1005            let mut stderr = Vec::new();
1006            let mut exit_code = None;
1007
1008            let deve_parar = processar_mensagem_exec(
1009                russh::ChannelMsg::Data {
1010                    data: b"stdout".to_vec().into(),
1011                },
1012                &mut stdout,
1013                &mut stderr,
1014                &mut exit_code,
1015            );
1016            assert!(!deve_parar);
1017            assert_eq!(stdout, b"stdout");
1018
1019            let deve_parar = processar_mensagem_exec(
1020                russh::ChannelMsg::ExtendedData {
1021                    data: b"stderr".to_vec().into(),
1022                    ext: 1,
1023                },
1024                &mut stdout,
1025                &mut stderr,
1026                &mut exit_code,
1027            );
1028            assert!(!deve_parar);
1029            assert_eq!(stderr, b"stderr");
1030
1031            let _ = processar_mensagem_exec(
1032                russh::ChannelMsg::ExitStatus { exit_status: 17 },
1033                &mut stdout,
1034                &mut stderr,
1035                &mut exit_code,
1036            );
1037            assert_eq!(exit_code, Some(17));
1038
1039            let deve_parar = processar_mensagem_exec(
1040                russh::ChannelMsg::Close,
1041                &mut stdout,
1042                &mut stderr,
1043                &mut exit_code,
1044            );
1045            assert!(deve_parar);
1046        }
1047
1048        #[test]
1049        fn formatar_header_upload_scp_gera_formato_esperado() {
1050            let header = formatar_header_upload_scp(123, "arquivo.txt");
1051            assert_eq!(header, "C0644 123 arquivo.txt\\n");
1052        }
1053
1054        #[test]
1055        fn processar_mensagem_exec_ignora_extendido_com_codigo_diferente_de_stderr() {
1056            let mut stdout = Vec::new();
1057            let mut stderr = Vec::new();
1058            let mut exit_code = None;
1059
1060            let deve_parar = processar_mensagem_exec(
1061                russh::ChannelMsg::ExtendedData {
1062                    data: b"nao-e-stderr".to_vec().into(),
1063                    ext: 2,
1064                },
1065                &mut stdout,
1066                &mut stderr,
1067                &mut exit_code,
1068            );
1069
1070            assert!(!deve_parar);
1071            assert!(stdout.is_empty());
1072            assert!(stderr.is_empty());
1073            assert!(exit_code.is_none());
1074        }
1075
1076        #[test]
1077        fn processar_mensagem_exec_trata_exit_signal_e_eof_sem_encerrar_loop() {
1078            let mut stdout = Vec::new();
1079            let mut stderr = Vec::new();
1080            let mut exit_code = Some(7);
1081
1082            let deve_parar_signal = processar_mensagem_exec(
1083                russh::ChannelMsg::ExitSignal {
1084                    signal_name: russh::Sig::TERM,
1085                    core_dumped: false,
1086                    error_message: "encerrado".to_string(),
1087                    lang_tag: "pt-BR".to_string(),
1088                },
1089                &mut stdout,
1090                &mut stderr,
1091                &mut exit_code,
1092            );
1093
1094            let deve_parar_eof = processar_mensagem_exec(
1095                russh::ChannelMsg::Eof,
1096                &mut stdout,
1097                &mut stderr,
1098                &mut exit_code,
1099            );
1100
1101            assert!(!deve_parar_signal);
1102            assert!(!deve_parar_eof);
1103            assert_eq!(exit_code, Some(7));
1104        }
1105
1106        #[test]
1107        fn processar_mensagem_exec_ignora_variantes_sem_tratamento_especifico() {
1108            let mut stdout = Vec::new();
1109            let mut stderr = Vec::new();
1110            let mut exit_code = None;
1111
1112            let deve_parar = processar_mensagem_exec(
1113                russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
1114                &mut stdout,
1115                &mut stderr,
1116                &mut exit_code,
1117            );
1118
1119            assert!(!deve_parar);
1120            assert!(stdout.is_empty());
1121            assert!(stderr.is_empty());
1122            assert!(exit_code.is_none());
1123        }
1124    }
1125}
1126
1127#[cfg(feature = "ssh-real")]
1128pub use real::{ClienteSsh, ManipuladorCliente};
1129
1130// =========================================================================
1131// Stub usado quando a feature `ssh-real` está DESATIVADA.
1132// =========================================================================
1133
1134#[cfg(not(feature = "ssh-real"))]
1135mod stub {
1136    use super::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
1137    use crate::erros::ErroSshCli;
1138    use crate::ssh::cliente::ClienteSshTrait;
1139    use async_trait::async_trait;
1140    use std::path::Path;
1141
1142    /// Stub quando `ssh-real` está desativado: sempre retorna
1143    /// [`ErroSshCli::ConexaoFalhou`].
1144    #[derive(Debug)]
1145    pub struct ClienteSsh;
1146
1147    #[async_trait]
1148    impl ClienteSshTrait for ClienteSsh {
1149        async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
1150            Err(ErroSshCli::ConexaoFalhou(
1151                "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
1152            ))
1153        }
1154
1155        async fn executar_comando(
1156            &mut self,
1157            _cmd: &str,
1158            _max_chars: usize,
1159            _stdin_data: Option<Vec<u8>>,
1160        ) -> Result<SaidaExecucao, ErroSshCli> {
1161            Err(ErroSshCli::CanalFalhou(
1162                "stub sem russh: feature `ssh-real` desabilitada".into(),
1163            ))
1164        }
1165
1166        async fn upload(
1167            &mut self,
1168            _local: &Path,
1169            _remote: &Path,
1170        ) -> Result<TransferenciaResultado, ErroSshCli> {
1171            Err(ErroSshCli::CanalFalhou(
1172                "stub sem russh: feature `ssh-real` desabilitada".into(),
1173            ))
1174        }
1175
1176        async fn download(
1177            &mut self,
1178            _remote: &Path,
1179            _local: &Path,
1180        ) -> Result<TransferenciaResultado, ErroSshCli> {
1181            Err(ErroSshCli::CanalFalhou(
1182                "stub sem russh: feature `ssh-real` desabilitada".into(),
1183            ))
1184        }
1185
1186        async fn abrir_canal_tunel(
1187            &self,
1188            _host_remoto: &str,
1189            _porta_remota: u16,
1190            _endereco_origem: &str,
1191            _porta_origem: u16,
1192        ) -> Result<Box<dyn super::CanalTunel>, ErroSshCli> {
1193            Err(ErroSshCli::CanalFalhou(
1194                "stub sem russh: feature `ssh-real` desabilitada".into(),
1195            ))
1196        }
1197
1198        async fn desconectar(&self) -> Result<(), ErroSshCli> {
1199            Ok(())
1200        }
1201    }
1202}
1203
1204#[cfg(not(feature = "ssh-real"))]
1205pub use stub::ClienteSsh;
1206
1207// =========================================================================
1208// Testes unitários (sem rede, sem feature gate).
1209// =========================================================================
1210
1211#[cfg(test)]
1212mod testes {
1213    use super::*;
1214    use secrecy::SecretString;
1215
1216    fn cfg_valida() -> ConfiguracaoConexao {
1217        ConfiguracaoConexao {
1218            host: "127.0.0.1".to_string(),
1219            porta: 22,
1220            usuario: "root".to_string(),
1221            senha: SecretString::from("senha-exemplo".to_string()),
1222            key_path: None,
1223            key_passphrase: None,
1224            timeout_ms: 5000,
1225            known_hosts_path: None,
1226            replace_host_key: false,
1227        }
1228    }
1229
1230    #[test]
1231    fn validar_host_vazio_retorna_erro() {
1232        let mut c = cfg_valida();
1233        c.host = String::new();
1234        let r = c.validar();
1235        assert!(r.is_err());
1236        let msg = r.unwrap_err().to_string();
1237        assert!(msg.contains("host"));
1238    }
1239
1240    #[test]
1241    fn validar_host_apenas_espacos_retorna_erro() {
1242        let mut c = cfg_valida();
1243        c.host = "   ".to_string();
1244        assert!(c.validar().is_err());
1245    }
1246
1247    #[test]
1248    fn validar_porta_zero_retorna_erro() {
1249        let mut c = cfg_valida();
1250        c.porta = 0;
1251        let r = c.validar();
1252        assert!(r.is_err());
1253        let msg = r.unwrap_err().to_string();
1254        assert!(msg.contains("porta"));
1255    }
1256
1257    #[test]
1258    fn validar_usuario_vazio_retorna_erro() {
1259        let mut c = cfg_valida();
1260        c.usuario = String::new();
1261        assert!(c.validar().is_err());
1262    }
1263
1264    #[test]
1265    fn validar_configuracao_correta_retorna_ok() {
1266        assert!(cfg_valida().validar().is_ok());
1267    }
1268
1269    #[test]
1270    fn debug_nao_expoe_senha() {
1271        let c = cfg_valida();
1272        let dbg = format!("{c:?}");
1273        assert!(!dbg.contains("senha-exemplo"));
1274        assert!(dbg.contains("redacted"));
1275    }
1276
1277    #[test]
1278    fn truncar_utf8_nao_trunca_se_cabe() {
1279        let (s, t) = truncar_utf8("ola mundo", 100);
1280        assert_eq!(s, "ola mundo");
1281        assert!(!t);
1282    }
1283
1284    #[test]
1285    fn truncar_utf8_trunca_string_grande_ascii() {
1286        let entrada: String = "a".repeat(200);
1287        let (s, t) = truncar_utf8(&entrada, 50);
1288        assert_eq!(s.chars().count(), 50);
1289        assert!(t);
1290    }
1291
1292    #[test]
1293    fn truncar_utf8_preserva_grafemas_acentuados() {
1294        // 10 codepoints: "á" (1 char) * 10
1295        let entrada: String = "á".repeat(30);
1296        let (s, t) = truncar_utf8(&entrada, 10);
1297        assert_eq!(s.chars().count(), 10);
1298        // Cada 'á' ocupa 2 bytes em UTF-8 → 10 chars = 20 bytes
1299        assert_eq!(s.len(), 20);
1300        assert!(t);
1301        // Não corta no meio de byte
1302        assert!(s.chars().all(|c| c == 'á'));
1303    }
1304
1305    #[test]
1306    fn truncar_utf8_com_emojis_nao_quebra() {
1307        let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1308        let (s, t) = truncar_utf8(entrada, 5);
1309        assert_eq!(s.chars().count(), 5);
1310        assert!(t);
1311    }
1312
1313    #[test]
1314    fn truncar_utf8_zero_retorna_vazio() {
1315        let (s, t) = truncar_utf8("abc", 0);
1316        assert_eq!(s, "");
1317        assert!(t);
1318    }
1319
1320    #[test]
1321    fn saida_execucao_debug_nao_crasha() {
1322        let s = SaidaExecucao {
1323            stdout: "ok".into(),
1324            stderr: String::new(),
1325            exit_code: Some(0),
1326            truncado_stdout: false,
1327            truncado_stderr: false,
1328            duracao_ms: 42,
1329        };
1330        let _ = format!("{s:?}");
1331    }
1332
1333    #[test]
1334    fn duracao_ms_tipo_compativel() {
1335        // Garantia estática de que instant elapsed cabe em u64.
1336        let fake: u64 = 1234;
1337        assert_eq!(fake, 1234_u64);
1338    }
1339}