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//! Real SSH client over `russh` 0.62.5.
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 in the XDG `known_hosts` (see [`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    /// Whether the remote modification time was applied to the local file.
55    ///
56    /// G-SCP-R01: the product documented mtime preservation as a *guarantee* while the
57    /// implementation discarded the failure at two nesting levels. Destination
58    /// filesystems that cannot represent the operation — FAT32, exFAT, some bind
59    /// mounts, WSL interop paths — silently produced a file with the wrong timestamp,
60    /// and a build pipeline deciding whether to recompile by mtime comparison took the
61    /// wrong branch with the symptom appearing far from the cause. Reporting the
62    /// outcome resolves the contradiction without failing transfers that are otherwise
63    /// complete and correct.
64    ///
65    /// Always `true` for uploads and whenever the remote sent no timestamps: there was
66    /// nothing to preserve, so nothing was lost.
67    pub mtime_preserved: bool,
68    /// Whether the parent directory was successfully fsynced after the atomic rename.
69    ///
70    /// G-SCP-R02: the rename is atomic, but until the *directory* entry is flushed a
71    /// crash can leave the file missing even though the CLI already reported exit 0.
72    /// The fsync was best-effort by design and invisible by accident; an agent had no
73    /// way to know whether the success it received was durable.
74    pub durable: bool,
75}
76
77impl Default for TransferResult {
78    /// Defaults describe "nothing was attempted, so nothing failed".
79    ///
80    /// The booleans default to `true` because every non-SCP-download path either has
81    /// no local file to stamp or no rename to flush. Defaulting them to `false` would
82    /// have reported spurious durability loss on uploads.
83    fn default() -> Self {
84        Self {
85            bytes_transferred: 0,
86            duration_ms: 0,
87            mtime_preserved: true,
88            durable: true,
89        }
90    }
91}
92
93/// Hard upper bound (bytes) retained **per stream** while capturing remote exec output.
94///
95/// Resource rule: even with `max_chars` unlimited (`usize::MAX`), never grow
96/// unbounded from remote flood. Post-decode codepoint truncation still applies.
97pub(crate) const EXEC_CAPTURE_HARD_MAX_BYTES: usize = 16 * 1024 * 1024;
98
99/// Bytes retained per stream while capturing remote output.
100///
101/// Uses UTF-8 worst-case 4 bytes/codepoint (+4 slack for a trailing incomplete
102/// sequence), then clamps to `EXEC_CAPTURE_HARD_MAX_BYTES`.
103#[must_use]
104pub(crate) fn exec_capture_byte_cap(max_chars: usize) -> usize {
105    if max_chars == 0 {
106        return 0;
107    }
108    max_chars
109        .saturating_mul(4)
110        .saturating_add(4)
111        .min(EXEC_CAPTURE_HARD_MAX_BYTES)
112}
113
114/// Appends `data` into `buf` without exceeding `cap`; sets `truncated` if any byte is dropped.
115pub(crate) fn append_capped(buf: &mut Vec<u8>, data: &[u8], cap: usize, truncated: &mut bool) {
116    if data.is_empty() {
117        return;
118    }
119    if cap == 0 || buf.len() >= cap {
120        *truncated = true;
121        return;
122    }
123    let room = cap - buf.len();
124    if data.len() <= room {
125        buf.extend_from_slice(data);
126    } else {
127        buf.extend_from_slice(&data[..room]);
128        *truncated = true;
129    }
130}
131
132// G-TYPE-14: UTF-8 truncation lives in `session_io` (re-exported for callers).
133pub(crate) use super::session_io::take_utf8_capped;
134pub use super::session_io::truncate_utf8;
135
136// =========================================================================
137// SshClientTrait enables real or mock SSH clients in tests.
138// =========================================================================
139
140use async_trait::async_trait;
141use std::path::Path;
142
143/// Bidirectional stream used for SSH tunnel (direct-tcpip).
144pub trait TunnelChannel: AsyncRead + AsyncWrite + Unpin + Send {}
145
146impl<T> TunnelChannel for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
147
148/// SSH client trait allowing a real (russh) or mock implementation for tests.
149///
150/// Abstracts SSH connection operations so unit tests can run without a real network.
151#[async_trait]
152pub trait SshClientTrait: Send + Sync + 'static {
153    /// Connects to an SSH server and authenticates with the provided credentials.
154    async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError>
155    where
156        Self: Sized;
157
158    /// Runs a remote shell command and returns the captured output.
159    ///
160    /// `stdin_data`, if present, is written to the channel after `exec` and before the loop
161    /// read loop (GAP-SSH-SEC-001: sudo/su password stays off the remote argv).
162    async fn run_command(
163        &mut self,
164        cmd: &str,
165        max_chars: usize,
166        stdin_data: Option<Vec<u8>>,
167    ) -> Result<ExecutionOutput, SshCliError>;
168
169    /// Uploads a local file to the remote server via SCP.
170    async fn upload(&self, local: &Path, remote: &Path) -> Result<TransferResult, SshCliError>;
171
172    /// Downloads a remote file to the local filesystem via SCP.
173    async fn download(&self, remote: &Path, local: &Path) -> Result<TransferResult, SshCliError>;
174
175    /// Opens a `direct-tcpip` channel for tunnel forwarding.
176    async fn open_tunnel_channel(
177        &self,
178        remote_host: &str,
179        remote_port: u16,
180        origin_addr: &str,
181        origin_port: u16,
182    ) -> Result<Box<dyn TunnelChannel>, SshCliError>;
183
184    /// Opens a `direct-streamlocal@openssh.com` channel to a remote Unix socket.
185    ///
186    /// Defaulted to "unsupported" rather than made mandatory: mock and stub clients
187    /// genuinely cannot forward a Unix socket, and forcing every one of them to
188    /// hand-write the same refusal only adds places for the refusal to drift.
189    ///
190    /// # Errors
191    /// [`SshCliError::ChannelFailed`] — always, for clients without streamlocal.
192    async fn open_streamlocal_channel(
193        &self,
194        _socket_path: &str,
195    ) -> Result<Box<dyn TunnelChannel>, SshCliError> {
196        Err(SshCliError::channel_msg(
197            "streamlocal forwarding is not available on this client",
198        ))
199    }
200
201    /// Requests a server-side listener, returning the port the server bound.
202    ///
203    /// # Errors
204    /// [`SshCliError::ChannelFailed`] — always, for clients without reverse forwarding.
205    async fn request_remote_forward(&self, _address: &str, _port: u16) -> Result<u16, SshCliError> {
206        Err(SshCliError::channel_msg(
207            "reverse forwarding is not available on this client",
208        ))
209    }
210
211    /// Cancels a server-side listener previously requested.
212    ///
213    /// # Errors
214    /// [`SshCliError::ChannelFailed`] — always, for clients without reverse forwarding.
215    async fn cancel_remote_forward(&self, _address: &str, _port: u16) -> Result<(), SshCliError> {
216        Err(SshCliError::channel_msg(
217            "reverse forwarding is not available on this client",
218        ))
219    }
220
221    /// Waits for the next channel opened by the server on an active reverse forward.
222    ///
223    /// `None` means no further channels can arrive, which ends the accept loop.
224    async fn accept_forwarded_channel(&self) -> Option<Box<dyn TunnelChannel>> {
225        None
226    }
227
228    /// Cleanly closes the SSH connection.
229    async fn disconnect(&self) -> Result<(), SshCliError>;
230}
231
232#[cfg(test)]
233/// SSH client mocks used in unit tests.
234pub mod mocks {
235    use super::*;
236    use mockall::mock;
237
238    mock! {
239        pub SshClient {}
240
241    #[async_trait]
242    impl crate::ssh::client::SshClientTrait for SshClient {
243            async fn connect(cfg: ConnectionConfig) -> Result<Box<Self>, SshCliError>;
244            async fn run_command(&mut self, cmd: &str, max_chars: usize, stdin_data: Option<Vec<u8>>) -> Result<ExecutionOutput, SshCliError>;
245            async fn upload(&self, local: &Path, remote: &Path) -> Result<TransferResult, SshCliError>;
246            async fn download(&self, remote: &Path, local: &Path) -> Result<TransferResult, SshCliError>;
247            async fn open_tunnel_channel(
248                &self,
249                remote_host: &str,
250                remote_port: u16,
251                origin_addr: &str,
252                origin_port: u16,
253            ) -> Result<Box<dyn TunnelChannel>, SshCliError>;
254            async fn disconnect(&self) -> Result<(), SshCliError>;
255        }
256    }
257}
258
259// =========================================================================
260// REAL SSH implementation (`ssh-real` feature).
261// =========================================================================
262
263#[cfg(feature = "ssh-real")]
264#[path = "client_real.rs"]
265mod real;
266
267/// Real SSH client backed by `russh` (default feature `ssh-real`).
268#[cfg(feature = "ssh-real")]
269#[cfg_attr(docsrs, doc(cfg(feature = "ssh-real")))]
270pub use real::{ClientHandler, SshClient};
271
272#[cfg(not(feature = "ssh-real"))]
273#[path = "client_stub.rs"]
274mod stub;
275
276/// Stub client when `ssh-real` is disabled.
277#[cfg(not(feature = "ssh-real"))]
278#[cfg_attr(docsrs, doc(cfg(not(feature = "ssh-real"))))]
279pub use stub::SshClient;
280
281// =========================================================================
282// Unit tests (no network, no feature gate).
283// =========================================================================
284
285#[cfg(test)]
286#[path = "client_tests.rs"]
287mod tests;