Skip to main content

ssh_cli/ssh/
cliente.rs

1//! Cliente SSH real via `russh` 0.60.x.
2//!
3//! Implementa conexão TCP + handshake SSH + autenticação por senha + execução
4//! de comandos com captura paralela de stdout/stderr.
5//!
6//! Na iteração 2 a verificação de chave de servidor (`check_server_key`) é
7//! permissiva (trust-on-first-use sem persistência). Iterações futuras devem:
8//! - persistir fingerprints em `known_hosts`
9//! - suportar autenticação por chave pública
10//! - suportar `sudo` e `su -` via PTY + stdin
11//!
12//! Quando a feature `ssh-real` está DESATIVADA (ex.: `--no-default-features`),
13//! o módulo exporta apenas a `ConfiguracaoConexao` e stubs mínimos — o código
14//! de alto nível da CLI deve compilar sem russh.
15
16use crate::erros::{ErroSshCli, ResultadoSshCli};
17use secrecy::SecretString;
18use tokio::io::{AsyncRead, AsyncWrite};
19
20/// Configuração de uma conexão SSH.
21///
22/// Construída a partir de um [`crate::vps::modelo::VpsRegistro`] no momento
23/// da chamada, carregando apenas os campos necessários. A senha continua
24/// protegida por [`SecretString`] (zeroize on drop).
25#[derive(Clone)]
26pub struct ConfiguracaoConexao {
27    /// Hostname ou IP do servidor SSH.
28    pub host: String,
29    /// Porta TCP do servidor SSH (padrão 22).
30    pub porta: u16,
31    /// Nome de usuário SSH.
32    pub usuario: String,
33    /// Senha SSH (`SecretString` para zeroize automático).
34    pub senha: SecretString,
35    /// Timeout total para conexão + handshake + autenticação, em milissegundos.
36    pub timeout_ms: u64,
37}
38
39impl std::fmt::Debug for ConfiguracaoConexao {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("ConfiguracaoConexao")
42            .field("host", &self.host)
43            .field("porta", &self.porta)
44            .field("usuario", &self.usuario)
45            .field("senha", &"<redacted>")
46            .field("timeout_ms", &self.timeout_ms)
47            .finish()
48    }
49}
50
51impl ConfiguracaoConexao {
52    /// Valida os campos básicos da configuração.
53    ///
54    /// Retorna [`ErroSshCli::ArgumentoInvalido`] se host estiver vazio ou porta for 0.
55    pub fn validar(&self) -> ResultadoSshCli<()> {
56        if self.host.trim().is_empty() {
57            return Err(ErroSshCli::ArgumentoInvalido(
58                "host vazio em ConfiguracaoConexao".to_string(),
59            ));
60        }
61        if self.porta == 0 {
62            return Err(ErroSshCli::ArgumentoInvalido(
63                "porta 0 inválida em ConfiguracaoConexao".to_string(),
64            ));
65        }
66        if self.usuario.trim().is_empty() {
67            return Err(ErroSshCli::ArgumentoInvalido(
68                "usuário vazio em ConfiguracaoConexao".to_string(),
69            ));
70        }
71        Ok(())
72    }
73}
74
75/// Saída da execução de um comando SSH remoto.
76#[derive(Debug, Clone)]
77pub struct SaidaExecucao {
78    /// Stdout capturado (possivelmente truncado a `max_chars` codepoints).
79    pub stdout: String,
80    /// Stderr capturado (possivelmente truncado a `max_chars` codepoints).
81    pub stderr: String,
82    /// Código de saída. `None` quando o comando foi terminado por sinal ou timeout.
83    pub exit_code: Option<i32>,
84    /// `true` se `stdout` foi truncado em `max_chars`.
85    pub truncado_stdout: bool,
86    /// `true` se `stderr` foi truncado em `max_chars`.
87    pub truncado_stderr: bool,
88    /// Duração total da execução, em milissegundos.
89    pub duracao_ms: u64,
90}
91
92/// Resultado de uma operação de transferência de arquivo via SCP.
93#[derive(Debug, Clone)]
94pub struct TransferenciaResultado {
95    /// Número de bytes transferidos.
96    pub bytes_transferidos: u64,
97    /// Duração total em milissegundos.
98    pub duracao_ms: u64,
99}
100
101/// Trunca uma string UTF-8 a no máximo `max_chars` codepoints.
102///
103/// Retorna `(string_truncada, truncou)`. Se `max_chars == 0` retorna string vazia.
104/// Unicode-safe: opera sobre codepoints via `chars()`, nunca quebra no meio.
105#[must_use]
106pub fn truncar_utf8(conteudo: &str, max_chars: usize) -> (String, bool) {
107    let total = conteudo.chars().count();
108    if total <= max_chars {
109        return (conteudo.to_string(), false);
110    }
111    let truncado: String = conteudo.chars().take(max_chars).collect();
112    (truncado, true)
113}
114
115// =========================================================================
116// Trait ClienteSshTrait para permitir mocks em teste.
117// =========================================================================
118
119use async_trait::async_trait;
120use std::path::Path;
121
122/// Stream bidirecional usado para tunnel SSH (direct-tcpip).
123pub trait CanalTunel: AsyncRead + AsyncWrite + Unpin + Send {}
124
125impl<T> CanalTunel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
126
127/// Trait para cliente SSH que permite implementação real (russh) ou mock para testes.
128///
129/// Este trait abstrai as operações de conexão SSH para permitir testes unitários
130/// sem necessidade de conexão de rede real.
131#[async_trait]
132pub trait ClienteSshTrait: Send + Sync + 'static {
133    /// Conecta a um servidor SSH e autentica com as credenciais fornecidas.
134    async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>
135    where
136        Self: Sized;
137
138    /// Executa um comando shell remoto e retorna a saída capturada.
139    async fn executar_comando(
140        &mut self,
141        cmd: &str,
142        max_chars: usize,
143    ) -> Result<SaidaExecucao, ErroSshCli>;
144
145    /// Faz upload de um arquivo local para o servidor remoto via SCP.
146    async fn upload(
147        &mut self,
148        local: &Path,
149        remote: &Path,
150    ) -> Result<TransferenciaResultado, ErroSshCli>;
151
152    /// Faz download de um arquivo remoto para o sistema local via SCP.
153    async fn download(
154        &mut self,
155        remote: &Path,
156        local: &Path,
157    ) -> Result<TransferenciaResultado, ErroSshCli>;
158
159    /// Abre um canal `direct-tcpip` para forwarding de tunnel.
160    async fn abrir_canal_tunel(
161        &self,
162        host_remoto: &str,
163        porta_remota: u16,
164        endereco_origem: &str,
165        porta_origem: u16,
166    ) -> Result<Box<dyn CanalTunel>, ErroSshCli>;
167
168    /// Encerra a conexão SSH de forma limpa.
169    async fn desconectar(&self) -> Result<(), ErroSshCli>;
170}
171
172#[cfg(test)]
173/// Mocks de cliente SSH usados em testes unitários.
174pub mod mocks {
175    use super::*;
176    use mockall::mock;
177
178    mock! {
179        pub ClienteSsh {}
180
181    #[async_trait]
182    impl crate::ssh::cliente::ClienteSshTrait for ClienteSsh {
183            async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli>;
184            async fn executar_comando(&mut self, cmd: &str, max_chars: usize) -> Result<SaidaExecucao, ErroSshCli>;
185            async fn upload(&mut self, local: &Path, remote: &Path) -> Result<TransferenciaResultado, ErroSshCli>;
186            async fn download(&mut self, remote: &Path, local: &Path) -> Result<TransferenciaResultado, ErroSshCli>;
187            async fn abrir_canal_tunel(
188                &self,
189                host_remoto: &str,
190                porta_remota: u16,
191                endereco_origem: &str,
192                porta_origem: u16,
193            ) -> Result<Box<dyn CanalTunel>, ErroSshCli>;
194            async fn desconectar(&self) -> Result<(), ErroSshCli>;
195        }
196    }
197}
198
199// =========================================================================
200// Implementação SSH REAL (feature `ssh-real`).
201// =========================================================================
202
203#[cfg(feature = "ssh-real")]
204mod real {
205    use super::{
206        CanalTunel, ClienteSshTrait, ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado,
207    };
208    use crate::erros::{ErroSshCli, ResultadoSshCli};
209    use async_trait::async_trait;
210    use secrecy::ExposeSecret;
211    use std::path::Path;
212    use std::sync::Arc;
213    use std::time::{Duration, Instant};
214
215    /// Handler permissivo do russh: aceita TODA chave de servidor.
216    ///
217    /// **Aviso de segurança**: iteração 2 usa trust-on-first-use sem persistência.
218    /// Iteração 3+ deve validar contra `known_hosts` para evitar MITM.
219    pub struct ManipuladorCliente;
220
221    impl russh::client::Handler for ManipuladorCliente {
222        type Error = russh::Error;
223
224        async fn check_server_key(
225            &mut self,
226            _chave_servidor: &russh::keys::ssh_key::PublicKey,
227        ) -> Result<bool, Self::Error> {
228            tracing::warn!("check_server_key aceita TODA chave (iteração 2: sem known_hosts)");
229            Ok(true)
230        }
231    }
232
233    /// Cliente SSH ativo com sessão autenticada.
234    pub struct ClienteSsh {
235        /// Sessão SSH autenticada para operações de baixo nível.
236        pub sessao: russh::client::Handle<ManipuladorCliente>,
237        cfg: ConfiguracaoConexao,
238    }
239
240    impl std::fmt::Debug for ClienteSsh {
241        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242            f.debug_struct("ClienteSsh")
243                .field("host", &self.cfg.host)
244                .field("porta", &self.cfg.porta)
245                .field("usuario", &self.cfg.usuario)
246                .field("timeout_ms", &self.cfg.timeout_ms)
247                .finish()
248        }
249    }
250
251    fn mapear_exit_status(exit_status: u32) -> i32 {
252        i32::try_from(exit_status).unwrap_or(-1)
253    }
254
255    fn processar_mensagem_exec(
256        msg: russh::ChannelMsg,
257        stdout_bytes: &mut Vec<u8>,
258        stderr_bytes: &mut Vec<u8>,
259        exit_code: &mut Option<i32>,
260    ) -> bool {
261        use russh::ChannelMsg;
262
263        match msg {
264            ChannelMsg::Data { data } => {
265                stdout_bytes.extend_from_slice(&data);
266            }
267            ChannelMsg::ExtendedData { data, ext } => {
268                // ext == 1 → SSH_EXTENDED_DATA_STDERR (RFC 4254 §5.2).
269                if ext == 1 {
270                    stderr_bytes.extend_from_slice(&data);
271                } else {
272                    tracing::debug!(ext, "dados estendidos ignorados");
273                }
274            }
275            ChannelMsg::ExitStatus { exit_status } => {
276                // russh entrega como u32. Mantemos como i32 para acomodar
277                // convenções Unix (shells podem emitir códigos como u8 em
278                // wait-status; aqui já é o exit code aplicativo, 0..=255).
279                *exit_code = Some(mapear_exit_status(exit_status));
280                // NÃO retorna true: aguardar Eof/Close após ExitStatus.
281            }
282            ChannelMsg::ExitSignal {
283                signal_name,
284                core_dumped,
285                error_message,
286                ..
287            } => {
288                tracing::warn!(
289                    ?signal_name,
290                    core_dumped,
291                    %error_message,
292                    "processo remoto terminou por sinal"
293                );
294                // Sem exit_status → mantemos None.
295            }
296            ChannelMsg::Eof => {
297                tracing::debug!("EOF no canal SSH");
298            }
299            ChannelMsg::Close => {
300                tracing::debug!("canal SSH fechado pelo servidor");
301                return true;
302            }
303            _ => {}
304        }
305
306        false
307    }
308
309    fn formatar_header_upload_scp(tamanho: u64, nome_arquivo: &str) -> String {
310        format!("C0644 {} {}\\n", tamanho, nome_arquivo)
311    }
312
313    fn parse_header_scp(header: &str) -> ResultadoSshCli<u64> {
314        let header = header.trim();
315
316        if !header.starts_with('C') {
317            return Err(ErroSshCli::CanalFalhou(format!(
318                "header SCP inesperado: {}",
319                header
320            )));
321        }
322
323        let partes: Vec<&str> = header.split_whitespace().collect();
324        if partes.len() < 3 {
325            return Err(ErroSshCli::CanalFalhou(format!(
326                "header SCP mal formatado: {}",
327                header
328            )));
329        }
330
331        partes[1].parse().map_err(|_| {
332            ErroSshCli::CanalFalhou(format!("tamanho inválido no header: {}", partes[1]))
333        })
334    }
335
336    impl ClienteSsh {
337        /// Conecta e autentica. Todo o fluxo (TCP + handshake + auth) respeita
338        /// o `timeout_ms` da configuração.
339        ///
340        /// # Erros
341        /// - [`ErroSshCli::ArgumentoInvalido`] se a configuração for inválida.
342        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout total.
343        /// - [`ErroSshCli::ConexaoFalhou`] em falhas TCP/handshake.
344        /// - [`ErroSshCli::AutenticacaoFalhou`] se o servidor rejeitar a senha.
345        pub async fn conectar(cfg: ConfiguracaoConexao) -> ResultadoSshCli<Self> {
346            cfg.validar()?;
347
348            let timeout = Duration::from_millis(cfg.timeout_ms);
349            let host = cfg.host.clone();
350            let porta = cfg.porta;
351            let usuario = cfg.usuario.clone();
352            let senha_segura = cfg.senha.clone();
353
354            let config_cliente = Arc::new(russh::client::Config {
355                inactivity_timeout: Some(timeout),
356                ..Default::default()
357            });
358
359            tracing::info!(
360                host = %host,
361                porta,
362                usuario = %usuario,
363                timeout_ms = cfg.timeout_ms,
364                "iniciando conexão SSH"
365            );
366
367            // Envelopa conexão + handshake + autenticação em um único timeout global.
368            let resultado_conexao = tokio::time::timeout(timeout, async move {
369                let mut sessao = russh::client::connect(
370                    config_cliente,
371                    (host.as_str(), porta),
372                    ManipuladorCliente,
373                )
374                .await
375                .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha TCP/handshake: {e}")))?;
376
377                let auth = sessao
378                    .authenticate_password(usuario.clone(), senha_segura.expose_secret())
379                    .await
380                    .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha auth transport: {e}")))?;
381
382                if !auth.success() {
383                    tracing::warn!(
384                        host = %host,
385                        usuario = %usuario,
386                        "autenticação SSH rejeitada"
387                    );
388                    return Err(ErroSshCli::AutenticacaoFalhou);
389                }
390
391                Ok::<_, ErroSshCli>(sessao)
392            })
393            .await;
394
395            let sessao = match resultado_conexao {
396                Ok(Ok(s)) => s,
397                Ok(Err(erro)) => return Err(erro),
398                Err(_) => return Err(ErroSshCli::TimeoutSsh(cfg.timeout_ms)),
399            };
400
401            tracing::info!("conexão SSH autenticada com sucesso");
402
403            Ok(Self { sessao, cfg })
404        }
405
406        /// Executa um comando shell remoto e captura stdout/stderr em paralelo.
407        ///
408        /// Trunca cada stream em `max_chars` codepoints UTF-8. Respeita o
409        /// `timeout_ms` da configuração para a execução inteira.
410        ///
411        /// # Erros
412        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal ou enviar `exec`.
413        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
414        pub async fn executar_comando(
415            &mut self,
416            comando: &str,
417            max_chars: usize,
418        ) -> ResultadoSshCli<SaidaExecucao> {
419            let inicio = Instant::now();
420            let timeout = Duration::from_millis(self.cfg.timeout_ms);
421
422            let resultado = tokio::time::timeout(timeout, async {
423                let mut canal = self
424                    .sessao
425                    .channel_open_session()
426                    .await
427                    .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão: {e}")))?;
428
429                canal
430                    .exec(true, comando)
431                    .await
432                    .map_err(|e| ErroSshCli::CanalFalhou(format!("exec: {e}")))?;
433
434                let mut stdout_bytes: Vec<u8> = Vec::new();
435                let mut stderr_bytes: Vec<u8> = Vec::new();
436                let mut exit_code: Option<i32> = None;
437
438                while let Some(msg) = canal.wait().await {
439                    if processar_mensagem_exec(
440                        msg,
441                        &mut stdout_bytes,
442                        &mut stderr_bytes,
443                        &mut exit_code,
444                    ) {
445                        break;
446                    }
447                }
448
449                Ok::<_, ErroSshCli>((stdout_bytes, stderr_bytes, exit_code))
450            })
451            .await;
452
453            let (stdout_bytes, stderr_bytes, exit_code) = match resultado {
454                Ok(Ok(t)) => t,
455                Ok(Err(erro)) => return Err(erro),
456                Err(_) => return Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms)),
457            };
458
459            // Converte de bytes para String UTF-8 de forma resiliente.
460            let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
461            let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
462
463            let (stdout_truncado, truncado_stdout) = super::truncar_utf8(&stdout_str, max_chars);
464            let (stderr_truncado, truncado_stderr) = super::truncar_utf8(&stderr_str, max_chars);
465
466            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
467
468            Ok(SaidaExecucao {
469                stdout: stdout_truncado,
470                stderr: stderr_truncado,
471                exit_code,
472                truncado_stdout,
473                truncado_stderr,
474                duracao_ms,
475            })
476        }
477
478        /// Upload de arquivo local para remote via SCP.
479        ///
480        /// # Erros
481        /// - [`ErroSshCli::ArquivoNaoEncontrado`] se o arquivo local não existir.
482        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP.
483        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
484        pub async fn upload(
485            &mut self,
486            local: &std::path::Path,
487            remote: &std::path::Path,
488        ) -> ResultadoSshCli<TransferenciaResultado> {
489            use russh::ChannelMsg;
490            use std::time::Instant;
491
492            let local_str = local.display().to_string();
493            let remote_str = remote.display().to_string();
494
495            let metadados = std::fs::metadata(local).map_err(|e| {
496                if e.kind() == std::io::ErrorKind::NotFound {
497                    ErroSshCli::ArquivoNaoEncontrado(local_str.clone())
498                } else {
499                    ErroSshCli::Io(e)
500                }
501            })?;
502
503            if !metadados.is_file() {
504                return Err(ErroSshCli::ArgumentoInvalido(
505                    "upload só suporta arquivos regulares".to_string(),
506                ));
507            }
508
509            let tamanho = metadados.len();
510            let nome_arquivo = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
511
512            let inicio = Instant::now();
513            let timeout = Duration::from_millis(self.cfg.timeout_ms);
514
515            let resultado =
516                tokio::time::timeout(timeout, async {
517                    let mut canal =
518                        self.sessao.channel_open_session().await.map_err(|e| {
519                            ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}"))
520                        })?;
521
522                    let comando = format!("scp -t -p {}", remote_str);
523                    canal
524                        .exec(true, comando.as_str())
525                        .await
526                        .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
527
528                    canal.wait().await.ok_or_else(|| {
529                        ErroSshCli::CanalFalhou("canal fechou prematuramente".to_string())
530                    })?;
531
532                    let resposta = formatar_header_upload_scp(tamanho, nome_arquivo);
533                    canal
534                        .data(resposta.as_bytes())
535                        .await
536                        .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar header SCP: {e}")))?;
537
538                    canal.wait().await.ok_or_else(|| {
539                        ErroSshCli::CanalFalhou("canal fechou durante header".to_string())
540                    })?;
541
542                    let conteudo = std::fs::read(local).map_err(ErroSshCli::Io)?;
543                    let mut offset = 0;
544                    let tamanho_bloco = 32768;
545
546                    while offset < conteudo.len() {
547                        let fim = std::cmp::min(offset + tamanho_bloco, conteudo.len());
548                        let bloco = &conteudo[offset..fim];
549                        canal.data(bloco).await.map_err(|e| {
550                            ErroSshCli::CanalFalhou(format!("enviar bloco SCP: {e}"))
551                        })?;
552                        offset = fim;
553                    }
554
555                    canal
556                        .data(&[] as &[u8])
557                        .await
558                        .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar EOF SCP: {e}")))?;
559
560                    canal.wait().await.ok_or_else(|| {
561                        ErroSshCli::CanalFalhou("canal fechou durante transferência".to_string())
562                    })?;
563
564                    while let Some(msg) = canal.wait().await {
565                        if let ChannelMsg::Close = msg {
566                            break;
567                        }
568                    }
569
570                    Ok::<_, ErroSshCli>(())
571                })
572                .await;
573
574            resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
575
576            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
577
578            Ok(TransferenciaResultado {
579                bytes_transferidos: tamanho,
580                duracao_ms,
581            })
582        }
583
584        /// Download de arquivo remote para local via SCP.
585        ///
586        /// # Erros
587        /// - [`ErroSshCli::Io`] se não conseguir escrever o arquivo local.
588        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP.
589        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
590        pub async fn download(
591            &mut self,
592            remote: &std::path::Path,
593            local: &std::path::Path,
594        ) -> ResultadoSshCli<TransferenciaResultado> {
595            use russh::ChannelMsg;
596            use std::io::Write;
597            use std::time::Instant;
598
599            let remote_str = remote.display().to_string();
600
601            let inicio = Instant::now();
602            let timeout = Duration::from_millis(self.cfg.timeout_ms);
603
604            let resultado = tokio::time::timeout(timeout, async {
605                let mut canal = self
606                    .sessao
607                    .channel_open_session()
608                    .await
609                    .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}")))?;
610
611                let comando = format!("scp -f -p {}", remote_str);
612                canal
613                    .exec(true, comando.as_str())
614                    .await
615                    .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
616
617                canal
618                    .data(&[] as &[u8])
619                    .await
620                    .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack inicial: {e}")))?;
621
622                let mut msg = canal.wait().await.ok_or_else(|| {
623                    ErroSshCli::CanalFalhou("canal fechou esperando header".to_string())
624                })?;
625
626                let ChannelMsg::Data { data } = msg else {
627                    return Err(ErroSshCli::CanalFalhou(
628                        "esperava dados do servidor".to_string(),
629                    ));
630                };
631
632                let header = String::from_utf8_lossy(&data);
633                let tamanho = parse_header_scp(&header)?;
634
635                canal
636                    .data(&[] as &[u8])
637                    .await
638                    .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack: {e}")))?;
639
640                if let Some(pai) = local.parent() {
641                    std::fs::create_dir_all(pai)?;
642                }
643
644                let mut arquivo = std::fs::File::create(local).map_err(ErroSshCli::Io)?;
645                let mut recebidos: u64 = 0;
646
647                while recebidos < tamanho {
648                    msg = canal.wait().await.ok_or_else(|| {
649                        ErroSshCli::CanalFalhou("canal fechou durante download".to_string())
650                    })?;
651
652                    let ChannelMsg::Data { data } = msg else {
653                        continue;
654                    };
655
656                    let bytes = data.as_ref();
657                    if bytes.is_empty() {
658                        continue;
659                    }
660
661                    arquivo.write_all(bytes).map_err(ErroSshCli::Io)?;
662                    recebidos += bytes.len() as u64;
663
664                    canal.data(&[] as &[u8]).await.map_err(|e| {
665                        ErroSshCli::CanalFalhou(format!("enviar ack durante download: {e}"))
666                    })?;
667                }
668
669                while let Some(msg) = canal.wait().await {
670                    if let ChannelMsg::Close = msg {
671                        break;
672                    }
673                }
674
675                Ok::<_, ErroSshCli>(recebidos)
676            })
677            .await;
678
679            let recebidos =
680                resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
681
682            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
683
684            Ok(TransferenciaResultado {
685                bytes_transferidos: recebidos,
686                duracao_ms,
687            })
688        }
689
690        /// Encerra a sessão SSH de forma limpa.
691        ///
692        /// # Erros
693        /// Propaga falha se `disconnect` retornar erro do transporte.
694        pub async fn desconectar(&self) -> ResultadoSshCli<()> {
695            let resultado = self
696                .sessao
697                .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
698                .await;
699            match resultado {
700                Ok(()) => {
701                    tracing::info!("sessão SSH encerrada");
702                    Ok(())
703                }
704                Err(e) => {
705                    tracing::warn!(erro = %e, "falha ao encerrar sessão SSH");
706                    Err(ErroSshCli::ConexaoFalhou(format!(
707                        "falha ao desconectar: {e}"
708                    )))
709                }
710            }
711        }
712
713        /// Abre canal direct-tcpip para forwarding SSH.
714        pub async fn abrir_canal_tunel(
715            &self,
716            host_remoto: &str,
717            porta_remota: u16,
718            endereco_origem: &str,
719            porta_origem: u16,
720        ) -> ResultadoSshCli<Box<dyn CanalTunel>> {
721            let canal = self
722                .sessao
723                .channel_open_direct_tcpip(
724                    host_remoto.to_string(),
725                    u32::from(porta_remota),
726                    endereco_origem.to_string(),
727                    u32::from(porta_origem),
728                )
729                .await
730                .map_err(|e| {
731                    ErroSshCli::CanalFalhou(format!(
732                        "falha ao abrir canal direct-tcpip para {}:{}: {}",
733                        host_remoto, porta_remota, e
734                    ))
735                })?;
736
737            Ok(Box::new(canal.into_stream()))
738        }
739    }
740
741    #[async_trait]
742    impl ClienteSshTrait for ClienteSsh {
743        async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
744            Self::conectar(cfg).await.map(Box::new)
745        }
746
747        async fn executar_comando(
748            &mut self,
749            cmd: &str,
750            max_chars: usize,
751        ) -> Result<SaidaExecucao, ErroSshCli> {
752            Self::executar_comando(self, cmd, max_chars).await
753        }
754
755        async fn upload(
756            &mut self,
757            local: &Path,
758            remote: &Path,
759        ) -> Result<TransferenciaResultado, ErroSshCli> {
760            Self::upload(self, local, remote).await
761        }
762
763        async fn download(
764            &mut self,
765            remote: &Path,
766            local: &Path,
767        ) -> Result<TransferenciaResultado, ErroSshCli> {
768            Self::download(self, remote, local).await
769        }
770
771        async fn abrir_canal_tunel(
772            &self,
773            host_remoto: &str,
774            porta_remota: u16,
775            endereco_origem: &str,
776            porta_origem: u16,
777        ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
778            Self::abrir_canal_tunel(
779                self,
780                host_remoto,
781                porta_remota,
782                endereco_origem,
783                porta_origem,
784            )
785            .await
786        }
787
788        async fn desconectar(&self) -> Result<(), ErroSshCli> {
789            Self::desconectar(self).await
790        }
791    }
792
793    #[cfg(test)]
794    mod testes_real {
795        use super::{
796            formatar_header_upload_scp, mapear_exit_status, parse_header_scp,
797            processar_mensagem_exec,
798        };
799
800        #[test]
801        fn mapear_exit_status_normal() {
802            assert_eq!(mapear_exit_status(0), 0);
803            assert_eq!(mapear_exit_status(255), 255);
804        }
805
806        #[test]
807        fn mapear_exit_status_overflow_retorna_menos_um() {
808            assert_eq!(mapear_exit_status(u32::MAX), -1);
809        }
810
811        #[test]
812        fn parse_header_scp_valido_retorna_tamanho() {
813            let tamanho = parse_header_scp("C0644 42 arquivo.txt\n").expect("header válido");
814            assert_eq!(tamanho, 42);
815        }
816
817        #[test]
818        fn parse_header_scp_invalido_retorna_erro() {
819            assert!(parse_header_scp("ERRO").is_err());
820            assert!(parse_header_scp("C0644 sem_tamanho").is_err());
821            assert!(parse_header_scp("C0644 abc arquivo").is_err());
822        }
823
824        #[test]
825        fn processar_mensagem_exec_trata_stdout_stderr_e_close() {
826            let mut stdout = Vec::new();
827            let mut stderr = Vec::new();
828            let mut exit_code = None;
829
830            let deve_parar = processar_mensagem_exec(
831                russh::ChannelMsg::Data {
832                    data: b"stdout".to_vec().into(),
833                },
834                &mut stdout,
835                &mut stderr,
836                &mut exit_code,
837            );
838            assert!(!deve_parar);
839            assert_eq!(stdout, b"stdout");
840
841            let deve_parar = processar_mensagem_exec(
842                russh::ChannelMsg::ExtendedData {
843                    data: b"stderr".to_vec().into(),
844                    ext: 1,
845                },
846                &mut stdout,
847                &mut stderr,
848                &mut exit_code,
849            );
850            assert!(!deve_parar);
851            assert_eq!(stderr, b"stderr");
852
853            let _ = processar_mensagem_exec(
854                russh::ChannelMsg::ExitStatus { exit_status: 17 },
855                &mut stdout,
856                &mut stderr,
857                &mut exit_code,
858            );
859            assert_eq!(exit_code, Some(17));
860
861            let deve_parar = processar_mensagem_exec(
862                russh::ChannelMsg::Close,
863                &mut stdout,
864                &mut stderr,
865                &mut exit_code,
866            );
867            assert!(deve_parar);
868        }
869
870        #[test]
871        fn formatar_header_upload_scp_gera_formato_esperado() {
872            let header = formatar_header_upload_scp(123, "arquivo.txt");
873            assert_eq!(header, "C0644 123 arquivo.txt\\n");
874        }
875
876        #[test]
877        fn processar_mensagem_exec_ignora_extendido_com_codigo_diferente_de_stderr() {
878            let mut stdout = Vec::new();
879            let mut stderr = Vec::new();
880            let mut exit_code = None;
881
882            let deve_parar = processar_mensagem_exec(
883                russh::ChannelMsg::ExtendedData {
884                    data: b"nao-e-stderr".to_vec().into(),
885                    ext: 2,
886                },
887                &mut stdout,
888                &mut stderr,
889                &mut exit_code,
890            );
891
892            assert!(!deve_parar);
893            assert!(stdout.is_empty());
894            assert!(stderr.is_empty());
895            assert!(exit_code.is_none());
896        }
897
898        #[test]
899        fn processar_mensagem_exec_trata_exit_signal_e_eof_sem_encerrar_loop() {
900            let mut stdout = Vec::new();
901            let mut stderr = Vec::new();
902            let mut exit_code = Some(7);
903
904            let deve_parar_signal = processar_mensagem_exec(
905                russh::ChannelMsg::ExitSignal {
906                    signal_name: russh::Sig::TERM,
907                    core_dumped: false,
908                    error_message: "encerrado".to_string(),
909                    lang_tag: "pt-BR".to_string(),
910                },
911                &mut stdout,
912                &mut stderr,
913                &mut exit_code,
914            );
915
916            let deve_parar_eof = processar_mensagem_exec(
917                russh::ChannelMsg::Eof,
918                &mut stdout,
919                &mut stderr,
920                &mut exit_code,
921            );
922
923            assert!(!deve_parar_signal);
924            assert!(!deve_parar_eof);
925            assert_eq!(exit_code, Some(7));
926        }
927
928        #[test]
929        fn processar_mensagem_exec_ignora_variantes_sem_tratamento_especifico() {
930            let mut stdout = Vec::new();
931            let mut stderr = Vec::new();
932            let mut exit_code = None;
933
934            let deve_parar = processar_mensagem_exec(
935                russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
936                &mut stdout,
937                &mut stderr,
938                &mut exit_code,
939            );
940
941            assert!(!deve_parar);
942            assert!(stdout.is_empty());
943            assert!(stderr.is_empty());
944            assert!(exit_code.is_none());
945        }
946    }
947}
948
949#[cfg(feature = "ssh-real")]
950pub use real::{ClienteSsh, ManipuladorCliente};
951
952// =========================================================================
953// Stub usado quando a feature `ssh-real` está DESATIVADA.
954// =========================================================================
955
956#[cfg(not(feature = "ssh-real"))]
957mod stub {
958    use super::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
959    use crate::erros::ErroSshCli;
960    use crate::ssh::cliente::ClienteSshTrait;
961    use async_trait::async_trait;
962    use std::path::Path;
963
964    /// Stub quando `ssh-real` está desativado: sempre retorna
965    /// [`ErroSshCli::ConexaoFalhou`].
966    #[derive(Debug)]
967    pub struct ClienteSsh;
968
969    #[async_trait]
970    impl ClienteSshTrait for ClienteSsh {
971        async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
972            Err(ErroSshCli::ConexaoFalhou(
973                "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
974            ))
975        }
976
977        async fn executar_comando(
978            &mut self,
979            _cmd: &str,
980            _max_chars: usize,
981        ) -> Result<SaidaExecucao, ErroSshCli> {
982            Err(ErroSshCli::CanalFalhou(
983                "stub sem russh: feature `ssh-real` desabilitada".into(),
984            ))
985        }
986
987        async fn upload(
988            &mut self,
989            _local: &Path,
990            _remote: &Path,
991        ) -> Result<TransferenciaResultado, ErroSshCli> {
992            Err(ErroSshCli::CanalFalhou(
993                "stub sem russh: feature `ssh-real` desabilitada".into(),
994            ))
995        }
996
997        async fn download(
998            &mut self,
999            _remote: &Path,
1000            _local: &Path,
1001        ) -> Result<TransferenciaResultado, ErroSshCli> {
1002            Err(ErroSshCli::CanalFalhou(
1003                "stub sem russh: feature `ssh-real` desabilitada".into(),
1004            ))
1005        }
1006
1007        async fn abrir_canal_tunel(
1008            &self,
1009            _host_remoto: &str,
1010            _porta_remota: u16,
1011            _endereco_origem: &str,
1012            _porta_origem: u16,
1013        ) -> Result<Box<dyn super::CanalTunel>, ErroSshCli> {
1014            Err(ErroSshCli::CanalFalhou(
1015                "stub sem russh: feature `ssh-real` desabilitada".into(),
1016            ))
1017        }
1018
1019        async fn desconectar(&self) -> Result<(), ErroSshCli> {
1020            Ok(())
1021        }
1022    }
1023}
1024
1025#[cfg(not(feature = "ssh-real"))]
1026pub use stub::ClienteSsh;
1027
1028// =========================================================================
1029// Testes unitários (sem rede, sem feature gate).
1030// =========================================================================
1031
1032#[cfg(test)]
1033mod testes {
1034    use super::*;
1035    use secrecy::SecretString;
1036
1037    fn cfg_valida() -> ConfiguracaoConexao {
1038        ConfiguracaoConexao {
1039            host: "127.0.0.1".to_string(),
1040            porta: 22,
1041            usuario: "root".to_string(),
1042            senha: SecretString::from("senha-exemplo".to_string()),
1043            timeout_ms: 5000,
1044        }
1045    }
1046
1047    #[test]
1048    fn validar_host_vazio_retorna_erro() {
1049        let mut c = cfg_valida();
1050        c.host = String::new();
1051        let r = c.validar();
1052        assert!(r.is_err());
1053        let msg = r.unwrap_err().to_string();
1054        assert!(msg.contains("host"));
1055    }
1056
1057    #[test]
1058    fn validar_host_apenas_espacos_retorna_erro() {
1059        let mut c = cfg_valida();
1060        c.host = "   ".to_string();
1061        assert!(c.validar().is_err());
1062    }
1063
1064    #[test]
1065    fn validar_porta_zero_retorna_erro() {
1066        let mut c = cfg_valida();
1067        c.porta = 0;
1068        let r = c.validar();
1069        assert!(r.is_err());
1070        let msg = r.unwrap_err().to_string();
1071        assert!(msg.contains("porta"));
1072    }
1073
1074    #[test]
1075    fn validar_usuario_vazio_retorna_erro() {
1076        let mut c = cfg_valida();
1077        c.usuario = String::new();
1078        assert!(c.validar().is_err());
1079    }
1080
1081    #[test]
1082    fn validar_configuracao_correta_retorna_ok() {
1083        assert!(cfg_valida().validar().is_ok());
1084    }
1085
1086    #[test]
1087    fn debug_nao_expoe_senha() {
1088        let c = cfg_valida();
1089        let dbg = format!("{c:?}");
1090        assert!(!dbg.contains("senha-exemplo"));
1091        assert!(dbg.contains("redacted"));
1092    }
1093
1094    #[test]
1095    fn truncar_utf8_nao_trunca_se_cabe() {
1096        let (s, t) = truncar_utf8("ola mundo", 100);
1097        assert_eq!(s, "ola mundo");
1098        assert!(!t);
1099    }
1100
1101    #[test]
1102    fn truncar_utf8_trunca_string_grande_ascii() {
1103        let entrada: String = "a".repeat(200);
1104        let (s, t) = truncar_utf8(&entrada, 50);
1105        assert_eq!(s.chars().count(), 50);
1106        assert!(t);
1107    }
1108
1109    #[test]
1110    fn truncar_utf8_preserva_grafemas_acentuados() {
1111        // 10 codepoints: "á" (1 char) * 10
1112        let entrada: String = "á".repeat(30);
1113        let (s, t) = truncar_utf8(&entrada, 10);
1114        assert_eq!(s.chars().count(), 10);
1115        // Cada 'á' ocupa 2 bytes em UTF-8 → 10 chars = 20 bytes
1116        assert_eq!(s.len(), 20);
1117        assert!(t);
1118        // Não corta no meio de byte
1119        assert!(s.chars().all(|c| c == 'á'));
1120    }
1121
1122    #[test]
1123    fn truncar_utf8_com_emojis_nao_quebra() {
1124        let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1125        let (s, t) = truncar_utf8(entrada, 5);
1126        assert_eq!(s.chars().count(), 5);
1127        assert!(t);
1128    }
1129
1130    #[test]
1131    fn truncar_utf8_zero_retorna_vazio() {
1132        let (s, t) = truncar_utf8("abc", 0);
1133        assert_eq!(s, "");
1134        assert!(t);
1135    }
1136
1137    #[test]
1138    fn saida_execucao_debug_nao_crasha() {
1139        let s = SaidaExecucao {
1140            stdout: "ok".into(),
1141            stderr: String::new(),
1142            exit_code: Some(0),
1143            truncado_stdout: false,
1144            truncado_stderr: false,
1145            duracao_ms: 42,
1146        };
1147        let _ = format!("{s:?}");
1148    }
1149
1150    #[test]
1151    fn duracao_ms_tipo_compativel() {
1152        // Garantia estática de que instant elapsed cabe em u64.
1153        let fake: u64 = 1234;
1154        assert_eq!(fake, 1234_u64);
1155    }
1156}