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        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
346        pub async fn conectar(cfg: ConfiguracaoConexao) -> ResultadoSshCli<Self> {
347            cfg.validar()?;
348
349            let timeout = Duration::from_millis(cfg.timeout_ms);
350            let host = cfg.host.clone();
351            let porta = cfg.porta;
352            let usuario = cfg.usuario.clone();
353            let senha_segura = cfg.senha.clone();
354
355            let config_cliente = Arc::new(russh::client::Config {
356                inactivity_timeout: Some(timeout),
357                ..Default::default()
358            });
359
360            tracing::info!(
361                host = %host,
362                porta,
363                usuario = %usuario,
364                timeout_ms = cfg.timeout_ms,
365                "iniciando conexão SSH"
366            );
367
368            // Envelopa conexão + handshake + autenticação em um único timeout global.
369            let resultado_conexao = tokio::time::timeout(timeout, async move {
370                let mut sessao = russh::client::connect(
371                    config_cliente,
372                    (host.as_str(), porta),
373                    ManipuladorCliente,
374                )
375                .await
376                .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha TCP/handshake: {e}")))?;
377
378                let auth = sessao
379                    .authenticate_password(usuario.clone(), senha_segura.expose_secret())
380                    .await
381                    .map_err(|e| ErroSshCli::ConexaoFalhou(format!("falha auth transport: {e}")))?;
382
383                if !auth.success() {
384                    tracing::warn!(
385                        host = %host,
386                        usuario = %usuario,
387                        "autenticação SSH rejeitada"
388                    );
389                    return Err(ErroSshCli::AutenticacaoFalhou);
390                }
391
392                Ok::<_, ErroSshCli>(sessao)
393            })
394            .await;
395
396            let sessao = match resultado_conexao {
397                Ok(Ok(s)) => s,
398                Ok(Err(erro)) => return Err(erro),
399                Err(_) => return Err(ErroSshCli::TimeoutSsh(cfg.timeout_ms)),
400            };
401
402            tracing::info!("conexão SSH autenticada com sucesso");
403
404            Ok(Self { sessao, cfg })
405        }
406
407        /// Executa um comando shell remoto e captura stdout/stderr em paralelo.
408        ///
409        /// Trunca cada stream em `max_chars` codepoints UTF-8. Respeita o
410        /// `timeout_ms` da configuração para a execução inteira.
411        ///
412        /// # Erros
413        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal ou enviar `exec`.
414        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
415        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
416        pub async fn executar_comando(
417            &mut self,
418            comando: &str,
419            max_chars: usize,
420        ) -> ResultadoSshCli<SaidaExecucao> {
421            let inicio = Instant::now();
422            let timeout = Duration::from_millis(self.cfg.timeout_ms);
423
424            let resultado = tokio::time::timeout(timeout, async {
425                let mut canal = self
426                    .sessao
427                    .channel_open_session()
428                    .await
429                    .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão: {e}")))?;
430
431                canal
432                    .exec(true, comando)
433                    .await
434                    .map_err(|e| ErroSshCli::CanalFalhou(format!("exec: {e}")))?;
435
436                let mut stdout_bytes: Vec<u8> = Vec::new();
437                let mut stderr_bytes: Vec<u8> = Vec::new();
438                let mut exit_code: Option<i32> = None;
439
440                while let Some(msg) = canal.wait().await {
441                    if processar_mensagem_exec(
442                        msg,
443                        &mut stdout_bytes,
444                        &mut stderr_bytes,
445                        &mut exit_code,
446                    ) {
447                        break;
448                    }
449                }
450
451                Ok::<_, ErroSshCli>((stdout_bytes, stderr_bytes, exit_code))
452            })
453            .await;
454
455            let (stdout_bytes, stderr_bytes, exit_code) = match resultado {
456                Ok(Ok(t)) => t,
457                Ok(Err(erro)) => return Err(erro),
458                Err(_) => return Err(ErroSshCli::TimeoutSsh(self.cfg.timeout_ms)),
459            };
460
461            // Converte de bytes para String UTF-8 de forma resiliente.
462            let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
463            let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
464
465            let (stdout_truncado, truncado_stdout) = super::truncar_utf8(&stdout_str, max_chars);
466            let (stderr_truncado, truncado_stderr) = super::truncar_utf8(&stderr_str, max_chars);
467
468            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
469
470            Ok(SaidaExecucao {
471                stdout: stdout_truncado,
472                stderr: stderr_truncado,
473                exit_code,
474                truncado_stdout,
475                truncado_stderr,
476                duracao_ms,
477            })
478        }
479
480        /// Upload de arquivo local para remote via SCP.
481        ///
482        /// # Erros
483        /// - [`ErroSshCli::ArquivoNaoEncontrado`] se o arquivo local não existir.
484        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP.
485        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
486        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
487        pub async fn upload(
488            &mut self,
489            local: &std::path::Path,
490            remote: &std::path::Path,
491        ) -> ResultadoSshCli<TransferenciaResultado> {
492            use russh::ChannelMsg;
493            use std::time::Instant;
494
495            let local_str = local.display().to_string();
496            let remote_str = remote.display().to_string();
497
498            let metadados = std::fs::metadata(local).map_err(|e| {
499                if e.kind() == std::io::ErrorKind::NotFound {
500                    ErroSshCli::ArquivoNaoEncontrado(local_str.clone())
501                } else {
502                    ErroSshCli::Io(e)
503                }
504            })?;
505
506            if !metadados.is_file() {
507                return Err(ErroSshCli::ArgumentoInvalido(
508                    "upload só suporta arquivos regulares".to_string(),
509                ));
510            }
511
512            let tamanho = metadados.len();
513            let nome_arquivo = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
514
515            let inicio = Instant::now();
516            let timeout = Duration::from_millis(self.cfg.timeout_ms);
517
518            let resultado =
519                tokio::time::timeout(timeout, async {
520                    let mut canal =
521                        self.sessao.channel_open_session().await.map_err(|e| {
522                            ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}"))
523                        })?;
524
525                    let comando = format!("scp -t -p {}", remote_str);
526                    canal
527                        .exec(true, comando.as_str())
528                        .await
529                        .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
530
531                    canal.wait().await.ok_or_else(|| {
532                        ErroSshCli::CanalFalhou("canal fechou prematuramente".to_string())
533                    })?;
534
535                    let resposta = formatar_header_upload_scp(tamanho, nome_arquivo);
536                    canal
537                        .data(resposta.as_bytes())
538                        .await
539                        .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar header SCP: {e}")))?;
540
541                    canal.wait().await.ok_or_else(|| {
542                        ErroSshCli::CanalFalhou("canal fechou durante header".to_string())
543                    })?;
544
545                    let conteudo = std::fs::read(local).map_err(ErroSshCli::Io)?;
546                    let mut offset = 0;
547                    let tamanho_bloco = 32768;
548
549                    while offset < conteudo.len() {
550                        let fim = std::cmp::min(offset + tamanho_bloco, conteudo.len());
551                        let bloco = &conteudo[offset..fim];
552                        canal.data(bloco).await.map_err(|e| {
553                            ErroSshCli::CanalFalhou(format!("enviar bloco SCP: {e}"))
554                        })?;
555                        offset = fim;
556                    }
557
558                    canal
559                        .data(&[] as &[u8])
560                        .await
561                        .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar EOF SCP: {e}")))?;
562
563                    canal.wait().await.ok_or_else(|| {
564                        ErroSshCli::CanalFalhou("canal fechou durante transferência".to_string())
565                    })?;
566
567                    while let Some(msg) = canal.wait().await {
568                        if let ChannelMsg::Close = msg {
569                            break;
570                        }
571                    }
572
573                    Ok::<_, ErroSshCli>(())
574                })
575                .await;
576
577            resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
578
579            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
580
581            Ok(TransferenciaResultado {
582                bytes_transferidos: tamanho,
583                duracao_ms,
584            })
585        }
586
587        /// Download de arquivo remote para local via SCP.
588        ///
589        /// # Erros
590        /// - [`ErroSshCli::Io`] se não conseguir escrever o arquivo local.
591        /// - [`ErroSshCli::CanalFalhou`] em falha ao abrir canal SCP.
592        /// - [`ErroSshCli::TimeoutSsh`] se exceder o timeout.
593        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
594        pub async fn download(
595            &mut self,
596            remote: &std::path::Path,
597            local: &std::path::Path,
598        ) -> ResultadoSshCli<TransferenciaResultado> {
599            use russh::ChannelMsg;
600            use std::io::Write;
601            use std::time::Instant;
602
603            let remote_str = remote.display().to_string();
604
605            let inicio = Instant::now();
606            let timeout = Duration::from_millis(self.cfg.timeout_ms);
607
608            let resultado = tokio::time::timeout(timeout, async {
609                let mut canal = self
610                    .sessao
611                    .channel_open_session()
612                    .await
613                    .map_err(|e| ErroSshCli::CanalFalhou(format!("abrir sessão SCP: {e}")))?;
614
615                let comando = format!("scp -f -p {}", remote_str);
616                canal
617                    .exec(true, comando.as_str())
618                    .await
619                    .map_err(|e| ErroSshCli::CanalFalhou(format!("exec SCP: {e}")))?;
620
621                canal
622                    .data(&[] as &[u8])
623                    .await
624                    .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack inicial: {e}")))?;
625
626                let mut msg = canal.wait().await.ok_or_else(|| {
627                    ErroSshCli::CanalFalhou("canal fechou esperando header".to_string())
628                })?;
629
630                let ChannelMsg::Data { data } = msg else {
631                    return Err(ErroSshCli::CanalFalhou(
632                        "esperava dados do servidor".to_string(),
633                    ));
634                };
635
636                let header = String::from_utf8_lossy(&data);
637                let tamanho = parse_header_scp(&header)?;
638
639                canal
640                    .data(&[] as &[u8])
641                    .await
642                    .map_err(|e| ErroSshCli::CanalFalhou(format!("enviar ack: {e}")))?;
643
644                if let Some(pai) = local.parent() {
645                    std::fs::create_dir_all(pai)?;
646                }
647
648                let mut arquivo = std::fs::File::create(local).map_err(ErroSshCli::Io)?;
649                let mut recebidos: u64 = 0;
650
651                while recebidos < tamanho {
652                    msg = canal.wait().await.ok_or_else(|| {
653                        ErroSshCli::CanalFalhou("canal fechou durante download".to_string())
654                    })?;
655
656                    let ChannelMsg::Data { data } = msg else {
657                        continue;
658                    };
659
660                    let bytes = data.as_ref();
661                    if bytes.is_empty() {
662                        continue;
663                    }
664
665                    arquivo.write_all(bytes).map_err(ErroSshCli::Io)?;
666                    recebidos += bytes.len() as u64;
667
668                    canal.data(&[] as &[u8]).await.map_err(|e| {
669                        ErroSshCli::CanalFalhou(format!("enviar ack durante download: {e}"))
670                    })?;
671                }
672
673                while let Some(msg) = canal.wait().await {
674                    if let ChannelMsg::Close = msg {
675                        break;
676                    }
677                }
678
679                Ok::<_, ErroSshCli>(recebidos)
680            })
681            .await;
682
683            let recebidos =
684                resultado.map_err(|_| ErroSshCli::TimeoutSsh(self.cfg.timeout_ms))??;
685
686            let duracao_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
687
688            Ok(TransferenciaResultado {
689                bytes_transferidos: recebidos,
690                duracao_ms,
691            })
692        }
693
694        /// Encerra a sessão SSH de forma limpa.
695        ///
696        /// # Erros
697        /// Propaga falha se `disconnect` retornar erro do transporte.
698        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
699        pub async fn desconectar(&self) -> ResultadoSshCli<()> {
700            let resultado = self
701                .sessao
702                .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
703                .await;
704            match resultado {
705                Ok(()) => {
706                    tracing::info!("sessão SSH encerrada");
707                    Ok(())
708                }
709                Err(e) => {
710                    tracing::warn!(erro = %e, "falha ao encerrar sessão SSH");
711                    Err(ErroSshCli::ConexaoFalhou(format!(
712                        "falha ao desconectar: {e}"
713                    )))
714                }
715            }
716        }
717
718        /// Abre canal direct-tcpip para forwarding SSH.
719        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
720        pub async fn abrir_canal_tunel(
721            &self,
722            host_remoto: &str,
723            porta_remota: u16,
724            endereco_origem: &str,
725            porta_origem: u16,
726        ) -> ResultadoSshCli<Box<dyn CanalTunel>> {
727            let canal = self
728                .sessao
729                .channel_open_direct_tcpip(
730                    host_remoto.to_string(),
731                    u32::from(porta_remota),
732                    endereco_origem.to_string(),
733                    u32::from(porta_origem),
734                )
735                .await
736                .map_err(|e| {
737                    ErroSshCli::CanalFalhou(format!(
738                        "falha ao abrir canal direct-tcpip para {}:{}: {}",
739                        host_remoto, porta_remota, e
740                    ))
741                })?;
742
743            Ok(Box::new(canal.into_stream()))
744        }
745    }
746
747    #[async_trait]
748    impl ClienteSshTrait for ClienteSsh {
749        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
750        async fn conectar(cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
751            Self::conectar(cfg).await.map(Box::new)
752        }
753
754        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
755        async fn executar_comando(
756            &mut self,
757            cmd: &str,
758            max_chars: usize,
759        ) -> Result<SaidaExecucao, ErroSshCli> {
760            Self::executar_comando(self, cmd, max_chars).await
761        }
762
763        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
764        async fn upload(
765            &mut self,
766            local: &Path,
767            remote: &Path,
768        ) -> Result<TransferenciaResultado, ErroSshCli> {
769            Self::upload(self, local, remote).await
770        }
771
772        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
773        async fn download(
774            &mut self,
775            remote: &Path,
776            local: &Path,
777        ) -> Result<TransferenciaResultado, ErroSshCli> {
778            Self::download(self, remote, local).await
779        }
780
781        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
782        async fn abrir_canal_tunel(
783            &self,
784            host_remoto: &str,
785            porta_remota: u16,
786            endereco_origem: &str,
787            porta_origem: u16,
788        ) -> Result<Box<dyn CanalTunel>, ErroSshCli> {
789            Self::abrir_canal_tunel(
790                self,
791                host_remoto,
792                porta_remota,
793                endereco_origem,
794                porta_origem,
795            )
796            .await
797        }
798
799        // TESTABILIDADE: requer russh::Session real. Coberto apenas por testes E2E com servidor SSH embarcado.
800        async fn desconectar(&self) -> Result<(), ErroSshCli> {
801            Self::desconectar(self).await
802        }
803    }
804
805    // TESTABILIDADE: Os métodos `ClienteSsh::executar_comando`, `upload`,
806    // `download`, `abrir_canal_tunel`, `desconectar` e o `impl Debug`
807    // dependem de uma sessão russh autenticada (`russh::client::Handle`),
808    // que só pode ser construída após um handshake TCP+SSH contra um
809    // servidor SSH real. Testá-los em unitários exigiria um servidor SSH
810    // embarcado (ex.: `russh` lado servidor com chave efêmera) ou
811    // OpenSSH em container — ambos fora do escopo dos testes de biblioteca.
812    // Cobertura dessas funções acontece em: (a) execução contra VPS reais
813    // por operadores, (b) testes E2E opcionais com `sshd` local. Os helpers
814    // PUROS (`mapear_exit_status`, `parse_header_scp`, `formatar_header_upload_scp`,
815    // `processar_mensagem_exec`) que concentram a lógica testável estão
816    // cobertos abaixo. O ponto de entrada `conectar` é exercitado via
817    // `TcpListener` efêmero em testes de porta inalcançável/fechada.
818    #[cfg(test)]
819    mod testes_real {
820        use super::{
821            formatar_header_upload_scp, mapear_exit_status, parse_header_scp,
822            processar_mensagem_exec,
823        };
824
825        #[test]
826        fn mapear_exit_status_normal() {
827            assert_eq!(mapear_exit_status(0), 0);
828            assert_eq!(mapear_exit_status(255), 255);
829        }
830
831        #[test]
832        fn mapear_exit_status_overflow_retorna_menos_um() {
833            assert_eq!(mapear_exit_status(u32::MAX), -1);
834        }
835
836        #[test]
837        fn parse_header_scp_valido_retorna_tamanho() {
838            let tamanho = parse_header_scp("C0644 42 arquivo.txt\n").expect("header válido");
839            assert_eq!(tamanho, 42);
840        }
841
842        #[test]
843        fn parse_header_scp_invalido_retorna_erro() {
844            assert!(parse_header_scp("ERRO").is_err());
845            assert!(parse_header_scp("C0644 sem_tamanho").is_err());
846            assert!(parse_header_scp("C0644 abc arquivo").is_err());
847        }
848
849        #[test]
850        fn processar_mensagem_exec_trata_stdout_stderr_e_close() {
851            let mut stdout = Vec::new();
852            let mut stderr = Vec::new();
853            let mut exit_code = None;
854
855            let deve_parar = processar_mensagem_exec(
856                russh::ChannelMsg::Data {
857                    data: b"stdout".to_vec().into(),
858                },
859                &mut stdout,
860                &mut stderr,
861                &mut exit_code,
862            );
863            assert!(!deve_parar);
864            assert_eq!(stdout, b"stdout");
865
866            let deve_parar = processar_mensagem_exec(
867                russh::ChannelMsg::ExtendedData {
868                    data: b"stderr".to_vec().into(),
869                    ext: 1,
870                },
871                &mut stdout,
872                &mut stderr,
873                &mut exit_code,
874            );
875            assert!(!deve_parar);
876            assert_eq!(stderr, b"stderr");
877
878            let _ = processar_mensagem_exec(
879                russh::ChannelMsg::ExitStatus { exit_status: 17 },
880                &mut stdout,
881                &mut stderr,
882                &mut exit_code,
883            );
884            assert_eq!(exit_code, Some(17));
885
886            let deve_parar = processar_mensagem_exec(
887                russh::ChannelMsg::Close,
888                &mut stdout,
889                &mut stderr,
890                &mut exit_code,
891            );
892            assert!(deve_parar);
893        }
894
895        #[test]
896        fn formatar_header_upload_scp_gera_formato_esperado() {
897            let header = formatar_header_upload_scp(123, "arquivo.txt");
898            assert_eq!(header, "C0644 123 arquivo.txt\\n");
899        }
900
901        #[test]
902        fn processar_mensagem_exec_ignora_extendido_com_codigo_diferente_de_stderr() {
903            let mut stdout = Vec::new();
904            let mut stderr = Vec::new();
905            let mut exit_code = None;
906
907            let deve_parar = processar_mensagem_exec(
908                russh::ChannelMsg::ExtendedData {
909                    data: b"nao-e-stderr".to_vec().into(),
910                    ext: 2,
911                },
912                &mut stdout,
913                &mut stderr,
914                &mut exit_code,
915            );
916
917            assert!(!deve_parar);
918            assert!(stdout.is_empty());
919            assert!(stderr.is_empty());
920            assert!(exit_code.is_none());
921        }
922
923        #[test]
924        fn processar_mensagem_exec_trata_exit_signal_e_eof_sem_encerrar_loop() {
925            let mut stdout = Vec::new();
926            let mut stderr = Vec::new();
927            let mut exit_code = Some(7);
928
929            let deve_parar_signal = processar_mensagem_exec(
930                russh::ChannelMsg::ExitSignal {
931                    signal_name: russh::Sig::TERM,
932                    core_dumped: false,
933                    error_message: "encerrado".to_string(),
934                    lang_tag: "pt-BR".to_string(),
935                },
936                &mut stdout,
937                &mut stderr,
938                &mut exit_code,
939            );
940
941            let deve_parar_eof = processar_mensagem_exec(
942                russh::ChannelMsg::Eof,
943                &mut stdout,
944                &mut stderr,
945                &mut exit_code,
946            );
947
948            assert!(!deve_parar_signal);
949            assert!(!deve_parar_eof);
950            assert_eq!(exit_code, Some(7));
951        }
952
953        #[test]
954        fn processar_mensagem_exec_ignora_variantes_sem_tratamento_especifico() {
955            let mut stdout = Vec::new();
956            let mut stderr = Vec::new();
957            let mut exit_code = None;
958
959            let deve_parar = processar_mensagem_exec(
960                russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
961                &mut stdout,
962                &mut stderr,
963                &mut exit_code,
964            );
965
966            assert!(!deve_parar);
967            assert!(stdout.is_empty());
968            assert!(stderr.is_empty());
969            assert!(exit_code.is_none());
970        }
971
972        #[test]
973        fn parse_header_scp_aceita_header_com_whitespace_extra() {
974            // Tolerância a espaços extras devido ao `split_whitespace`.
975            let tamanho = parse_header_scp("  C0644   128   arquivo.bin  \r\n")
976                .expect("header com espaços extras é válido");
977            assert_eq!(tamanho, 128);
978        }
979
980        #[test]
981        fn parse_header_scp_numero_muito_grande_retorna_u64_ok() {
982            // 10 GiB em bytes continua cabendo em u64.
983            let tamanho = parse_header_scp("C0644 10737418240 grande.iso\n").expect("u64 válido");
984            assert_eq!(tamanho, 10_737_418_240);
985        }
986
987        #[test]
988        fn parse_header_scp_string_vazia_retorna_erro() {
989            // String vazia não começa com 'C' → erro controlado.
990            let resultado = parse_header_scp("");
991            assert!(resultado.is_err());
992        }
993
994        #[test]
995        fn parse_header_scp_header_apenas_c_retorna_erro() {
996            // Somente "C" sem partes subsequentes → erro de formato.
997            let resultado = parse_header_scp("C");
998            assert!(resultado.is_err());
999        }
1000
1001        #[test]
1002        fn formatar_header_upload_scp_com_nome_contendo_espaco() {
1003            let header = formatar_header_upload_scp(64, "meu arquivo.txt");
1004            assert!(header.starts_with("C0644 64 "));
1005            assert!(header.contains("meu arquivo.txt"));
1006            assert!(header.ends_with("\\n"));
1007        }
1008
1009        #[test]
1010        fn formatar_header_upload_scp_tamanho_zero_e_valido() {
1011            let header = formatar_header_upload_scp(0, "vazio.txt");
1012            assert_eq!(header, "C0644 0 vazio.txt\\n");
1013        }
1014
1015        #[test]
1016        fn mapear_exit_status_cobre_valores_intermediarios() {
1017            assert_eq!(mapear_exit_status(1), 1);
1018            assert_eq!(mapear_exit_status(42), 42);
1019            assert_eq!(mapear_exit_status(127), 127);
1020            assert_eq!(mapear_exit_status(128), 128);
1021            assert_eq!(mapear_exit_status(i32::MAX as u32), i32::MAX);
1022        }
1023
1024        #[test]
1025        fn processar_mensagem_exec_acumula_stdout_em_multiplas_chamadas() {
1026            let mut stdout = Vec::new();
1027            let mut stderr = Vec::new();
1028            let mut exit_code = None;
1029
1030            for parte in [b"parte1".to_vec(), b"-".to_vec(), b"parte2".to_vec()] {
1031                processar_mensagem_exec(
1032                    russh::ChannelMsg::Data { data: parte.into() },
1033                    &mut stdout,
1034                    &mut stderr,
1035                    &mut exit_code,
1036                );
1037            }
1038            assert_eq!(stdout, b"parte1-parte2");
1039            assert!(stderr.is_empty());
1040        }
1041
1042        #[tokio::test]
1043        async fn conectar_com_config_invalida_host_vazio_retorna_argumento_invalido() {
1044            use super::super::ConfiguracaoConexao;
1045            use super::ClienteSsh;
1046            use crate::erros::ErroSshCli;
1047            use secrecy::SecretString;
1048
1049            let cfg = ConfiguracaoConexao {
1050                host: String::new(),
1051                porta: 22,
1052                usuario: "root".to_string(),
1053                senha: SecretString::from("x".to_string()),
1054                timeout_ms: 500,
1055            };
1056
1057            match ClienteSsh::conectar(cfg).await {
1058                Err(ErroSshCli::ArgumentoInvalido(_)) => {}
1059                outro => panic!("esperava ArgumentoInvalido, veio {outro:?}"),
1060            }
1061        }
1062
1063        #[tokio::test]
1064        async fn conectar_com_porta_inalcançavel_retorna_erro_conexao_ou_timeout() {
1065            // Usa porta TCP 1 em localhost: normalmente é rejeitada
1066            // imediatamente. Com timeout baixo, deve retornar erro rápido sem
1067            // depender de servidor SSH real. Serve para exercitar o caminho de
1068            // inicialização da função `conectar` (arc de config, timeout, etc.).
1069            use super::super::ConfiguracaoConexao;
1070            use super::ClienteSsh;
1071            use crate::erros::ErroSshCli;
1072            use secrecy::SecretString;
1073
1074            // Usa TcpListener em porta efêmera reservada mas NÃO aceita handshake
1075            // para forçar o timeout SSH rapidamente.
1076            let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1077                .await
1078                .expect("bind efêmero");
1079            let porta = listener.local_addr().expect("addr").port();
1080
1081            let cfg = ConfiguracaoConexao {
1082                host: "127.0.0.1".to_string(),
1083                porta,
1084                usuario: "root".to_string(),
1085                senha: SecretString::from("senha".to_string()),
1086                timeout_ms: 200,
1087            };
1088
1089            let resultado = ClienteSsh::conectar(cfg).await;
1090            assert!(resultado.is_err(), "conexão deveria falhar");
1091            match resultado.unwrap_err() {
1092                ErroSshCli::TimeoutSsh(_) | ErroSshCli::ConexaoFalhou(_) => {
1093                    // Ambos aceitáveis — o alvo é só EXERCITAR o code path.
1094                }
1095                outro => panic!("esperava TimeoutSsh ou ConexaoFalhou, recebeu: {outro:?}"),
1096            }
1097        }
1098
1099        #[tokio::test]
1100        async fn conectar_com_porta_fechada_falha_conexao_tcp() {
1101            // Usa uma porta fechada DETERMINÍSTICA (bind + drop libera a porta).
1102            use super::super::ConfiguracaoConexao;
1103            use super::ClienteSsh;
1104            use secrecy::SecretString;
1105
1106            let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1107                .await
1108                .expect("bind");
1109            let porta = listener.local_addr().expect("addr").port();
1110            drop(listener); // Libera: próxima conexão à porta deve ser recusada.
1111
1112            let cfg = ConfiguracaoConexao {
1113                host: "127.0.0.1".to_string(),
1114                porta,
1115                usuario: "u".to_string(),
1116                senha: SecretString::from("s".to_string()),
1117                timeout_ms: 500,
1118            };
1119
1120            let resultado = ClienteSsh::conectar(cfg).await;
1121            assert!(resultado.is_err(), "conectar em porta fechada deve falhar");
1122        }
1123
1124        #[tokio::test]
1125        async fn manipulador_cliente_check_server_key_sempre_aceita() {
1126            // Constrói PublicKey a partir de base64 de chave OpenSSH conhecida
1127            // (gerada offline; não há entropia sendo consumida aqui).
1128            // A chave abaixo é uma Ed25519 pública de teste (`ssh-keygen -t ed25519`
1129            // determinístico). Como o handler ignora o valor da chave, qualquer
1130            // chave pública válida serve.
1131            use russh::client::Handler;
1132            use russh::keys::parse_public_key_base64;
1133
1134            // Chave pública Ed25519 válida em base64 (uso puramente decorativo).
1135            let chave_base64 =
1136                "AAAAC3NzaC1lZDI1NTE5AAAAIKHEChfyk+R2N4OgRtRhnYXJYfxZqkEyiqYW7v4zj4iV";
1137            let pub_key = parse_public_key_base64(chave_base64).expect("chave base64 válida");
1138
1139            let mut handler = super::ManipuladorCliente;
1140            let resultado = handler
1141                .check_server_key(&pub_key)
1142                .await
1143                .expect("handler não falha");
1144            assert!(
1145                resultado,
1146                "handler é permissivo por design (trust-on-first-use iteração 2)"
1147            );
1148        }
1149    }
1150}
1151
1152#[cfg(feature = "ssh-real")]
1153pub use real::{ClienteSsh, ManipuladorCliente};
1154
1155// =========================================================================
1156// Stub usado quando a feature `ssh-real` está DESATIVADA.
1157// =========================================================================
1158
1159#[cfg(not(feature = "ssh-real"))]
1160mod stub {
1161    use super::{ConfiguracaoConexao, SaidaExecucao, TransferenciaResultado};
1162    use crate::erros::ErroSshCli;
1163    use crate::ssh::cliente::ClienteSshTrait;
1164    use async_trait::async_trait;
1165    use std::path::Path;
1166
1167    /// Stub quando `ssh-real` está desativado: sempre retorna
1168    /// [`ErroSshCli::ConexaoFalhou`].
1169    #[derive(Debug)]
1170    pub struct ClienteSsh;
1171
1172    #[async_trait]
1173    impl ClienteSshTrait for ClienteSsh {
1174        async fn conectar(_cfg: ConfiguracaoConexao) -> Result<Box<Self>, ErroSshCli> {
1175            Err(ErroSshCli::ConexaoFalhou(
1176                "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
1177            ))
1178        }
1179
1180        async fn executar_comando(
1181            &mut self,
1182            _cmd: &str,
1183            _max_chars: usize,
1184        ) -> Result<SaidaExecucao, ErroSshCli> {
1185            Err(ErroSshCli::CanalFalhou(
1186                "stub sem russh: feature `ssh-real` desabilitada".into(),
1187            ))
1188        }
1189
1190        async fn upload(
1191            &mut self,
1192            _local: &Path,
1193            _remote: &Path,
1194        ) -> Result<TransferenciaResultado, ErroSshCli> {
1195            Err(ErroSshCli::CanalFalhou(
1196                "stub sem russh: feature `ssh-real` desabilitada".into(),
1197            ))
1198        }
1199
1200        async fn download(
1201            &mut self,
1202            _remote: &Path,
1203            _local: &Path,
1204        ) -> Result<TransferenciaResultado, ErroSshCli> {
1205            Err(ErroSshCli::CanalFalhou(
1206                "stub sem russh: feature `ssh-real` desabilitada".into(),
1207            ))
1208        }
1209
1210        async fn abrir_canal_tunel(
1211            &self,
1212            _host_remoto: &str,
1213            _porta_remota: u16,
1214            _endereco_origem: &str,
1215            _porta_origem: u16,
1216        ) -> Result<Box<dyn super::CanalTunel>, ErroSshCli> {
1217            Err(ErroSshCli::CanalFalhou(
1218                "stub sem russh: feature `ssh-real` desabilitada".into(),
1219            ))
1220        }
1221
1222        async fn desconectar(&self) -> Result<(), ErroSshCli> {
1223            Ok(())
1224        }
1225    }
1226}
1227
1228#[cfg(not(feature = "ssh-real"))]
1229pub use stub::ClienteSsh;
1230
1231// =========================================================================
1232// Testes unitários (sem rede, sem feature gate).
1233// =========================================================================
1234
1235#[cfg(test)]
1236mod testes {
1237    use super::*;
1238    use secrecy::SecretString;
1239
1240    fn cfg_valida() -> ConfiguracaoConexao {
1241        ConfiguracaoConexao {
1242            host: "127.0.0.1".to_string(),
1243            porta: 22,
1244            usuario: "root".to_string(),
1245            senha: SecretString::from("senha-exemplo".to_string()),
1246            timeout_ms: 5000,
1247        }
1248    }
1249
1250    #[test]
1251    fn validar_host_vazio_retorna_erro() {
1252        let mut c = cfg_valida();
1253        c.host = String::new();
1254        let r = c.validar();
1255        assert!(r.is_err());
1256        let msg = r.unwrap_err().to_string();
1257        assert!(msg.contains("host"));
1258    }
1259
1260    #[test]
1261    fn validar_host_apenas_espacos_retorna_erro() {
1262        let mut c = cfg_valida();
1263        c.host = "   ".to_string();
1264        assert!(c.validar().is_err());
1265    }
1266
1267    #[test]
1268    fn validar_porta_zero_retorna_erro() {
1269        let mut c = cfg_valida();
1270        c.porta = 0;
1271        let r = c.validar();
1272        assert!(r.is_err());
1273        let msg = r.unwrap_err().to_string();
1274        assert!(msg.contains("porta"));
1275    }
1276
1277    #[test]
1278    fn validar_usuario_vazio_retorna_erro() {
1279        let mut c = cfg_valida();
1280        c.usuario = String::new();
1281        assert!(c.validar().is_err());
1282    }
1283
1284    #[test]
1285    fn validar_configuracao_correta_retorna_ok() {
1286        assert!(cfg_valida().validar().is_ok());
1287    }
1288
1289    #[test]
1290    fn debug_nao_expoe_senha() {
1291        let c = cfg_valida();
1292        let dbg = format!("{c:?}");
1293        assert!(!dbg.contains("senha-exemplo"));
1294        assert!(dbg.contains("redacted"));
1295    }
1296
1297    #[test]
1298    fn truncar_utf8_nao_trunca_se_cabe() {
1299        let (s, t) = truncar_utf8("ola mundo", 100);
1300        assert_eq!(s, "ola mundo");
1301        assert!(!t);
1302    }
1303
1304    #[test]
1305    fn truncar_utf8_trunca_string_grande_ascii() {
1306        let entrada: String = "a".repeat(200);
1307        let (s, t) = truncar_utf8(&entrada, 50);
1308        assert_eq!(s.chars().count(), 50);
1309        assert!(t);
1310    }
1311
1312    #[test]
1313    fn truncar_utf8_preserva_grafemas_acentuados() {
1314        // 10 codepoints: "á" (1 char) * 10
1315        let entrada: String = "á".repeat(30);
1316        let (s, t) = truncar_utf8(&entrada, 10);
1317        assert_eq!(s.chars().count(), 10);
1318        // Cada 'á' ocupa 2 bytes em UTF-8 → 10 chars = 20 bytes
1319        assert_eq!(s.len(), 20);
1320        assert!(t);
1321        // Não corta no meio de byte
1322        assert!(s.chars().all(|c| c == 'á'));
1323    }
1324
1325    #[test]
1326    fn truncar_utf8_com_emojis_nao_quebra() {
1327        let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1328        let (s, t) = truncar_utf8(entrada, 5);
1329        assert_eq!(s.chars().count(), 5);
1330        assert!(t);
1331    }
1332
1333    #[test]
1334    fn truncar_utf8_zero_retorna_vazio() {
1335        let (s, t) = truncar_utf8("abc", 0);
1336        assert_eq!(s, "");
1337        assert!(t);
1338    }
1339
1340    #[test]
1341    fn saida_execucao_debug_nao_crasha() {
1342        let s = SaidaExecucao {
1343            stdout: "ok".into(),
1344            stderr: String::new(),
1345            exit_code: Some(0),
1346            truncado_stdout: false,
1347            truncado_stderr: false,
1348            duracao_ms: 42,
1349        };
1350        let _ = format!("{s:?}");
1351    }
1352
1353    #[test]
1354    fn duracao_ms_tipo_compativel() {
1355        // Garantia estática de que instant elapsed cabe em u64.
1356        let fake: u64 = 1234;
1357        assert_eq!(fake, 1234_u64);
1358    }
1359
1360    // =========================================================================
1361    // Cobertura adicional: Clone, Debug e construção de estruturas de saída.
1362    // =========================================================================
1363
1364    #[test]
1365    fn configuracao_conexao_clone_preserva_campos_visiveis() {
1366        let original = cfg_valida();
1367        let copia = original.clone();
1368        assert_eq!(copia.host, original.host);
1369        assert_eq!(copia.porta, original.porta);
1370        assert_eq!(copia.usuario, original.usuario);
1371        assert_eq!(copia.timeout_ms, original.timeout_ms);
1372    }
1373
1374    #[test]
1375    fn debug_contem_campos_principais() {
1376        let c = cfg_valida();
1377        let dbg = format!("{c:?}");
1378        assert!(dbg.contains("127.0.0.1"));
1379        assert!(dbg.contains("22"));
1380        assert!(dbg.contains("root"));
1381        assert!(dbg.contains("5000"));
1382    }
1383
1384    #[test]
1385    fn saida_execucao_clone_preserva_todos_campos() {
1386        let original = SaidaExecucao {
1387            stdout: "saida".to_string(),
1388            stderr: "erro".to_string(),
1389            exit_code: Some(7),
1390            truncado_stdout: true,
1391            truncado_stderr: false,
1392            duracao_ms: 999,
1393        };
1394        let copia = original.clone();
1395        assert_eq!(copia.stdout, "saida");
1396        assert_eq!(copia.stderr, "erro");
1397        assert_eq!(copia.exit_code, Some(7));
1398        assert!(copia.truncado_stdout);
1399        assert!(!copia.truncado_stderr);
1400        assert_eq!(copia.duracao_ms, 999);
1401    }
1402
1403    #[test]
1404    fn saida_execucao_exit_code_none_sinaliza_termino_por_sinal() {
1405        let s = SaidaExecucao {
1406            stdout: String::new(),
1407            stderr: String::new(),
1408            exit_code: None,
1409            truncado_stdout: false,
1410            truncado_stderr: false,
1411            duracao_ms: 0,
1412        };
1413        assert!(s.exit_code.is_none());
1414        let _ = format!("{s:?}");
1415    }
1416
1417    #[test]
1418    fn transferencia_resultado_clone_e_debug() {
1419        let original = TransferenciaResultado {
1420            bytes_transferidos: 1_048_576,
1421            duracao_ms: 2500,
1422        };
1423        let copia = original.clone();
1424        assert_eq!(copia.bytes_transferidos, 1_048_576);
1425        assert_eq!(copia.duracao_ms, 2500);
1426        let dbg = format!("{copia:?}");
1427        assert!(dbg.contains("1048576"));
1428        assert!(dbg.contains("2500"));
1429    }
1430
1431    // =========================================================================
1432    // truncar_utf8: testes adicionais para edge cases.
1433    // =========================================================================
1434
1435    #[test]
1436    fn truncar_utf8_exato_max_chars_nao_marca_truncado() {
1437        let entrada = "abcde";
1438        let (s, t) = truncar_utf8(entrada, 5);
1439        assert_eq!(s, "abcde");
1440        assert!(!t);
1441    }
1442
1443    #[test]
1444    fn truncar_utf8_com_string_vazia_retorna_vazio_sem_truncar() {
1445        let (s, t) = truncar_utf8("", 100);
1446        assert_eq!(s, "");
1447        assert!(!t);
1448    }
1449
1450    #[test]
1451    fn truncar_utf8_com_string_vazia_max_zero_nao_trunca() {
1452        // total == 0 <= max_chars == 0 → retorna cedo, truncou = false
1453        let (s, t) = truncar_utf8("", 0);
1454        assert_eq!(s, "");
1455        assert!(!t);
1456    }
1457
1458    #[test]
1459    fn truncar_utf8_preserva_codepoints_mistos_cjk_emoji() {
1460        // Mistura CJK, emoji e ASCII: cada codepoint vale 1 char, independente de bytes.
1461        let entrada = "a中🔑b漢";
1462        assert_eq!(entrada.chars().count(), 5);
1463        let (s, t) = truncar_utf8(entrada, 3);
1464        assert_eq!(s.chars().count(), 3);
1465        // Os 3 primeiros codepoints são 'a', '中', '🔑'
1466        let colhidos: String = entrada.chars().take(3).collect();
1467        assert_eq!(s, colhidos);
1468        assert!(t);
1469    }
1470
1471    #[test]
1472    fn truncar_utf8_max_muito_maior_que_string_nao_trunca() {
1473        let (s, t) = truncar_utf8("oi", usize::MAX);
1474        assert_eq!(s, "oi");
1475        assert!(!t);
1476    }
1477
1478    // =========================================================================
1479    // Propriedade invariante via loop determinístico (sem proptest para manter
1480    // tempo de execução baixo e evitar dependência de shrinking em CI).
1481    // =========================================================================
1482
1483    #[test]
1484    fn truncar_utf8_invariante_chars_count_sempre_le_max() {
1485        for max in [0usize, 1, 5, 10, 50, 100] {
1486            for entrada in [
1487                "",
1488                "a",
1489                "abcdef",
1490                "á".repeat(20).as_str(),
1491                "🚀",
1492                "🚀🚀🚀🚀🚀",
1493                "中文测试字符串",
1494            ] {
1495                let (s, _) = truncar_utf8(entrada, max);
1496                assert!(
1497                    s.chars().count() <= max.max(0),
1498                    "falha para max={max}, entrada={entrada:?}"
1499                );
1500            }
1501        }
1502    }
1503
1504    // =========================================================================
1505    // Testes que EXERCITAM o MockClienteSsh (cobre as expansões da macro mock!).
1506    // =========================================================================
1507
1508    #[tokio::test]
1509    async fn mock_executar_comando_retorna_saida_configurada() {
1510        use crate::ssh::cliente::mocks::MockClienteSsh;
1511        use crate::ssh::cliente::ClienteSshTrait;
1512
1513        let mut mock = MockClienteSsh::new();
1514        mock.expect_executar_comando().times(1).returning(|cmd, _| {
1515            Ok(SaidaExecucao {
1516                stdout: format!("eco: {cmd}"),
1517                stderr: String::new(),
1518                exit_code: Some(0),
1519                truncado_stdout: false,
1520                truncado_stderr: false,
1521                duracao_ms: 10,
1522            })
1523        });
1524
1525        let saida = mock.executar_comando("echo oi", 100).await.expect("ok");
1526        assert_eq!(saida.stdout, "eco: echo oi");
1527        assert_eq!(saida.exit_code, Some(0));
1528        assert_eq!(saida.duracao_ms, 10);
1529    }
1530
1531    #[tokio::test]
1532    async fn mock_executar_comando_propaga_erro_canal() {
1533        use crate::erros::ErroSshCli;
1534        use crate::ssh::cliente::mocks::MockClienteSsh;
1535        use crate::ssh::cliente::ClienteSshTrait;
1536
1537        let mut mock = MockClienteSsh::new();
1538        mock.expect_executar_comando()
1539            .returning(|_, _| Err(ErroSshCli::CanalFalhou("erro simulado".to_string())));
1540
1541        let erro = mock.executar_comando("ls", 100).await.expect_err("erro");
1542        assert!(matches!(erro, ErroSshCli::CanalFalhou(_)));
1543    }
1544
1545    #[tokio::test]
1546    async fn mock_upload_retorna_transferencia_configurada() {
1547        use crate::ssh::cliente::mocks::MockClienteSsh;
1548        use crate::ssh::cliente::ClienteSshTrait;
1549        use std::path::PathBuf;
1550
1551        let mut mock = MockClienteSsh::new();
1552        mock.expect_upload().times(1).returning(|_, _| {
1553            Ok(TransferenciaResultado {
1554                bytes_transferidos: 4096,
1555                duracao_ms: 50,
1556            })
1557        });
1558
1559        let local = PathBuf::from("/tmp/arquivo_local");
1560        let remote = PathBuf::from("/remote/arquivo");
1561        let resultado = mock.upload(&local, &remote).await.expect("ok");
1562        assert_eq!(resultado.bytes_transferidos, 4096);
1563        assert_eq!(resultado.duracao_ms, 50);
1564    }
1565
1566    #[tokio::test]
1567    async fn mock_download_retorna_transferencia_configurada() {
1568        use crate::ssh::cliente::mocks::MockClienteSsh;
1569        use crate::ssh::cliente::ClienteSshTrait;
1570        use std::path::PathBuf;
1571
1572        let mut mock = MockClienteSsh::new();
1573        mock.expect_download().times(1).returning(|_, _| {
1574            Ok(TransferenciaResultado {
1575                bytes_transferidos: 2048,
1576                duracao_ms: 30,
1577            })
1578        });
1579
1580        let remote = PathBuf::from("/remote/x");
1581        let local = PathBuf::from("/tmp/x");
1582        let resultado = mock.download(&remote, &local).await.expect("ok");
1583        assert_eq!(resultado.bytes_transferidos, 2048);
1584        assert_eq!(resultado.duracao_ms, 30);
1585    }
1586
1587    #[tokio::test]
1588    async fn mock_download_propaga_erro_arquivo() {
1589        use crate::erros::ErroSshCli;
1590        use crate::ssh::cliente::mocks::MockClienteSsh;
1591        use crate::ssh::cliente::ClienteSshTrait;
1592        use std::path::PathBuf;
1593
1594        let mut mock = MockClienteSsh::new();
1595        mock.expect_download()
1596            .returning(|_, _| Err(ErroSshCli::ArquivoNaoEncontrado("inexistente".to_string())));
1597
1598        let erro = mock
1599            .download(&PathBuf::from("/r"), &PathBuf::from("/l"))
1600            .await
1601            .expect_err("erro");
1602        assert!(matches!(erro, ErroSshCli::ArquivoNaoEncontrado(_)));
1603    }
1604
1605    #[tokio::test]
1606    async fn mock_desconectar_ok() {
1607        use crate::ssh::cliente::mocks::MockClienteSsh;
1608        use crate::ssh::cliente::ClienteSshTrait;
1609
1610        let mut mock = MockClienteSsh::new();
1611        mock.expect_desconectar().times(1).returning(|| Ok(()));
1612
1613        assert!(mock.desconectar().await.is_ok());
1614    }
1615
1616    #[tokio::test]
1617    async fn mock_desconectar_propaga_erro() {
1618        use crate::erros::ErroSshCli;
1619        use crate::ssh::cliente::mocks::MockClienteSsh;
1620        use crate::ssh::cliente::ClienteSshTrait;
1621
1622        let mut mock = MockClienteSsh::new();
1623        mock.expect_desconectar()
1624            .returning(|| Err(ErroSshCli::ConexaoFalhou("eof".to_string())));
1625
1626        let erro = mock.desconectar().await.expect_err("erro");
1627        assert!(matches!(erro, ErroSshCli::ConexaoFalhou(_)));
1628    }
1629
1630    #[tokio::test]
1631    async fn mock_executar_comando_invocado_multiplas_vezes_respeita_times() {
1632        use crate::ssh::cliente::mocks::MockClienteSsh;
1633        use crate::ssh::cliente::ClienteSshTrait;
1634
1635        let mut mock = MockClienteSsh::new();
1636        mock.expect_executar_comando().times(3).returning(|_, _| {
1637            Ok(SaidaExecucao {
1638                stdout: "ok".to_string(),
1639                stderr: String::new(),
1640                exit_code: Some(0),
1641                truncado_stdout: false,
1642                truncado_stderr: false,
1643                duracao_ms: 1,
1644            })
1645        });
1646
1647        for _ in 0..3 {
1648            let r = mock.executar_comando("x", 10).await.expect("ok");
1649            assert_eq!(r.stdout, "ok");
1650        }
1651    }
1652
1653    #[tokio::test]
1654    async fn mock_executar_comando_com_with_matcher_filtra_argumentos() {
1655        use crate::ssh::cliente::mocks::MockClienteSsh;
1656        use crate::ssh::cliente::ClienteSshTrait;
1657        use mockall::predicate::*;
1658
1659        let mut mock = MockClienteSsh::new();
1660        mock.expect_executar_comando()
1661            .with(eq("ls -la"), eq(500usize))
1662            .times(1)
1663            .returning(|_, _| {
1664                Ok(SaidaExecucao {
1665                    stdout: "listagem".to_string(),
1666                    stderr: String::new(),
1667                    exit_code: Some(0),
1668                    truncado_stdout: false,
1669                    truncado_stderr: false,
1670                    duracao_ms: 5,
1671                })
1672            });
1673
1674        let r = mock.executar_comando("ls -la", 500).await.expect("ok");
1675        assert_eq!(r.stdout, "listagem");
1676    }
1677
1678    #[tokio::test]
1679    async fn mock_upload_com_predicate_caminho() {
1680        use crate::ssh::cliente::mocks::MockClienteSsh;
1681        use crate::ssh::cliente::ClienteSshTrait;
1682        use mockall::predicate::*;
1683        use std::path::{Path, PathBuf};
1684
1685        let mut mock = MockClienteSsh::new();
1686        mock.expect_upload()
1687            .with(eq(Path::new("/tmp/a")), eq(Path::new("/remote/b")))
1688            .returning(|_, _| {
1689                Ok(TransferenciaResultado {
1690                    bytes_transferidos: 10,
1691                    duracao_ms: 1,
1692                })
1693            });
1694
1695        let r = mock
1696            .upload(&PathBuf::from("/tmp/a"), &PathBuf::from("/remote/b"))
1697            .await
1698            .expect("ok");
1699        assert_eq!(r.bytes_transferidos, 10);
1700    }
1701
1702    #[tokio::test]
1703    async fn mock_abrir_canal_tunel_propaga_erro_canal() {
1704        use crate::erros::ErroSshCli;
1705        use crate::ssh::cliente::mocks::MockClienteSsh;
1706        use crate::ssh::cliente::ClienteSshTrait;
1707
1708        let mut mock = MockClienteSsh::new();
1709        mock.expect_abrir_canal_tunel()
1710            .returning(|_, _, _, _| Err(ErroSshCli::CanalFalhou("sem tunnel".to_string())));
1711
1712        let resultado = mock
1713            .abrir_canal_tunel("host.exemplo", 8080, "127.0.0.1", 12345)
1714            .await;
1715        match resultado {
1716            Ok(_) => panic!("esperava erro, recebeu Ok"),
1717            Err(ErroSshCli::CanalFalhou(_)) => {}
1718            Err(outro) => panic!("variante de erro inesperada: {outro:?}"),
1719        }
1720    }
1721
1722    #[tokio::test]
1723    async fn mock_fluxo_completo_conectar_exec_desconectar() {
1724        // Exercita o fluxo esperado de um cliente: executar_comando seguido de
1725        // desconectar, cobrindo dois métodos do mock em sequência.
1726        use crate::ssh::cliente::mocks::MockClienteSsh;
1727        use crate::ssh::cliente::ClienteSshTrait;
1728
1729        let mut mock = MockClienteSsh::new();
1730        mock.expect_executar_comando().returning(|_, _| {
1731            Ok(SaidaExecucao {
1732                stdout: "hostname-x".to_string(),
1733                stderr: String::new(),
1734                exit_code: Some(0),
1735                truncado_stdout: false,
1736                truncado_stderr: false,
1737                duracao_ms: 7,
1738            })
1739        });
1740        mock.expect_desconectar().returning(|| Ok(()));
1741
1742        let saida = mock.executar_comando("hostname", 200).await.expect("ok");
1743        assert_eq!(saida.stdout, "hostname-x");
1744        mock.desconectar().await.expect("desconecta");
1745    }
1746
1747    #[tokio::test]
1748    async fn mock_conectar_retorna_caixa_do_mock() {
1749        // A associated fn `conectar` do trait NÃO pode ser chamada em instância
1750        // já criada; aqui exercitamos a expectativa estática do mock para
1751        // cobrir a macro.
1752        use crate::ssh::cliente::mocks::MockClienteSsh;
1753
1754        // Define expectativa estática (cobre expansão `ExpectationGuard`).
1755        let _guard = MockClienteSsh::conectar_context();
1756        // Sem chamada real — apenas exercita a construção do contexto.
1757        drop(_guard);
1758    }
1759
1760    // =========================================================================
1761    // Testes adicionais que exercitam MAIS variantes da macro `mockall::mock!`
1762    // (return_once, return_const, never, etc.) para elevar cobertura das
1763    // funções auxiliares geradas automaticamente.
1764    // =========================================================================
1765
1766    #[tokio::test]
1767    async fn mock_executar_comando_com_return_once() {
1768        use crate::ssh::cliente::mocks::MockClienteSsh;
1769        use crate::ssh::cliente::ClienteSshTrait;
1770
1771        let mut mock = MockClienteSsh::new();
1772        let saida = SaidaExecucao {
1773            stdout: "único".to_string(),
1774            stderr: String::new(),
1775            exit_code: Some(0),
1776            truncado_stdout: false,
1777            truncado_stderr: false,
1778            duracao_ms: 3,
1779        };
1780        mock.expect_executar_comando()
1781            .return_once(move |_, _| Ok(saida));
1782
1783        let r = mock.executar_comando("once", 100).await.expect("ok");
1784        assert_eq!(r.stdout, "único");
1785    }
1786
1787    #[tokio::test]
1788    async fn mock_desconectar_com_returning_ok() {
1789        // Variante alternativa para cobrir `returning` (sem `return_const`
1790        // porque `Result<(), ErroSshCli>` não implementa `Clone`).
1791        use crate::ssh::cliente::mocks::MockClienteSsh;
1792        use crate::ssh::cliente::ClienteSshTrait;
1793
1794        let mut mock = MockClienteSsh::new();
1795        mock.expect_desconectar().returning(|| Ok(()));
1796
1797        assert!(mock.desconectar().await.is_ok());
1798    }
1799
1800    #[tokio::test]
1801    async fn mock_upload_usado_zero_vezes_respeita_never() {
1802        use crate::ssh::cliente::mocks::MockClienteSsh;
1803
1804        let mut mock = MockClienteSsh::new();
1805        mock.expect_upload().never();
1806        // Dropa sem chamar upload — expectativa `never` é satisfeita.
1807        drop(mock);
1808    }
1809
1810    #[tokio::test]
1811    async fn mock_download_com_times_range() {
1812        use crate::ssh::cliente::mocks::MockClienteSsh;
1813        use crate::ssh::cliente::ClienteSshTrait;
1814        use std::path::PathBuf;
1815
1816        let mut mock = MockClienteSsh::new();
1817        mock.expect_download().times(1..=2).returning(|_, _| {
1818            Ok(TransferenciaResultado {
1819                bytes_transferidos: 1,
1820                duracao_ms: 1,
1821            })
1822        });
1823
1824        let r = mock
1825            .download(&PathBuf::from("/r"), &PathBuf::from("/l"))
1826            .await
1827            .expect("ok");
1828        assert_eq!(r.bytes_transferidos, 1);
1829    }
1830
1831    #[tokio::test]
1832    async fn mock_executar_comando_com_never_e_dropa() {
1833        use crate::ssh::cliente::mocks::MockClienteSsh;
1834
1835        let mut mock = MockClienteSsh::new();
1836        mock.expect_executar_comando().never();
1837        // Nenhuma chamada — dropar sem panic valida `never`.
1838        drop(mock);
1839    }
1840
1841    #[tokio::test]
1842    async fn mock_desconectar_com_returning_sem_argumentos() {
1843        use crate::ssh::cliente::mocks::MockClienteSsh;
1844        use crate::ssh::cliente::ClienteSshTrait;
1845
1846        let mut mock = MockClienteSsh::new();
1847        mock.expect_desconectar().returning(|| Ok(()));
1848
1849        assert!(mock.desconectar().await.is_ok());
1850        // Reforça trait bound: Send + Sync para conversão em Box<dyn>.
1851        let _boxed: Box<dyn ClienteSshTrait> = Box::new(mock);
1852    }
1853
1854    #[tokio::test]
1855    async fn mock_upload_com_times_exato() {
1856        use crate::ssh::cliente::mocks::MockClienteSsh;
1857        use crate::ssh::cliente::ClienteSshTrait;
1858        use std::path::PathBuf;
1859
1860        let mut mock = MockClienteSsh::new();
1861        mock.expect_upload().times(2).returning(|_, _| {
1862            Ok(TransferenciaResultado {
1863                bytes_transferidos: 512,
1864                duracao_ms: 10,
1865            })
1866        });
1867
1868        for _ in 0..2 {
1869            let r = mock
1870                .upload(&PathBuf::from("/a"), &PathBuf::from("/b"))
1871                .await
1872                .expect("ok");
1873            assert_eq!(r.bytes_transferidos, 512);
1874        }
1875    }
1876
1877    #[tokio::test]
1878    async fn mock_abrir_canal_tunel_com_returning_captura_argumentos() {
1879        use crate::erros::ErroSshCli;
1880        use crate::ssh::cliente::mocks::MockClienteSsh;
1881        use crate::ssh::cliente::ClienteSshTrait;
1882
1883        let mut mock = MockClienteSsh::new();
1884        mock.expect_abrir_canal_tunel()
1885            .returning(|host, porta, origem, porta_origem| {
1886                assert_eq!(host, "servidor.exemplo");
1887                assert_eq!(porta, 443);
1888                assert_eq!(origem, "127.0.0.1");
1889                assert_eq!(porta_origem, 8443);
1890                Err(ErroSshCli::CanalFalhou("fake".to_string()))
1891            });
1892
1893        let resultado = mock
1894            .abrir_canal_tunel("servidor.exemplo", 443, "127.0.0.1", 8443)
1895            .await;
1896        assert!(resultado.is_err());
1897    }
1898
1899    #[tokio::test]
1900    async fn mock_executar_comando_com_in_sequence() {
1901        // Exercita a composição de múltiplas expectativas em sequência
1902        // determinística (cobre funções auxiliares de ordenação).
1903        use crate::ssh::cliente::mocks::MockClienteSsh;
1904        use crate::ssh::cliente::ClienteSshTrait;
1905        use mockall::Sequence;
1906
1907        let mut mock = MockClienteSsh::new();
1908        let mut seq = Sequence::new();
1909
1910        mock.expect_executar_comando()
1911            .times(1)
1912            .in_sequence(&mut seq)
1913            .returning(|_, _| {
1914                Ok(SaidaExecucao {
1915                    stdout: "primeiro".to_string(),
1916                    stderr: String::new(),
1917                    exit_code: Some(0),
1918                    truncado_stdout: false,
1919                    truncado_stderr: false,
1920                    duracao_ms: 1,
1921                })
1922            });
1923
1924        mock.expect_executar_comando()
1925            .times(1)
1926            .in_sequence(&mut seq)
1927            .returning(|_, _| {
1928                Ok(SaidaExecucao {
1929                    stdout: "segundo".to_string(),
1930                    stderr: String::new(),
1931                    exit_code: Some(0),
1932                    truncado_stdout: false,
1933                    truncado_stderr: false,
1934                    duracao_ms: 1,
1935                })
1936            });
1937
1938        let r1 = mock.executar_comando("a", 10).await.expect("ok");
1939        assert_eq!(r1.stdout, "primeiro");
1940        let r2 = mock.executar_comando("b", 10).await.expect("ok");
1941        assert_eq!(r2.stdout, "segundo");
1942    }
1943}