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;