Skip to main content

ssh_cli/ssh/
client.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Cliente SSH real via `russh` 0.62.2.
3//!
4//! One-shot connection: TCP + handshake + auth (password and/or key) + exec with
5//! timeout, output truncation, and best-effort remote abort.
6//! Host keys: TOFU em `known_hosts` XDG (ver [`super::known_hosts`]).
7
8use crate::erros::{SshCliError, SshCliResult};
9use secrecy::{ExposeSecret, SecretString};
10use std::path::PathBuf;
11use tokio::io::{AsyncRead, AsyncWrite};
12
13/// SSH connection configuration.
14///
15/// Built from a [`crate::vps::model::VpsRecord`] at the time
16/// of the call. Auth: private key (preferred) and/or password.
17#[derive(Clone)]
18pub struct ConnectionConfig {
19    /// Hostname ou IP do servidor SSH.
20    pub host: String,
21    /// SSH server TCP port (default 22).
22    pub port: u16,
23    /// SSH username.
24    pub username: String,
25    /// SSH password (`SecretString` for automatic zeroize); may be empty for key-only.
26    pub password: SecretString,
27    /// OpenSSH private key path (optional).
28    pub key_path: Option<String>,
29    /// Key passphrase (optional).
30    pub key_passphrase: Option<SecretString>,
31    /// Total timeout for connect + handshake + authentication + exec, in ms.
32    pub timeout_ms: u64,
33    /// Path to known_hosts file (TOFU). `None` = always-trust (tests only).
34    pub known_hosts_path: Option<PathBuf>,
35    /// Se true, permite substituir fingerprint divergente.
36    pub replace_host_key: bool,
37}
38
39impl std::fmt::Debug for ConnectionConfig {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("ConnectionConfig")
42            .field("host", &self.host)
43            .field("porta", &self.port)
44            .field("usuario", &self.username)
45            .field("senha", &"<redacted>")
46            .field("key_path", &self.key_path)
47            .field(
48                "key_passphrase",
49                &self.key_passphrase.as_ref().map(|_| "<redacted>"),
50            )
51            .field("timeout_ms", &self.timeout_ms)
52            .field("known_hosts_path", &self.known_hosts_path)
53            .field("replace_host_key", &self.replace_host_key)
54            .finish()
55    }
56}
57
58impl ConnectionConfig {
59    /// Validates basic configuration fields.
60    pub fn validate(&self) -> SshCliResult<()> {
61        if self.host.trim().is_empty() {
62            return Err(SshCliError::InvalidArgument(
63                "empty host in ConnectionConfig".to_string(),
64            ));
65        }
66        if self.port == 0 {
67            return Err(SshCliError::InvalidArgument(
68                "port 0 is invalid in ConnectionConfig".to_string(),
69            ));
70        }
71        if self.username.trim().is_empty() {
72            return Err(SshCliError::InvalidArgument(
73                "empty user in ConnectionConfig".to_string(),
74            ));
75        }
76        let has_password = !self.password.expose_secret().is_empty();
77        let tem_key = self.key_path.as_ref().is_some_and(|p| !p.trim().is_empty());
78        if !has_password && !tem_key {
79            return Err(SshCliError::InvalidArgument(
80                "auth requires password or key_path".to_string(),
81            ));
82        }
83        Ok(())
84    }
85}
86
87/// Output of a remote SSH command execution.
88#[derive(Debug, Clone)]
89pub struct ExecutionOutput {
90    /// Stdout capturado (possivelmente truncated a `max_chars` codepoints).
91    pub stdout: String,
92    /// Stderr capturado (possivelmente truncated a `max_chars` codepoints).
93    pub stderr: String,
94    /// Exit code. `None` when the command was terminated by signal or timeout.
95    pub exit_code: Option<i32>,
96    /// `true` se `stdout` foi truncated em `max_chars`.
97    pub truncated_stdout: bool,
98    /// `true` se `stderr` foi truncated em `max_chars`.
99    pub truncated_stderr: bool,
100    /// Total execution duration in milliseconds.
101    pub duration_ms: u64,
102}
103
104/// Result of an SCP file transfer operation.
105#[derive(Debug, Clone)]
106pub struct TransferResult {
107    /// Number of bytes transferred.
108    pub bytes_transferred: u64,
109    /// Total duration in milliseconds.
110    pub duration_ms: u64,
111}
112
113/// Truncates a UTF-8 string to at most `max_chars` codepoints.
114///
115/// Returns `(truncated_string, was_truncated)`. If `max_chars == 0` returns empty string.
116/// Unicode-safe: opera sobre codepoints via `chars()`, nunca quebra no meio.
117#[must_use]
118pub fn truncate_utf8(content: &str, max_chars: usize) -> (String, bool) {
119    let total = content.chars().count();
120    if total <= max_chars {
121        return (content.to_string(), false);
122    }
123    let truncated: String = content.chars().take(max_chars).collect();
124    (truncated, true)
125}
126
127// =========================================================================
128// Trait SshClientTrait para permitir mocks em teste.
129// =========================================================================
130
131use async_trait::async_trait;
132use std::path::Path;
133
134/// Stream bidirecional usado para tunnel SSH (direct-tcpip).
135pub trait TunnelChannel: AsyncRead + AsyncWrite + Unpin + Send {}
136
137impl<T> TunnelChannel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
138
139/// SSH client trait allowing a real (russh) or mock implementation for tests.
140///
141/// This trait abstracts SSH connection operations to allow unit tests
142/// without needing a real network connection.
143#[async_trait]
144pub trait SshClientTrait: Send + Sync + 'static {
145    /// Connects to an SSH server and authenticates with the provided credentials.
146    async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError>
147    where
148        Self: Sized;
149
150    /// Runs a remote shell command and returns the captured output.
151    ///
152    /// `stdin_data`, if present, is written to the channel after `exec` and before the loop
153    /// de leitura (GAP-SSH-SEC-001: password sudo/su fora da argv remota).
154    async fn run_command(
155        &mut self,
156        cmd: &str,
157        max_chars: usize,
158        stdin_data: Option<Vec<u8>>,
159    ) -> Result<ExecutionOutput, SshCliError>;
160
161    /// Faz upload de um file local para o servidor remoto via SCP.
162    async fn upload(
163        &mut self,
164        local: &Path,
165        remote: &Path,
166    ) -> Result<TransferResult, SshCliError>;
167
168    /// Faz download de um file remoto para o sistema local via SCP.
169    async fn download(
170        &mut self,
171        remote: &Path,
172        local: &Path,
173    ) -> Result<TransferResult, SshCliError>;
174
175    /// Abre um channel `direct-tcpip` para forwarding de tunnel.
176    async fn open_tunnel_channel(
177        &self,
178        remote_host: &str,
179        remote_port: u16,
180        endereco_origem: &str,
181        porta_origem: u16,
182    ) -> Result<Box<dyn TunnelChannel>, SshCliError>;
183
184    /// Cleanly closes the SSH connection.
185    async fn disconnect(&self) -> Result<(), SshCliError>;
186}
187
188#[cfg(test)]
189/// SSH client mocks used in unit tests.
190pub mod mocks {
191    use super::*;
192    use mockall::mock;
193
194    mock! {
195        pub SshClient {}
196
197    #[async_trait]
198    impl crate::ssh::client::SshClientTrait for SshClient {
199            async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError>;
200            async fn run_command(&mut self, cmd: &str, max_chars: usize, stdin_data: Option<Vec<u8>>) -> Result<ExecutionOutput, SshCliError>;
201            async fn upload(&mut self, local: &Path, remote: &Path) -> Result<TransferResult, SshCliError>;
202            async fn download(&mut self, remote: &Path, local: &Path) -> Result<TransferResult, SshCliError>;
203            async fn open_tunnel_channel(
204                &self,
205                remote_host: &str,
206                remote_port: u16,
207                endereco_origem: &str,
208                porta_origem: u16,
209            ) -> Result<Box<dyn TunnelChannel>, SshCliError>;
210            async fn disconnect(&self) -> Result<(), SshCliError>;
211        }
212    }
213}
214
215// =========================================================================
216// REAL SSH implementation (`ssh-real` feature).
217// =========================================================================
218
219#[cfg(feature = "ssh-real")]
220mod real {
221    use super::{
222        TunnelChannel, SshClientTrait, ConnectionConfig, ExecutionOutput, TransferResult,
223    };
224    use crate::erros::{SshCliError, SshCliResult};
225    use async_trait::async_trait;
226    use secrecy::ExposeSecret;
227    use std::path::Path;
228    use std::sync::Arc;
229    use std::time::{Duration, Instant};
230
231    /// russh handler with TOFU known_hosts (or always-trust if path is absent).
232    pub struct ClientHandler {
233        host: String,
234        port: u16,
235        known_hosts_path: Option<std::path::PathBuf>,
236        replace_host_key: bool,
237        /// Captured host-key error (russh Error does not carry our type).
238        host_key_rejected: bool,
239        host_key_detail: Option<String>,
240    }
241
242    impl ClientHandler {
243        fn new(cfg: &ConnectionConfig) -> Self {
244            Self {
245                host: cfg.host.clone(),
246                port: cfg.port,
247                known_hosts_path: cfg.known_hosts_path.clone(),
248                replace_host_key: cfg.replace_host_key,
249                host_key_rejected: false,
250                host_key_detail: None,
251            }
252        }
253    }
254
255    impl russh::client::Handler for ClientHandler {
256        type Error = russh::Error;
257
258        async fn check_server_key(
259            &mut self,
260            server_key: &russh::keys::ssh_key::PublicKey,
261        ) -> Result<bool, Self::Error> {
262            let fingerprint = format!(
263                "{}",
264                server_key.fingerprint(russh::keys::HashAlg::Sha256)
265            );
266
267            let Some(path) = self.known_hosts_path.clone() else {
268                tracing::warn!("known_hosts missing: accepting host key (test mode)");
269                return Ok(true);
270            };
271
272            let mut kh = match crate::ssh::known_hosts::KnownHosts::load(path) {
273                Ok(k) => k,
274                Err(e) => {
275                    tracing::error!(err = %e, "failed to load known_hosts");
276                    self.host_key_rejected = true;
277                    self.host_key_detail = Some(e.to_string());
278                    return Ok(false);
279                }
280            };
281
282            match crate::ssh::known_hosts::verify_tofu(
283                &mut kh,
284                &self.host,
285                self.port,
286                &fingerprint,
287                self.replace_host_key,
288            ) {
289                Ok(true) => Ok(true),
290                Ok(false) => Ok(false),
291                Err(e) => {
292                    self.host_key_rejected = true;
293                    self.host_key_detail = Some(e.to_string());
294                    tracing::error!(err = %e, "host key rejeitada");
295                    Ok(false)
296                }
297            }
298        }
299    }
300
301    /// Active SSH client with an authenticated session.
302    pub struct SshClient {
303        /// Authenticated SSH session for low-level operations.
304        pub session: russh::client::Handle<ClientHandler>,
305        cfg: ConnectionConfig,
306    }
307
308    impl std::fmt::Debug for SshClient {
309        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310            f.debug_struct("SshClient")
311                .field("host", &self.cfg.host)
312                .field("port", &self.cfg.port)
313                .field("user", &self.cfg.username)
314                .field("timeout_ms", &self.cfg.timeout_ms)
315                .finish()
316        }
317    }
318
319    fn map_exit_status(exit_status: u32) -> i32 {
320        i32::try_from(exit_status).unwrap_or(-1)
321    }
322
323    fn process_exec_message(
324        msg: russh::ChannelMsg,
325        stdout_bytes: &mut Vec<u8>,
326        stderr_bytes: &mut Vec<u8>,
327        exit_code: &mut Option<i32>,
328    ) -> bool {
329        use russh::ChannelMsg;
330
331        match msg {
332            ChannelMsg::Data { data } => {
333                stdout_bytes.extend_from_slice(&data);
334            }
335            ChannelMsg::ExtendedData { data, ext } => {
336                // ext == 1 → SSH_EXTENDED_DATA_STDERR (RFC 4254 §5.2).
337                if ext == 1 {
338                    stderr_bytes.extend_from_slice(&data);
339                } else {
340                    tracing::debug!(ext, "extended data ignored");
341                }
342            }
343            ChannelMsg::ExitStatus { exit_status } => {
344                // russh entrega como u32. Mantemos como i32 para acomodar
345                // Unix conventions (shells may emit codes as u8 in
346                // wait-status; here it is already the application exit code, 0..=255).
347                *exit_code = Some(map_exit_status(exit_status));
348                // Do NOT return true: wait for Eof/Close after ExitStatus.
349            }
350            ChannelMsg::ExitSignal {
351                signal_name,
352                core_dumped,
353                error_message,
354                ..
355            } => {
356                tracing::warn!(
357                    ?signal_name,
358                    core_dumped,
359                    %error_message,
360                    "remote process terminated by signal"
361                );
362                // Sem exit_status → mantemos None.
363            }
364            ChannelMsg::Eof => {
365                tracing::debug!("EOF on SSH channel");
366            }
367            ChannelMsg::Close => {
368                tracing::debug!("SSH channel closed by server");
369                return true;
370            }
371            _ => {}
372        }
373
374        false
375    }
376
377    /// SCP protocol ACK/OK byte (also used as payload terminator).
378    const SCP_OK: u8 = 0;
379
380    /// Safe basename for the SCP wire (no path separators / control chars).
381    fn basename_scp(file_name: &str) -> String {
382        file_name
383            .split(['/', '\\'])
384            .next_back()
385            .unwrap_or("file")
386            .replace(['\n', '\r', '\0'], "_")
387    }
388
389    /// Header `C`-line do protocolo SCP (newline real `0x0a`, nunca `\\n` literal).
390    #[cfg_attr(not(test), allow(dead_code))]
391    fn format_scp_upload_header(size: u64, file_name: &str) -> String {
392        format_scp_upload_header_with_mode(0o644, size, file_name)
393    }
394
395    /// Header `C` with octal mode (e.g. `0644`).
396    fn format_scp_upload_header_with_mode(mode: u32, size: u64, file_name: &str) -> String {
397        let name = basename_scp(file_name);
398        let mode = mode & 0o7777;
399        format!("C{mode:04o} {size} {name}\n")
400    }
401
402    /// Linha `T` do protocolo SCP (preserve times / `-p`).
403    fn format_scp_t_line(mtime_secs: u64, atime_secs: u64) -> String {
404        format!("T{mtime_secs} 0 {atime_secs} 0\n")
405    }
406
407    /// Parse da line `T mtime 0 atime 0`.
408    fn parse_scp_t_line(line: &str) -> SshCliResult<(u64, u64)> {
409        let line = line.trim_end_matches(['\0', '\r', '\n']).trim();
410        if !line.starts_with('T') {
411            return Err(SshCliError::ChannelFailed(format!(
412                "unexpected SCP T line: {line}"
413            )));
414        }
415        let resto = &line[1..];
416        let partes: Vec<&str> = resto.split_whitespace().collect();
417        if partes.len() < 3 {
418            return Err(SshCliError::ChannelFailed(format!(
419                "malformed SCP T line: {line}"
420            )));
421        }
422        let mtime: u64 = partes[0].parse().map_err(|_| {
423            SshCliError::ChannelFailed(format!("invalid mtime in T line: {}", partes[0]))
424        })?;
425        let atime: u64 = partes[2].parse().map_err(|_| {
426            SshCliError::ChannelFailed(format!("invalid atime in T line: {}", partes[2]))
427        })?;
428        Ok((mtime, atime))
429    }
430
431    /// Parse do header `C0mmm size name` → `(mode, size)`.
432    fn parse_scp_header(header: &str) -> SshCliResult<(u32, u64)> {
433        let header = header.trim_end_matches(['\0', '\r', '\n']).trim();
434
435        if !header.starts_with('C') {
436            return Err(SshCliError::ChannelFailed(format!(
437                "unexpected SCP header: {}",
438                header
439            )));
440        }
441
442        let partes: Vec<&str> = header.split_whitespace().collect();
443        if partes.len() < 3 {
444            return Err(SshCliError::ChannelFailed(format!(
445                "malformed SCP header: {}",
446                header
447            )));
448        }
449
450        // Mode field: `C0644` (`C` prefix + 4 octal digits).
451        let mode_token = partes[0];
452        if mode_token.len() < 2 {
453            return Err(SshCliError::ChannelFailed(format!(
454                "missing SCP mode in header: {header}"
455            )));
456        }
457        let mode_oct = &mode_token[1..];
458        let mode: u32 = u32::from_str_radix(mode_oct, 8)
459            .map_err(|_| SshCliError::ChannelFailed(format!("invalid SCP mode: {mode_oct}")))?;
460
461        let size = partes[1].parse().map_err(|_| {
462            SshCliError::ChannelFailed(format!("invalid size in header: {}", partes[1]))
463        })?;
464        Ok((mode & 0o7777, size))
465    }
466
467    /// Mode octal para o header `C` a partir de metadata local.
468    fn scp_mode_from_metadata(meta: &std::fs::Metadata) -> u32 {
469        #[cfg(unix)]
470        {
471            use std::os::unix::fs::PermissionsExt;
472            meta.permissions().mode() & 0o7777
473        }
474        #[cfg(not(unix))]
475        {
476            let _ = meta;
477            0o644
478        }
479    }
480
481    /// Segundos epoch a partir de SystemTime (best-effort).
482    fn system_time_secs(t: std::time::SystemTime) -> u64 {
483        t.duration_since(std::time::UNIX_EPOCH)
484            .map(|d| d.as_secs())
485            .unwrap_or(0)
486    }
487
488    /// Atomic download temporary file suffix (SCP-022).
489    const SCP_PARTIAL_SUFFIX: &str = ".ssh-cli.partial";
490
491    fn partial_download_path(local: &std::path::Path) -> std::path::PathBuf {
492        let mut p = local.as_os_str().to_os_string();
493        p.push(SCP_PARTIAL_SUFFIX);
494        std::path::PathBuf::from(p)
495    }
496
497    /// GAP-SSH-IO-010 / GAP-AUD-025: classify SCP err → missing-file (66) vs channel (74).
498    ///
499    /// OpenSSH typically emits `scp: PATH: No such file or directory` on status `1`/`2`.
500    /// Missing-file messages are normalized to a clean path for the Display wrapper
501    /// `file not found: {path}` (no stacked `SCP:` / `scp:` prefixes).
502    fn classify_scp_message(msg: &str) -> SshCliError {
503        let lower = msg.to_ascii_lowercase();
504        if lower.contains("no such file") || lower.contains("not found") {
505            SshCliError::FileNotFound(normalize_scp_missing_path(msg))
506        } else if msg.is_empty() {
507            SshCliError::ChannelFailed("SCP rejected the transfer".to_string())
508        } else if msg.starts_with("SCP:") || msg.starts_with("SCP ") {
509            SshCliError::ChannelFailed(msg.to_string())
510        } else {
511            SshCliError::ChannelFailed(format!("SCP: {msg}"))
512        }
513    }
514
515    /// Strips `SCP:` / `scp:` wrappers and trailing OS phrases → remote path or cleaned msg.
516    fn normalize_scp_missing_path(msg: &str) -> String {
517        let mut s = msg.trim().to_string();
518        for prefix in ["SCP: ", "SCP:", "scp: ", "scp:"] {
519            if let Some(rest) = s.strip_prefix(prefix) {
520                s = rest.trim().to_string();
521            }
522        }
523        // Pattern: `/path: No such file or directory` or `path: not found`
524        let lower = s.to_ascii_lowercase();
525        for needle in [": no such file or directory", ": not found"] {
526            if let Some(idx) = lower.find(needle) {
527                return s[..idx].trim().trim_matches('"').to_string();
528            }
529        }
530        s
531    }
532
533    /// Interpreta o primeiro byte de status SCP: `0`=OK, `1`/`2`=err (+ mensagem).
534    fn interpret_scp_status(bytes: &[u8]) -> SshCliResult<()> {
535        if bytes.is_empty() {
536            return Err(SshCliError::ChannelFailed(
537                "empty SCP status (expected ACK 0x00)".to_string(),
538            ));
539        }
540        match bytes[0] {
541            SCP_OK => Ok(()),
542            1 | 2 => {
543                let msg = String::from_utf8_lossy(&bytes[1..]).trim().to_string();
544                if msg.is_empty() {
545                    Err(SshCliError::ChannelFailed(format!(
546                        "SCP rejected the transfer (status {})",
547                        bytes[0]
548                    )))
549                } else {
550                    // Stable prefix for agents; classifier looks at OpenSSH text.
551                    let full = format!("SCP: {msg}");
552                    Err(classify_scp_message(&full))
553                }
554            }
555            other => Err(SshCliError::ChannelFailed(format!(
556                "unexpected SCP status: 0x{other:02x}"
557            ))),
558        }
559    }
560
561    /// Builds `scp -t[p]/-f[p]` with remote path escaped for the remote shell.
562    ///
563    /// OpenSSH: source (`-f`) only emits `T` line and honest mode with **`-p`**.
564    /// Sink (`-t`) with `-p` applies full mode (no sticky umask mask).
565    /// Sempre usamos `-p` (SCP-023 bi-direcional).
566    fn remote_scp_command(mode: &str, remote: &std::path::Path) -> String {
567        let path = crate::ssh::packing::escape_shell_single_quotes(&remote.display().to_string());
568        // `mode` expected: `-t` or `-f` (without `-p`); we append `p` explicitly.
569        let mode_p = if mode.contains('p') {
570            mode.to_string()
571        } else {
572            format!("{mode}p")
573        };
574        // Path in single-quotes (no `--` for maximum legacy OpenSSH scp compatibility).
575        format!("scp {mode_p} {path}")
576    }
577
578    /// Aplica mode POSIX do header `C` no file local (best-effort no Unix).
579    fn apply_local_mode(path: &std::path::Path, mode: u32) -> SshCliResult<()> {
580        #[cfg(unix)]
581        {
582            use std::os::unix::fs::PermissionsExt;
583            let perms = std::fs::Permissions::from_mode(mode & 0o7777);
584            std::fs::set_permissions(path, perms).map_err(SshCliError::Io)?;
585        }
586        #[cfg(not(unix))]
587        {
588            let _ = (path, mode);
589        }
590        Ok(())
591    }
592
593    /// Reads the next non-empty `ChannelMsg::Data` from the SCP channel.
594    async fn scp_read_data<S>(channel: &mut russh::Channel<S>) -> SshCliResult<Vec<u8>>
595    where
596        S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
597    {
598        use russh::ChannelMsg;
599        loop {
600            match channel.wait().await {
601                Some(ChannelMsg::Data { data }) => {
602                    if data.is_empty() {
603                        continue;
604                    }
605                    return Ok(data.to_vec());
606                }
607                Some(ChannelMsg::ExtendedData { data, .. }) => {
608                    if data.is_empty() {
609                        continue;
610                    }
611                    let msg = String::from_utf8_lossy(data.as_ref()).trim().to_string();
612                    // GAP-SSH-IO-010: OpenSSH stderr "No such file" → 66, not 74.
613                    let full = format!("SCP stderr: {msg}");
614                    return Err(classify_scp_message(&full));
615                }
616                Some(ChannelMsg::ExitStatus { exit_status }) if exit_status != 0 => {
617                    return Err(SshCliError::ChannelFailed(format!(
618                        "scp encerrou com exit {exit_status}"
619                    )));
620                }
621                Some(ChannelMsg::Close) | None => {
622                    return Err(SshCliError::ChannelFailed(
623                        "SCP channel closed prematurely".to_string(),
624                    ));
625                }
626                _ => continue,
627            }
628        }
629    }
630
631    /// Aguarda ACK de status SCP (`0x00`) ou propaga err `1`/`2`.
632    async fn scp_wait_status<S>(channel: &mut russh::Channel<S>) -> SshCliResult<()>
633    where
634        S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
635    {
636        let data = scp_read_data(channel).await?;
637        interpret_scp_status(&data)
638    }
639
640    /// Reads bytes until a newline (header `C`/`T`) or error status `1`/`2`.
641    async fn scp_read_until_newline<S>(channel: &mut russh::Channel<S>) -> SshCliResult<Vec<u8>>
642    where
643        S: From<(russh::ChannelId, russh::ChannelMsg)> + Send + Sync + 'static,
644    {
645        let mut buf = Vec::new();
646        loop {
647            let chunk = scp_read_data(channel).await?;
648            if buf.is_empty() && matches!(chunk.first().copied(), Some(1 | 2)) {
649                return Ok(chunk);
650            }
651            buf.extend_from_slice(&chunk);
652            if buf.contains(&b'\n') {
653                return Ok(buf);
654            }
655            if buf.len() > 16_384 {
656                return Err(SshCliError::ChannelFailed(
657                    "SCP header excessively long".to_string(),
658                ));
659            }
660        }
661    }
662
663    impl SshClient {
664        /// Connects and authenticates. The full flow (TCP + handshake + auth) honors
665        /// the configuration `timeout_ms`.
666        ///
667        /// # Errors
668        /// - [`SshCliError::InvalidArgument`] if the configuration is invalid.
669        /// - [`SshCliError::SshTimeout`] se exceder o timeout total.
670        /// - [`SshCliError::ConnectionFailed`] em falhas TCP/handshake.
671        /// - [`SshCliError::AuthenticationFailed`] se o servidor rejeitar password/key
672        ///   (tente `--key`, `--password-stdin` ou `--key-passphrase-stdin`).
673        pub async fn connect(cfg: ConnectionConfig) -> SshCliResult<Self> {
674            cfg.validate()?;
675
676            let timeout = Duration::from_millis(cfg.timeout_ms);
677            let host = cfg.host.clone();
678            let port = cfg.port;
679            let username = cfg.username.clone();
680            let secure_password = cfg.password.clone();
681            let key_path = cfg.key_path.clone();
682            let key_passphrase = cfg.key_passphrase.clone();
683            let handler = ClientHandler::new(&cfg);
684
685            let client_config = Arc::new(russh::client::Config {
686                inactivity_timeout: Some(timeout),
687                ..Default::default()
688            });
689
690            tracing::info!(
691                host = %host,
692                port,
693                username = %username,
694                timeout_ms = cfg.timeout_ms,
695                has_key = key_path.is_some(),
696                "iniciando conexão SSH"
697            );
698
699            let connection_result = tokio::time::timeout(timeout, async move {
700                let mut session = russh::client::connect(
701                    client_config,
702                    (host.as_str(), port),
703                    handler,
704                )
705                .await
706                .map_err(|e| SshCliError::ConnectionFailed(format!("TCP/handshake failed: {e}")))?;
707
708                // Preference: private key first; password fallback if both present.
709                let mut autenticado = false;
710
711                if let Some(ref kp) = key_path {
712                    let pass = key_passphrase
713                        .as_ref()
714                        .map(|s| s.expose_secret().to_string());
715                    let key = russh::keys::load_secret_key(kp, pass.as_deref()).map_err(|e| {
716                        SshCliError::SshAuthentication(format!(
717                            "failed to load key {kp}: {e}"
718                        ))
719                    })?;
720                    let hash = session
721                        .best_supported_rsa_hash()
722                        .await
723                        .map_err(|e| {
724                            SshCliError::ConnectionFailed(format!("rsa hash: {e}"))
725                        })?
726                        .flatten();
727                    let auth = session
728                        .authenticate_publickey(
729                            username.clone(),
730                            russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), hash),
731                        )
732                        .await
733                        .map_err(|e| {
734                            SshCliError::ConnectionFailed(format!("publickey auth failed: {e}"))
735                        })?;
736                    autenticado = auth.success();
737                    if !autenticado {
738                        tracing::warn!(host = %host, "key auth rejected; trying password if present");
739                    }
740                }
741
742                if !autenticado && !secure_password.expose_secret().is_empty() {
743                    let auth = session
744                        .authenticate_password(username.clone(), secure_password.expose_secret())
745                        .await
746                        .map_err(|e| {
747                            SshCliError::ConnectionFailed(format!("password auth failed: {e}"))
748                        })?;
749                    autenticado = auth.success();
750                }
751
752                if !autenticado {
753                    tracing::warn!(host = %host, username = %username, "SSH authentication rejected");
754                    return Err(SshCliError::AuthenticationFailed);
755                }
756
757                Ok::<_, SshCliError>(session)
758            })
759            .await;
760
761            let session = match connection_result {
762                Ok(Ok(s)) => s,
763                Ok(Err(err)) => return Err(err),
764                Err(_) => return Err(SshCliError::SshTimeout(cfg.timeout_ms)),
765            };
766
767            tracing::info!("conexão SSH autenticada com sucesso");
768
769            Ok(Self { session, cfg })
770        }
771
772        /// Runs a remote shell command and captures stdout/stderr in parallel.
773        ///
774        /// Trunca cada stream em `max_chars` codepoints UTF-8. Respeita o
775        /// configuration `timeout_ms` for the entire execution.
776        ///
777        /// # Errors
778        /// - [`SshCliError::ChannelFailed`] em falha ao abrir channel ou enviar `exec`.
779        /// - [`SshCliError::SshTimeout`] se exceder o timeout.
780        pub async fn run_command(
781            &mut self,
782            command: &str,
783            max_chars: usize,
784            stdin_data: Option<Vec<u8>>,
785        ) -> SshCliResult<ExecutionOutput> {
786            self.run_command_internal(command, max_chars, true, stdin_data)
787                .await
788        }
789
790        async fn run_command_internal(
791            &mut self,
792            command: &str,
793            max_chars: usize,
794            abort_em_timeout: bool,
795            stdin_data: Option<Vec<u8>>,
796        ) -> SshCliResult<ExecutionOutput> {
797            let inicio = Instant::now();
798            let timeout = Duration::from_millis(self.cfg.timeout_ms);
799
800            let result = tokio::time::timeout(timeout, async {
801                let mut channel = self
802                    .session
803                    .channel_open_session()
804                    .await
805                    .map_err(|e| SshCliError::ChannelFailed(format!("abrir sessão: {e}")))?;
806
807                channel
808                    .exec(true, command)
809                    .await
810                    .map_err(|e| SshCliError::ChannelFailed(format!("exec: {e}")))?;
811
812                // Senha sudo/su no stdin do channel — nunca na cmdline remota (SEC-001).
813                if let Some(bytes) = stdin_data.as_ref() {
814                    channel
815                        .data(&bytes[..])
816                        .await
817                        .map_err(|e| SshCliError::ChannelFailed(format!("stdin channel: {e}")))?;
818                    channel
819                        .eof()
820                        .await
821                        .map_err(|e| SshCliError::ChannelFailed(format!("eof channel: {e}")))?;
822                }
823
824                let mut stdout_bytes: Vec<u8> = Vec::new();
825                let mut stderr_bytes: Vec<u8> = Vec::new();
826                let mut exit_code: Option<i32> = None;
827
828                while let Some(msg) = channel.wait().await {
829                    if process_exec_message(
830                        msg,
831                        &mut stdout_bytes,
832                        &mut stderr_bytes,
833                        &mut exit_code,
834                    ) {
835                        break;
836                    }
837                }
838
839                Ok::<_, SshCliError>((stdout_bytes, stderr_bytes, exit_code))
840            })
841            .await;
842
843            let (stdout_bytes, stderr_bytes, exit_code) = match result {
844                Ok(Ok(t)) => t,
845                Ok(Err(err)) => return Err(err),
846                Err(_) => {
847                    if abort_em_timeout {
848                        if let Some(pattern) = crate::ssh::packing::remote_abort_pattern(command) {
849                            let abort_cmd = crate::ssh::packing::pack_abort_pkill(&pattern);
850                            tracing::warn!(
851                                pattern = %pattern,
852                                "local timeout; attempting best-effort remote abort"
853                            );
854                            let _ = self.try_remote_abort(&abort_cmd).await;
855                        }
856                    }
857                    return Err(SshCliError::SshTimeout(self.cfg.timeout_ms));
858                }
859            };
860
861            let stdout_str = String::from_utf8_lossy(&stdout_bytes).to_string();
862            let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
863
864            let (stdout_truncado, truncated_stdout) = super::truncate_utf8(&stdout_str, max_chars);
865            let (stderr_truncado, truncated_stderr) = super::truncate_utf8(&stderr_str, max_chars);
866
867            let duration_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
868
869            Ok(ExecutionOutput {
870                stdout: stdout_truncado,
871                stderr: stderr_truncado,
872                exit_code,
873                truncated_stdout,
874                truncated_stderr,
875                duration_ms,
876            })
877        }
878
879        /// Upload de file local para remote via SCP (protocolo OpenSSH sink).
880        ///
881        /// One-shot: stream in chunks (without loading the whole file into RAM).
882        ///
883        /// # Errors
884        /// - [`SshCliError::FileNotFound`] if the local file does not exist.
885        /// - [`SshCliError::InvalidArgument`] if the local path is not a regular file.
886        /// - [`SshCliError::ChannelFailed`] em falha ao abrir channel SCP / status remoto.
887        /// - [`SshCliError::SshTimeout`] se exceder o timeout.
888        pub async fn upload(
889            &mut self,
890            local: &std::path::Path,
891            remote: &std::path::Path,
892        ) -> SshCliResult<TransferResult> {
893            use russh::ChannelMsg;
894            use std::io::Read;
895            use std::time::Instant;
896
897            let local_str = local.display().to_string();
898
899            if local.is_dir() {
900                return Err(SshCliError::InvalidArgument(crate::i18n::t(
901                    crate::i18n::Message::ScpUploadFileOnly,
902                )));
903            }
904
905            let metadados = std::fs::metadata(local).map_err(|e| {
906                if e.kind() == std::io::ErrorKind::NotFound {
907                    SshCliError::FileNotFound(local_str.clone())
908                } else {
909                    SshCliError::Io(e)
910                }
911            })?;
912
913            if !metadados.is_file() {
914                return Err(SshCliError::InvalidArgument(crate::i18n::t(
915                    crate::i18n::Message::ScpUploadFileOnly,
916                )));
917            }
918
919            let size = metadados.len();
920            let mode = scp_mode_from_metadata(&metadados);
921            let mtime = metadados.modified().ok().map(system_time_secs).unwrap_or(0);
922            let atime = metadados
923                .accessed()
924                .ok()
925                .map(system_time_secs)
926                .unwrap_or(mtime);
927            let file_name = local.file_name().and_then(|n| n.to_str()).unwrap_or("file");
928
929            let inicio = Instant::now();
930            let timeout = Duration::from_millis(self.cfg.timeout_ms);
931
932            let result =
933                tokio::time::timeout(timeout, async {
934                    if crate::signals::is_cancelled() {
935                        return Err(SshCliError::InvalidArgument(crate::i18n::t(
936                            crate::i18n::Message::OperationCancelled,
937                        )));
938                    }
939
940                    let mut channel =
941                        self.session.channel_open_session().await.map_err(|e| {
942                            SshCliError::ChannelFailed(format!("abrir sessão SCP: {e}"))
943                        })?;
944
945                    let command = remote_scp_command("-t", remote);
946                    channel
947                        .exec(true, command.as_str())
948                        .await
949                        .map_err(|e| SshCliError::ChannelFailed(format!("exec SCP: {e}")))?;
950
951                    // Remote sink sends ACK (0x00) before accepting the header.
952                    scp_wait_status(&mut channel).await?;
953
954                    // Preserve times (line T) antes do header C.
955                    let linha_t = format_scp_t_line(mtime, atime);
956                    channel
957                        .data(linha_t.as_bytes())
958                        .await
959                        .map_err(|e| SshCliError::ChannelFailed(format!("enviar linha T SCP: {e}")))?;
960                    scp_wait_status(&mut channel).await?;
961
962                    let header = format_scp_upload_header_with_mode(mode, size, file_name);
963                    channel
964                        .data(header.as_bytes())
965                        .await
966                        .map_err(|e| SshCliError::ChannelFailed(format!("enviar header SCP: {e}")))?;
967                    scp_wait_status(&mut channel).await?;
968
969                    // SCP-018: stream do disco em chunks (sem fs::read total).
970                    let mut file = std::fs::File::open(local).map_err(SshCliError::Io)?;
971                    let mut buf = vec![0u8; 32_768];
972                    loop {
973                        if crate::signals::is_cancelled() {
974                            return Err(SshCliError::InvalidArgument(crate::i18n::t(
975                                crate::i18n::Message::OperationCancelled,
976                            )));
977                        }
978                        let n = file.read(&mut buf).map_err(SshCliError::Io)?;
979                        if n == 0 {
980                            break;
981                        }
982                        channel.data(&buf[..n]).await.map_err(|e| {
983                            SshCliError::ChannelFailed(format!("enviar bloco SCP: {e}"))
984                        })?;
985                    }
986
987                    // File terminator = byte 0x00 (not empty data).
988                    channel
989                        .data([SCP_OK].as_slice())
990                        .await
991                        .map_err(|e| SshCliError::ChannelFailed(format!("enviar EOF SCP: {e}")))?;
992                    scp_wait_status(&mut channel).await?;
993
994                    let _ = channel.eof().await;
995                    while let Some(msg) = channel.wait().await {
996                        if let ChannelMsg::Close = msg {
997                            break;
998                        }
999                    }
1000
1001                    Ok::<_, SshCliError>(())
1002                })
1003                .await;
1004
1005            result.map_err(|_| SshCliError::SshTimeout(self.cfg.timeout_ms))??;
1006
1007            let duration_ms = u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1008
1009            Ok(TransferResult {
1010                bytes_transferred: size,
1011                duration_ms,
1012            })
1013        }
1014
1015        /// Download de file remote para local via SCP (protocolo OpenSSH source).
1016        ///
1017        /// Writes to `{local}.ssh-cli.partial` and renames atomically (SCP-022).
1018        ///
1019        /// # Errors
1020        /// - [`SshCliError::Io`] if the local file cannot be written.
1021        /// - [`SshCliError::ChannelFailed`] em falha ao abrir channel SCP / status remoto.
1022        /// - [`SshCliError::SshTimeout`] se exceder o timeout.
1023        pub async fn download(
1024            &mut self,
1025            remote: &std::path::Path,
1026            local: &std::path::Path,
1027        ) -> SshCliResult<TransferResult> {
1028            use russh::ChannelMsg;
1029            use std::io::Write;
1030            use std::time::{Duration as StdDuration, Instant, UNIX_EPOCH};
1031
1032            if local.is_dir() {
1033                return Err(SshCliError::InvalidArgument(crate::i18n::t(
1034                    crate::i18n::Message::ScpDownloadLocalNotDirectory,
1035                )));
1036            }
1037
1038            let inicio = Instant::now();
1039            let timeout = Duration::from_millis(self.cfg.timeout_ms);
1040            let partial = partial_download_path(local);
1041
1042            let result = tokio::time::timeout(timeout, async {
1043                if crate::signals::is_cancelled() {
1044                    return Err(SshCliError::InvalidArgument(crate::i18n::t(
1045                        crate::i18n::Message::OperationCancelled,
1046                    )));
1047                }
1048
1049                let mut channel = self
1050                    .session
1051                    .channel_open_session()
1052                    .await
1053                    .map_err(|e| SshCliError::ChannelFailed(format!("abrir sessão SCP: {e}")))?;
1054
1055                let command = remote_scp_command("-f", remote);
1056                channel
1057                    .exec(true, command.as_str())
1058                    .await
1059                    .map_err(|e| SshCliError::ChannelFailed(format!("exec SCP: {e}")))?;
1060
1061                // Remote source only sends the header after the local sink's initial ACK.
1062                channel
1063                    .data([SCP_OK].as_slice())
1064                    .await
1065                    .map_err(|e| SshCliError::ChannelFailed(format!("enviar ack inicial: {e}")))?;
1066
1067                let mut times: Option<(u64, u64)> = None;
1068                let mut header_bytes = scp_read_until_newline(&mut channel).await?;
1069                // Erro remoto: status 1/2 no primeiro byte.
1070                if !header_bytes.is_empty() && matches!(header_bytes[0], 1 | 2) {
1071                    interpret_scp_status(&header_bytes)?;
1072                }
1073                let mut header = String::from_utf8_lossy(&header_bytes).into_owned();
1074                // Linha T opcional (preserve times).
1075                if header.trim_start().starts_with('T') {
1076                    times = Some(parse_scp_t_line(&header)?);
1077                    channel
1078                        .data([SCP_OK].as_slice())
1079                        .await
1080                        .map_err(|e| SshCliError::ChannelFailed(format!("enviar ack T: {e}")))?;
1081                    header_bytes = scp_read_until_newline(&mut channel).await?;
1082                    if !header_bytes.is_empty() && matches!(header_bytes[0], 1 | 2) {
1083                        interpret_scp_status(&header_bytes)?;
1084                    }
1085                    header = String::from_utf8_lossy(&header_bytes).into_owned();
1086                }
1087                let (mode_remoto, size) = parse_scp_header(&header)?;
1088
1089                channel
1090                    .data([SCP_OK].as_slice())
1091                    .await
1092                    .map_err(|e| SshCliError::ChannelFailed(format!("enviar ack header: {e}")))?;
1093
1094                if let Some(parent_dir) = local.parent() {
1095                    if !parent_dir.as_os_str().is_empty() {
1096                        std::fs::create_dir_all(parent_dir)?;
1097                    }
1098                }
1099
1100                // SCP-022: write to partial; rename only on success.
1101                let mut file = std::fs::File::create(&partial).map_err(SshCliError::Io)?;
1102                let mut recebidos: u64 = 0;
1103                let mut pendente: Vec<u8> = Vec::new();
1104
1105                while recebidos < size {
1106                    if crate::signals::is_cancelled() {
1107                        return Err(SshCliError::InvalidArgument(crate::i18n::t(
1108                            crate::i18n::Message::OperationCancelled,
1109                        )));
1110                    }
1111                    if pendente.is_empty() {
1112                        let chunk = scp_read_data(&mut channel).await?;
1113                        pendente.extend_from_slice(&chunk);
1114                    }
1115                    let falta = (size - recebidos) as usize;
1116                    let usar = falta.min(pendente.len());
1117                    file
1118                        .write_all(&pendente[..usar])
1119                        .map_err(SshCliError::Io)?;
1120                    recebidos += usar as u64;
1121                    pendente.drain(..usar);
1122                }
1123
1124                // After payload, source sends final 0x00 (may already be in `pendente`).
1125                if pendente.is_empty() {
1126                    match scp_read_data(&mut channel).await {
1127                        Ok(trail) => pendente.extend_from_slice(&trail),
1128                        Err(_) if recebidos == size => {}
1129                        Err(e) => return Err(e),
1130                    }
1131                }
1132                if pendente.first() == Some(&SCP_OK) {
1133                    pendente.remove(0);
1134                } else if !pendente.is_empty() {
1135                    return Err(SshCliError::ChannelFailed(format!(
1136                        "unexpected SCP terminator after payload (0x{:02x})",
1137                        pendente[0]
1138                    )));
1139                }
1140
1141                file.flush().map_err(SshCliError::Io)?;
1142                let _ = file.sync_data();
1143                drop(file);
1144
1145                channel
1146                    .data([SCP_OK].as_slice())
1147                    .await
1148                    .map_err(|e| SshCliError::ChannelFailed(format!("enviar ack final: {e}")))?;
1149
1150                let _ = channel.eof().await;
1151                while let Some(msg) = channel.wait().await {
1152                    if matches!(msg, ChannelMsg::Close) {
1153                        break;
1154                    }
1155                }
1156
1157                // SCP-022b: apply mode/times on partial BEFORE atomic rename.
1158                // So metadata failure does not leave `local` with partial success content.
1159                apply_local_mode(&partial, mode_remoto)?;
1160                if let Some((mtime, atime)) = times {
1161                    let mtime_st = UNIX_EPOCH + StdDuration::from_secs(mtime);
1162                    let atime_st = UNIX_EPOCH + StdDuration::from_secs(atime);
1163                    let ft = std::fs::FileTimes::new()
1164                        .set_modified(mtime_st)
1165                        .set_accessed(atime_st);
1166                    if let Ok(f) = std::fs::File::options().write(true).open(&partial) {
1167                        let _ = f.set_times(ft);
1168                    }
1169                }
1170
1171                std::fs::rename(&partial, local).map_err(SshCliError::Io)?;
1172                // Atomic write: fsync parent_dir after rename (best-effort).
1173                if let Some(parent_dir) = local.parent() {
1174                    if !parent_dir.as_os_str().is_empty() {
1175                        if let Ok(dir) = std::fs::File::open(parent_dir) {
1176                            let _ = dir.sync_all();
1177                        }
1178                    }
1179                }
1180
1181                Ok::<_, SshCliError>(recebidos)
1182            })
1183            .await;
1184
1185            match result {
1186                Ok(Ok(recebidos)) => {
1187                    let duration_ms =
1188                        u64::try_from(inicio.elapsed().as_millis()).unwrap_or(u64::MAX);
1189                    Ok(TransferResult {
1190                        bytes_transferred: recebidos,
1191                        duration_ms,
1192                    })
1193                }
1194                Ok(Err(e)) => {
1195                    let _ = std::fs::remove_file(&partial);
1196                    // If rename already happened and something failed later (best-effort fsync does not fail),
1197                    // still remove partial; `local` only exists after a successful rename.
1198                    Err(e)
1199                }
1200                Err(_) => {
1201                    let _ = std::fs::remove_file(&partial);
1202                    Err(SshCliError::SshTimeout(self.cfg.timeout_ms))
1203                }
1204            }
1205        }
1206
1207        /// Best-effort remote abort: reconnects with a short timeout and runs pkill.
1208        async fn try_remote_abort(&self, abort_cmd: &str) -> SshCliResult<()> {
1209            // Inline implementation (without calling run_command_internal) avoids
1210            // async recursion detected by the compiler.
1211            let mut cfg_abort = self.cfg.clone();
1212            cfg_abort.timeout_ms = cfg_abort.timeout_ms.clamp(3_000, 10_000);
1213            let outro = match Self::connect(cfg_abort).await {
1214                Ok(c) => c,
1215                Err(e) => {
1216                    tracing::debug!(err = %e, "remote abort could not reconnect");
1217                    return Err(e);
1218                }
1219            };
1220            let timeout = Duration::from_millis(outro.cfg.timeout_ms);
1221            let _ = tokio::time::timeout(timeout, async {
1222                let mut channel = outro
1223                    .session
1224                    .channel_open_session()
1225                    .await
1226                    .map_err(|e| SshCliError::ChannelFailed(format!("abort channel: {e}")))?;
1227                channel
1228                    .exec(true, abort_cmd)
1229                    .await
1230                    .map_err(|e| SshCliError::ChannelFailed(format!("abort exec: {e}")))?;
1231                while let Some(msg) = channel.wait().await {
1232                    if matches!(msg, russh::ChannelMsg::Close) {
1233                        break;
1234                    }
1235                }
1236                Ok::<(), SshCliError>(())
1237            })
1238            .await;
1239            let _ = outro.disconnect().await;
1240            Ok(())
1241        }
1242
1243        /// Cleanly closes the SSH session.
1244        ///
1245        /// # Errors
1246        /// Propaga falha se `disconnect` retornar err do transporte.
1247        pub async fn disconnect(&self) -> SshCliResult<()> {
1248            let result = self
1249                .session
1250                .disconnect(russh::Disconnect::ByApplication, "encerrando", "pt-BR")
1251                .await;
1252            match result {
1253                Ok(()) => {
1254                    tracing::info!("sessão SSH encerrada");
1255                    Ok(())
1256                }
1257                Err(e) => {
1258                    tracing::warn!(err = %e, "failed to close SSH session");
1259                    Err(SshCliError::ConnectionFailed(format!(
1260                        "failed to disconnect: {e}"
1261                    )))
1262                }
1263            }
1264        }
1265
1266        /// Abre channel direct-tcpip para forwarding SSH.
1267        pub async fn open_tunnel_channel(
1268            &self,
1269            remote_host: &str,
1270            remote_port: u16,
1271            endereco_origem: &str,
1272            porta_origem: u16,
1273        ) -> SshCliResult<Box<dyn TunnelChannel>> {
1274            let channel = self
1275                .session
1276                .channel_open_direct_tcpip(
1277                    remote_host.to_string(),
1278                    u32::from(remote_port),
1279                    endereco_origem.to_string(),
1280                    u32::from(porta_origem),
1281                )
1282                .await
1283                .map_err(|e| {
1284                    SshCliError::ChannelFailed(format!(
1285                        "failed to open direct-tcpip channel to {}:{}: {}",
1286                        remote_host, remote_port, e
1287                    ))
1288                })?;
1289
1290            Ok(Box::new(channel.into_stream()))
1291        }
1292    }
1293
1294    #[async_trait]
1295    impl SshClientTrait for SshClient {
1296        async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError> {
1297            Self::connect(cfg).await.map(Box::new)
1298        }
1299
1300        async fn run_command(
1301            &mut self,
1302            cmd: &str,
1303            max_chars: usize,
1304            stdin_data: Option<Vec<u8>>,
1305        ) -> Result<ExecutionOutput, SshCliError> {
1306            Self::run_command(self, cmd, max_chars, stdin_data).await
1307        }
1308
1309        async fn upload(
1310            &mut self,
1311            local: &Path,
1312            remote: &Path,
1313        ) -> Result<TransferResult, SshCliError> {
1314            Self::upload(self, local, remote).await
1315        }
1316
1317        async fn download(
1318            &mut self,
1319            remote: &Path,
1320            local: &Path,
1321        ) -> Result<TransferResult, SshCliError> {
1322            Self::download(self, remote, local).await
1323        }
1324
1325        async fn open_tunnel_channel(
1326            &self,
1327            remote_host: &str,
1328            remote_port: u16,
1329            endereco_origem: &str,
1330            porta_origem: u16,
1331        ) -> Result<Box<dyn TunnelChannel>, SshCliError> {
1332            Self::open_tunnel_channel(
1333                self,
1334                remote_host,
1335                remote_port,
1336                endereco_origem,
1337                porta_origem,
1338            )
1339            .await
1340        }
1341
1342        async fn disconnect(&self) -> Result<(), SshCliError> {
1343            Self::disconnect(self).await
1344        }
1345    }
1346
1347    #[cfg(test)]
1348    mod real_tests {
1349        use super::{
1350            partial_download_path, classify_scp_message, remote_scp_command,
1351            format_scp_upload_header, format_scp_upload_header_with_mode, format_scp_t_line,
1352            interpret_scp_status, map_exit_status, parse_scp_header, parse_scp_t_line,
1353            process_exec_message, SCP_PARTIAL_SUFFIX,
1354        };
1355        use crate::erros::SshCliError;
1356
1357        #[test]
1358        fn map_exit_status_normal() {
1359            assert_eq!(map_exit_status(0), 0);
1360            assert_eq!(map_exit_status(255), 255);
1361        }
1362
1363        #[test]
1364        fn map_exit_status_overflow_returns_minus_one() {
1365            assert_eq!(map_exit_status(u32::MAX), -1);
1366        }
1367
1368        #[test]
1369        fn parse_scp_header_valid_returns_mode_and_size() {
1370            let (mode, size) =
1371                parse_scp_header("C0644 42 arquivo.txt\n").expect("valid header");
1372            assert_eq!(mode, 0o644);
1373            assert_eq!(size, 42);
1374            let (mode2, _) = parse_scp_header("C0600 1 x\n").expect("600");
1375            assert_eq!(mode2, 0o600);
1376        }
1377
1378        #[test]
1379        fn parse_scp_header_invalid_returns_error() {
1380            assert!(parse_scp_header("ERRO").is_err());
1381            assert!(parse_scp_header("C0644 sem_tamanho").is_err());
1382            assert!(parse_scp_header("C0644 abc arquivo").is_err());
1383            assert!(parse_scp_header("Czzzz 1 x\n").is_err());
1384        }
1385
1386        #[test]
1387        fn process_exec_message_handles_stdout_stderr_close() {
1388            let mut stdout = Vec::new();
1389            let mut stderr = Vec::new();
1390            let mut exit_code = None;
1391
1392            let deve_parar = process_exec_message(
1393                russh::ChannelMsg::Data {
1394                    data: b"stdout".to_vec().into(),
1395                },
1396                &mut stdout,
1397                &mut stderr,
1398                &mut exit_code,
1399            );
1400            assert!(!deve_parar);
1401            assert_eq!(stdout, b"stdout");
1402
1403            let deve_parar = process_exec_message(
1404                russh::ChannelMsg::ExtendedData {
1405                    data: b"stderr".to_vec().into(),
1406                    ext: 1,
1407                },
1408                &mut stdout,
1409                &mut stderr,
1410                &mut exit_code,
1411            );
1412            assert!(!deve_parar);
1413            assert_eq!(stderr, b"stderr");
1414
1415            let _ = process_exec_message(
1416                russh::ChannelMsg::ExitStatus { exit_status: 17 },
1417                &mut stdout,
1418                &mut stderr,
1419                &mut exit_code,
1420            );
1421            assert_eq!(exit_code, Some(17));
1422
1423            let deve_parar = process_exec_message(
1424                russh::ChannelMsg::Close,
1425                &mut stdout,
1426                &mut stderr,
1427                &mut exit_code,
1428            );
1429            assert!(deve_parar);
1430        }
1431
1432        #[test]
1433        fn format_scp_upload_header_expected_format() {
1434            let header = format_scp_upload_header(123, "arquivo.txt");
1435            // Wire protocol: real newline (0x0a), NOT the literal '\'+'n' sequence.
1436            assert_eq!(header, "C0644 123 arquivo.txt\n");
1437            assert_eq!(header.as_bytes().last().copied(), Some(b'\n'));
1438            assert!(
1439                !header.as_bytes().windows(2).any(|w| w == *b"\\n"),
1440                "header must not contain literal backslash-n"
1441            );
1442        }
1443
1444        #[test]
1445        fn format_scp_upload_header_uses_basename() {
1446            let header = format_scp_upload_header(1, "/tmp/dir/nome.bin");
1447            assert_eq!(header, "C0644 1 nome.bin\n");
1448        }
1449
1450        #[test]
1451        fn interpret_scp_status_ok_and_error() {
1452            assert!(interpret_scp_status(&[0]).is_ok());
1453            assert!(interpret_scp_status(&[1, b'f', b'a', b'i', b'l']).is_err());
1454            assert!(interpret_scp_status(&[]).is_err());
1455        }
1456
1457        /// GAP-SSH-IO-010: remote missing → FileNotFound (exit 66).
1458        #[test]
1459        fn interpret_scp_status_no_such_file() {
1460            let mut payload = vec![1u8];
1461            payload.extend_from_slice(b"scp: /tmp/missing: No such file or directory\n");
1462            let err = interpret_scp_status(&payload).unwrap_err();
1463            assert!(
1464                matches!(err, SshCliError::FileNotFound(_)),
1465                "esperado FileNotFound, got {err:?}"
1466            );
1467            assert_eq!(err.exit_code(), 66);
1468        }
1469
1470        #[test]
1471        fn classificar_mensagem_scp_protocol_permanece_canal() {
1472            let err = classify_scp_message("SCP: protocol error");
1473            assert!(matches!(err, SshCliError::ChannelFailed(_)));
1474            assert_eq!(err.exit_code(), 74);
1475            let err2 = classify_scp_message("SCP stderr: Permission denied");
1476            assert!(matches!(err2, SshCliError::ChannelFailed(_)));
1477            assert_eq!(err2.exit_code(), 74);
1478        }
1479
1480        #[test]
1481        fn classificar_mensagem_scp_not_found_e_66() {
1482            let err = classify_scp_message("SCP stderr: scp: foo: not found");
1483            assert!(matches!(err, SshCliError::FileNotFound(_)));
1484            assert_eq!(err.exit_code(), 66);
1485        }
1486
1487        #[test]
1488        fn remote_scp_command_escapa_path_e_usa_p() {
1489            let cmd = remote_scp_command("-t", std::path::Path::new("/tmp/a b.txt"));
1490            assert_eq!(cmd, "scp -tp '/tmp/a b.txt'");
1491            let cmd_f = remote_scp_command("-f", std::path::Path::new("/var/log/a.log"));
1492            assert_eq!(cmd_f, "scp -fp '/var/log/a.log'");
1493            // Idempotent if it already contains p.
1494            assert_eq!(
1495                remote_scp_command("-fp", std::path::Path::new("/x")),
1496                "scp -fp '/x'"
1497            );
1498        }
1499
1500        #[test]
1501        fn format_scp_t_line_format() {
1502            let t = format_scp_t_line(1_700_000_000, 1_700_000_001);
1503            assert_eq!(t, "T1700000000 0 1700000001 0\n");
1504            assert_eq!(t.as_bytes().last().copied(), Some(b'\n'));
1505        }
1506
1507        #[test]
1508        fn parse_scp_t_line_ok() {
1509            let (m, a) = parse_scp_t_line("T100 0 200 0\n").expect("T ok");
1510            assert_eq!((m, a), (100, 200));
1511        }
1512
1513        #[test]
1514        fn format_header_with_mode() {
1515            let h = format_scp_upload_header_with_mode(0o755, 10, "x.sh");
1516            assert_eq!(h, "C0755 10 x.sh\n");
1517        }
1518
1519        #[test]
1520        fn partial_download_path_suffix() {
1521            let p = partial_download_path(std::path::Path::new("/tmp/out.bin"));
1522            assert!(p.to_string_lossy().ends_with(SCP_PARTIAL_SUFFIX));
1523            assert!(p.to_string_lossy().contains("out.bin"));
1524        }
1525
1526        #[test]
1527        fn process_exec_message_ignores_extended_non_stderr() {
1528            let mut stdout = Vec::new();
1529            let mut stderr = Vec::new();
1530            let mut exit_code = None;
1531
1532            let deve_parar = process_exec_message(
1533                russh::ChannelMsg::ExtendedData {
1534                    data: b"nao-e-stderr".to_vec().into(),
1535                    ext: 2,
1536                },
1537                &mut stdout,
1538                &mut stderr,
1539                &mut exit_code,
1540            );
1541
1542            assert!(!deve_parar);
1543            assert!(stdout.is_empty());
1544            assert!(stderr.is_empty());
1545            assert!(exit_code.is_none());
1546        }
1547
1548        #[test]
1549        fn process_exec_message_handles_exit_signal_and_eof() {
1550            let mut stdout = Vec::new();
1551            let mut stderr = Vec::new();
1552            let mut exit_code = Some(7);
1553
1554            let deve_parar_signal = process_exec_message(
1555                russh::ChannelMsg::ExitSignal {
1556                    signal_name: russh::Sig::TERM,
1557                    core_dumped: false,
1558                    error_message: "encerrado".to_string(),
1559                    lang_tag: "pt-BR".to_string(),
1560                },
1561                &mut stdout,
1562                &mut stderr,
1563                &mut exit_code,
1564            );
1565
1566            let deve_parar_eof = process_exec_message(
1567                russh::ChannelMsg::Eof,
1568                &mut stdout,
1569                &mut stderr,
1570                &mut exit_code,
1571            );
1572
1573            assert!(!deve_parar_signal);
1574            assert!(!deve_parar_eof);
1575            assert_eq!(exit_code, Some(7));
1576        }
1577
1578        #[test]
1579        fn process_exec_message_ignores_unhandled_variants() {
1580            let mut stdout = Vec::new();
1581            let mut stderr = Vec::new();
1582            let mut exit_code = None;
1583
1584            let deve_parar = process_exec_message(
1585                russh::ChannelMsg::WindowAdjusted { new_size: 2048 },
1586                &mut stdout,
1587                &mut stderr,
1588                &mut exit_code,
1589            );
1590
1591            assert!(!deve_parar);
1592            assert!(stdout.is_empty());
1593            assert!(stderr.is_empty());
1594            assert!(exit_code.is_none());
1595        }
1596    }
1597}
1598
1599#[cfg(feature = "ssh-real")]
1600pub use real::{SshClient, ClientHandler};
1601
1602// =========================================================================
1603// Stub used when the `ssh-real` feature is DISABLED.
1604// =========================================================================
1605
1606#[cfg(not(feature = "ssh-real"))]
1607mod stub {
1608    use super::{ConnectionConfig, ExecutionOutput, TransferResult};
1609    use crate::erros::SshCliError;
1610    use crate::ssh::client::SshClientTrait;
1611    use async_trait::async_trait;
1612    use std::path::Path;
1613
1614    /// Stub when `ssh-real` is disabled: always returns
1615    /// [`SshCliError::ConnectionFailed`].
1616    #[derive(Debug)]
1617    pub struct SshClient;
1618
1619    #[async_trait]
1620    impl SshClientTrait for SshClient {
1621        async fn connect(_cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError> {
1622            Err(SshCliError::ConnectionFailed(
1623                "feature `ssh-real` está desabilitada; recompile com --features ssh-real".into(),
1624            ))
1625        }
1626
1627        async fn run_command(
1628            &mut self,
1629            _cmd: &str,
1630            _max_chars: usize,
1631            _stdin_data: Option<Vec<u8>>,
1632        ) -> Result<ExecutionOutput, SshCliError> {
1633            Err(SshCliError::ChannelFailed(
1634                "stub sem russh: feature `ssh-real` desabilitada".into(),
1635            ))
1636        }
1637
1638        async fn upload(
1639            &mut self,
1640            _local: &Path,
1641            _remote: &Path,
1642        ) -> Result<TransferResult, SshCliError> {
1643            Err(SshCliError::ChannelFailed(
1644                "stub sem russh: feature `ssh-real` desabilitada".into(),
1645            ))
1646        }
1647
1648        async fn download(
1649            &mut self,
1650            _remote: &Path,
1651            _local: &Path,
1652        ) -> Result<TransferResult, SshCliError> {
1653            Err(SshCliError::ChannelFailed(
1654                "stub sem russh: feature `ssh-real` desabilitada".into(),
1655            ))
1656        }
1657
1658        async fn open_tunnel_channel(
1659            &self,
1660            _host_remoto: &str,
1661            _porta_remota: u16,
1662            _endereco_origem: &str,
1663            _porta_origem: u16,
1664        ) -> Result<Box<dyn super::TunnelChannel>, SshCliError> {
1665            Err(SshCliError::ChannelFailed(
1666                "stub sem russh: feature `ssh-real` desabilitada".into(),
1667            ))
1668        }
1669
1670        async fn disconnect(&self) -> Result<(), SshCliError> {
1671            Ok(())
1672        }
1673    }
1674}
1675
1676#[cfg(not(feature = "ssh-real"))]
1677pub use stub::SshClient;
1678
1679// =========================================================================
1680// Unit tests (no network, no feature gate).
1681// =========================================================================
1682
1683#[cfg(test)]
1684mod tests {
1685    use super::*;
1686    use secrecy::SecretString;
1687
1688    fn valid_cfg() -> ConnectionConfig {
1689        ConnectionConfig {
1690            host: "127.0.0.1".to_string(),
1691            port: 22,
1692            username: "root".to_string(),
1693            password: SecretString::from("senha-exemplo".to_string()),
1694            key_path: None,
1695            key_passphrase: None,
1696            timeout_ms: 5000,
1697            known_hosts_path: None,
1698            replace_host_key: false,
1699        }
1700    }
1701
1702    #[test]
1703    fn validate_empty_host_returns_error() {
1704        let mut c = valid_cfg();
1705        c.host = String::new();
1706        let r = c.validate();
1707        assert!(r.is_err());
1708        let msg = r.unwrap_err().to_string();
1709        assert!(msg.contains("host"));
1710    }
1711
1712    #[test]
1713    fn validate_whitespace_host_returns_error() {
1714        let mut c = valid_cfg();
1715        c.host = "   ".to_string();
1716        assert!(c.validate().is_err());
1717    }
1718
1719    #[test]
1720    fn validate_port_zero_returns_error() {
1721        let mut c = valid_cfg();
1722        c.port = 0;
1723        let r = c.validate();
1724        assert!(r.is_err());
1725        let msg = r.unwrap_err().to_string();
1726        assert!(msg.contains("port"));
1727    }
1728
1729    #[test]
1730    fn validate_empty_user_returns_error() {
1731        let mut c = valid_cfg();
1732        c.username = String::new();
1733        assert!(c.validate().is_err());
1734    }
1735
1736    #[test]
1737    fn validate_correct_config_returns_ok() {
1738        assert!(valid_cfg().validate().is_ok());
1739    }
1740
1741    #[test]
1742    fn debug_does_not_expose_password() {
1743        let c = valid_cfg();
1744        let dbg = format!("{c:?}");
1745        assert!(!dbg.contains("senha-exemplo"));
1746        assert!(dbg.contains("redacted"));
1747    }
1748
1749    #[test]
1750    fn truncate_utf8_no_truncate_if_fits() {
1751        let (s, t) = truncate_utf8("ola mundo", 100);
1752        assert_eq!(s, "ola mundo");
1753        assert!(!t);
1754    }
1755
1756    #[test]
1757    fn truncate_utf8_truncates_large_ascii() {
1758        let entrada: String = "a".repeat(200);
1759        let (s, t) = truncate_utf8(&entrada, 50);
1760        assert_eq!(s.chars().count(), 50);
1761        assert!(t);
1762    }
1763
1764    #[test]
1765    fn truncate_utf8_preserves_accented_graphemes() {
1766        // 10 codepoints: "á" (1 char) * 10
1767        let entrada: String = "á".repeat(30);
1768        let (s, t) = truncate_utf8(&entrada, 10);
1769        assert_eq!(s.chars().count(), 10);
1770        // Each 'á' is 2 UTF-8 bytes → 10 chars = 20 bytes
1771        assert_eq!(s.len(), 20);
1772        assert!(t);
1773        // Does not split mid-byte
1774        assert!(s.chars().all(|c| c == 'á'));
1775    }
1776
1777    #[test]
1778    fn truncate_utf8_emojis_does_not_break() {
1779        let entrada = "🚀🔒🛡🔑✨🎉💎⚡🌟🔥🎨";
1780        let (s, t) = truncate_utf8(entrada, 5);
1781        assert_eq!(s.chars().count(), 5);
1782        assert!(t);
1783    }
1784
1785    #[test]
1786    fn truncate_utf8_zero_returns_empty() {
1787        let (s, t) = truncate_utf8("abc", 0);
1788        assert_eq!(s, "");
1789        assert!(t);
1790    }
1791
1792    #[test]
1793    fn execution_output_debug_does_not_crash() {
1794        let s = ExecutionOutput {
1795            stdout: "ok".into(),
1796            stderr: String::new(),
1797            exit_code: Some(0),
1798            truncated_stdout: false,
1799            truncated_stderr: false,
1800            duration_ms: 42,
1801        };
1802        let _ = format!("{s:?}");
1803    }
1804
1805    #[test]
1806    fn duration_ms_type_compatible() {
1807        // Static guarantee that Instant::elapsed fits in u64.
1808        let fake: u64 = 1234;
1809        assert_eq!(fake, 1234_u64);
1810    }
1811}