Skip to main content

ssh_cli/ssh/
client.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Cliente SSH real via `russh` 0.62.2.
5//!
6//! One-shot connection: TCP + handshake + auth (password and/or key) + exec with
7//! timeout, output truncation, and best-effort remote abort.
8//! Host keys: TOFU em `known_hosts` XDG (ver [`super::known_hosts`]).
9//!
10//! # Workload classification (resource economy)
11//!
12//! - **Class:** I/O-bound (SSH/TCP + disk SCP). Not CPU-bound.
13//! - **Runtime:** Tokio multi-thread (see `main.rs`) for russh crypto/IO + tunnel
14//!   accept fan-out — not a substitute for CPU parallelism.
15//! - **No Rayon / no process pool:** one-shot single session; coordination cost
16//!   exceeds any local CPU fan-out on the agent path.
17//! - **Capture RAM:** stdout/stderr bounded by `max_chars×4` bytes (UTF-8 worst
18//!   case) and hard-capped at [`EXEC_CAPTURE_HARD_MAX_BYTES`] per stream.
19//! - **SCP:** stream in 32 KiB chunks to/from disk (no full-file heap load);
20//!   disk I/O uses `tokio::fs` so the async worker is not blocked on syscalls.
21//! - **Latency:** RTT-bound; decode path reuses the capture `Vec` via
22//!   [`take_utf8_capped`] when remote bytes are valid UTF-8.
23
24use crate::errors::SshCliError;
25use tokio::io::{AsyncRead, AsyncWrite};
26
27// G-COMP-R: ConnectionConfig lives in `connection` (SRP).
28pub use super::connection::ConnectionConfig;
29
30/// Output of a remote SSH command execution.
31#[derive(Debug, Clone)]
32pub struct ExecutionOutput {
33    /// Stdout capturado (possivelmente truncated a `max_chars` codepoints).
34    pub stdout: String,
35    /// Stderr capturado (possivelmente truncated a `max_chars` codepoints).
36    pub stderr: String,
37    /// Exit code. `None` when the command was terminated by signal or timeout.
38    pub exit_code: Option<i32>,
39    /// `true` se `stdout` foi truncated em `max_chars`.
40    pub truncated_stdout: bool,
41    /// `true` se `stderr` foi truncated em `max_chars`.
42    pub truncated_stderr: bool,
43    /// Total execution duration in milliseconds.
44    pub duration_ms: u64,
45}
46
47/// Result of an SCP file transfer operation.
48#[derive(Debug, Clone)]
49pub struct TransferResult {
50    /// Number of bytes transferred.
51    pub bytes_transferred: u64,
52    /// Total duration in milliseconds.
53    pub duration_ms: u64,
54}
55
56/// Hard upper bound (bytes) retained **per stream** while capturing remote exec output.
57///
58/// Resource rule: even with `max_chars` unlimited (`usize::MAX`), never grow
59/// unbounded from remote flood. Post-decode codepoint truncation still applies.
60pub(crate) const EXEC_CAPTURE_HARD_MAX_BYTES: usize = 16 * 1024 * 1024;
61
62/// Bytes retained per stream while capturing remote output.
63///
64/// Uses UTF-8 worst-case 4 bytes/codepoint (+4 slack for a trailing incomplete
65/// sequence), then clamps to [`EXEC_CAPTURE_HARD_MAX_BYTES`].
66#[must_use]
67pub(crate) fn exec_capture_byte_cap(max_chars: usize) -> usize {
68    if max_chars == 0 {
69        return 0;
70    }
71    max_chars
72        .saturating_mul(4)
73        .saturating_add(4)
74        .min(EXEC_CAPTURE_HARD_MAX_BYTES)
75}
76
77/// Appends `data` into `buf` without exceeding `cap`; sets `truncated` if any byte is dropped.
78pub(crate) fn append_capped(buf: &mut Vec<u8>, data: &[u8], cap: usize, truncated: &mut bool) {
79    if data.is_empty() {
80        return;
81    }
82    if cap == 0 || buf.len() >= cap {
83        *truncated = true;
84        return;
85    }
86    let room = cap - buf.len();
87    if data.len() <= room {
88        buf.extend_from_slice(data);
89    } else {
90        buf.extend_from_slice(&data[..room]);
91        *truncated = true;
92    }
93}
94
95// G-TYPE-14: UTF-8 truncation lives in `session_io` (re-exported for callers).
96pub(crate) use super::session_io::take_utf8_capped;
97pub use super::session_io::truncate_utf8;
98
99// =========================================================================
100// SshClientTrait enables real or mock SSH clients in tests.
101// =========================================================================
102
103use async_trait::async_trait;
104use std::path::Path;
105
106/// Bidirectional stream used for SSH tunnel (direct-tcpip).
107pub trait TunnelChannel: AsyncRead + AsyncWrite + Unpin + Send {}
108
109impl<T> TunnelChannel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
110
111/// SSH client trait allowing a real (russh) or mock implementation for tests.
112///
113/// Abstracts SSH connection operations so unit tests can run without a real network.
114#[async_trait]
115pub trait SshClientTrait: Send + Sync + 'static {
116    /// Connects to an SSH server and authenticates with the provided credentials.
117    async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError>
118    where
119        Self: Sized;
120
121    /// Runs a remote shell command and returns the captured output.
122    ///
123    /// `stdin_data`, if present, is written to the channel after `exec` and before the loop
124    /// read loop (GAP-SSH-SEC-001: sudo/su password stays off the remote argv).
125    async fn run_command(
126        &mut self,
127        cmd: &str,
128        max_chars: usize,
129        stdin_data: Option<Vec<u8>>,
130    ) -> Result<ExecutionOutput, SshCliError>;
131
132    /// Uploads a local file to the remote server via SCP.
133    async fn upload(&self, local: &Path, remote: &Path) -> Result<TransferResult, SshCliError>;
134
135    /// Downloads a remote file to the local filesystem via SCP.
136    async fn download(&self, remote: &Path, local: &Path) -> Result<TransferResult, SshCliError>;
137
138    /// Opens a `direct-tcpip` channel for tunnel forwarding.
139    async fn open_tunnel_channel(
140        &self,
141        remote_host: &str,
142        remote_port: u16,
143        origin_addr: &str,
144        origin_port: u16,
145    ) -> Result<Box<dyn TunnelChannel>, SshCliError>;
146
147    /// Cleanly closes the SSH connection.
148    async fn disconnect(&self) -> Result<(), SshCliError>;
149}
150
151#[cfg(test)]
152/// SSH client mocks used in unit tests.
153pub mod mocks {
154    use super::*;
155    use mockall::mock;
156
157    mock! {
158        pub SshClient {}
159
160    #[async_trait]
161    impl crate::ssh::client::SshClientTrait for SshClient {
162            async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError>;
163            async fn run_command(&mut self, cmd: &str, max_chars: usize, stdin_data: Option<Vec<u8>>) -> Result<ExecutionOutput, SshCliError>;
164            async fn upload(&self, local: &Path, remote: &Path) -> Result<TransferResult, SshCliError>;
165            async fn download(&self, remote: &Path, local: &Path) -> Result<TransferResult, SshCliError>;
166            async fn open_tunnel_channel(
167                &self,
168                remote_host: &str,
169                remote_port: u16,
170                origin_addr: &str,
171                origin_port: u16,
172            ) -> Result<Box<dyn TunnelChannel>, SshCliError>;
173            async fn disconnect(&self) -> Result<(), SshCliError>;
174        }
175    }
176}
177
178// =========================================================================
179// REAL SSH implementation (`ssh-real` feature).
180// =========================================================================
181
182#[cfg(feature = "ssh-real")]
183#[path = "client_real.rs"]
184mod real;
185
186/// Real SSH client backed by `russh` (default feature `ssh-real`).
187#[cfg(feature = "ssh-real")]
188#[cfg_attr(docsrs, doc(cfg(feature = "ssh-real")))]
189pub use real::{ClientHandler, SshClient};
190
191#[cfg(not(feature = "ssh-real"))]
192#[path = "client_stub.rs"]
193mod stub;
194
195/// Stub client when `ssh-real` is disabled.
196#[cfg(not(feature = "ssh-real"))]
197#[cfg_attr(docsrs, doc(cfg(not(feature = "ssh-real"))))]
198pub use stub::SshClient;
199
200// =========================================================================
201// Unit tests (no network, no feature gate).
202// =========================================================================
203
204#[cfg(test)]
205#[path = "client_tests.rs"]
206mod tests;