1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
// Implementation body for `client_real` (included).
// SPDX-License-Identifier: MIT OR Apache-2.0
// G-ERR-12: real russh client implementation (split from monólito client.rs).
use super::{
take_utf8_capped, TunnelChannel, SshClientTrait, ConnectionConfig, ExecutionOutput,
TransferResult,
};
use crate::errors::{SshCliError, SshCliResult};
use async_trait::async_trait;
use std::path::Path;
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
// Handler lives in `client_handler` (G-SSH-01/06/09/14).
pub use crate::ssh::client_handler::ClientHandler;
/// Active SSH client with an authenticated session.
pub struct SshClient {
/// Authenticated SSH session for low-level operations.
pub session: russh::client::Handle<ClientHandler>,
cfg: ConnectionConfig,
}
impl std::fmt::Debug for SshClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshClient")
.field("host", &self.cfg.host)
.field("port", &self.cfg.port)
.field("user", &self.cfg.username)
.field("timeout_ms", &self.cfg.timeout_ms)
.finish()
}
}
impl SshClient {
/// Wall-clock timeout (ms) from connection config (CLI/XDG) — G-SFTP-R05.
#[must_use]
pub fn timeout_ms(&self) -> u64 {
self.cfg.timeout_ms.get()
}
}
fn map_exit_status(exit_status: u32) -> i32 {
i32::try_from(exit_status).unwrap_or(-1)
}
fn process_exec_message(
msg: russh::ChannelMsg,
stdout_bytes: &mut Vec<u8>,
stderr_bytes: &mut Vec<u8>,
exit_code: &mut Option<i32>,
byte_cap: usize,
truncated_stdout: &mut bool,
truncated_stderr: &mut bool,
) -> bool {
use russh::ChannelMsg;
match msg {
ChannelMsg::Data { data } => {
// Resource: bound RAM to byte_cap (max_chars×4, hard 16 MiB) before UTF-8 truncate.
super::append_capped(stdout_bytes, data.as_ref(), byte_cap, truncated_stdout);
}
ChannelMsg::ExtendedData { data, ext } => {
// ext == 1 → SSH_EXTENDED_DATA_STDERR (RFC 4254 §5.2).
if ext == 1 {
super::append_capped(stderr_bytes, data.as_ref(), byte_cap, truncated_stderr);
} else {
tracing::debug!(ext, "extended data ignored");
}
}
ChannelMsg::ExitStatus { exit_status } => {
// russh delivers exit status as u32; keep i32 for negative conventions
// Unix conventions (shells may emit codes as u8 in
// wait-status; here it is already the application exit code, 0..=255).
*exit_code = Some(map_exit_status(exit_status));
// Do NOT return true: wait for Eof/Close after ExitStatus.
}
ChannelMsg::ExitSignal {
signal_name,
core_dumped,
error_message,
..
} => {
tracing::warn!(
?signal_name,
core_dumped,
%error_message,
"remote process terminated by signal"
);
// Sem exit_status → mantemos None.
}
ChannelMsg::Eof => {
tracing::debug!("EOF on SSH channel");
}
ChannelMsg::Close => {
tracing::debug!("SSH channel closed by server");
return true;
}
_ => {}
}
false
}
// SCP wire helpers: see `crate::ssh::scp_wire` (G-COMP-06a).
// Re-export wire helpers into this module so `real_tests` can `use super::…`.
// SCP wire helpers: see `crate::ssh::scp_wire` (G-COMP-06a).
// Re-export wire helpers into this module so `real_tests` can `use super::…`.
use crate::ssh::scp_wire::{
apply_local_mode, format_scp_t_line, format_scp_upload_header_with_mode,
interpret_scp_status, parse_scp_header, parse_scp_t_line, partial_download_path,
remote_scp_command, scp_mode_from_metadata, scp_read_data, scp_read_until_newline,
scp_wait_status, system_time_secs, SCP_OK,
};
impl SshClient {
/// Connects and authenticates. The full flow (TCP + handshake + auth) honors
/// the configuration `timeout_ms`.
///
/// # Errors
/// - [`SshCliError::InvalidArgument`] if the configuration is invalid.
/// - [`SshCliError::SshTimeout`] if the total timeout is exceeded.
/// - [`SshCliError::ConnectionFailed`] on TCP/handshake failures.
/// - [`SshCliError::HostKeyChanged`] when TOFU rejects a divergent host key.
/// - [`SshCliError::AuthenticationFailed`] if the server rejects password/key/agent
/// (try `--key`, `--use-agent`, `--password-stdin`, or `--key-passphrase-stdin`).
pub async fn connect(cfg: ConnectionConfig) -> SshCliResult<Self> {
let auth = crate::ssh::client_connect::connect_authenticated(cfg).await?;
Ok(Self {
session: auth.session,
cfg: auth.cfg,
})
}
/// Runs a remote shell command and captures stdout/stderr in parallel.
pub async fn run_command(
&mut self,
command: &str,
max_chars: usize,
stdin_data: Option<Vec<u8>>,
) -> SshCliResult<ExecutionOutput> {
self.run_command_internal(command, max_chars, true, stdin_data)
.await
}
async fn run_command_internal(
&mut self,
command: &str,
max_chars: usize,
abort_on_timeout: bool,
stdin_data: Option<Vec<u8>>,
) -> SshCliResult<ExecutionOutput> {
let start = Instant::now();
let timeout = Duration::from_millis(self.cfg.timeout_ms.get());
// Zeroizing: scrub password bytes on drop even if timeout cancels the future.
// Zeroizing: scrub password bytes on drop even if timeout cancels the future.
// Zeroizing: scrub password bytes on drop even if timeout cancels the future.
let stdin_data: Option<Zeroizing<Vec<u8>>> = stdin_data.map(Zeroizing::new);
let result = tokio::time::timeout(timeout, async {
let mut channel = self
.session
.channel_open_session()
.await
.map_err(|e| SshCliError::channel_msg(format!("open session: {e}")))?;
channel
.exec(true, command)
.await
.map_err(|e| SshCliError::channel_msg(format!("exec: {e}")))?;
// Senha sudo/su no stdin do channel — nunca na cmdline remota (SEC-001).
if let Some(ref bytes) = stdin_data {
channel
.data(bytes.as_slice())
.await
.map_err(|e| SshCliError::channel_msg(format!("stdin channel: {e}")))?;
channel
.eof()
.await
.map_err(|e| SshCliError::channel_msg(format!("eof channel: {e}")))?;
}
// Drop Zeroizing early so secrets do not sit through the capture loop.
drop(stdin_data);
// Resource: pre-size for typical capture; hard-capped by max_chars×4 / 16 MiB.
let byte_cap = super::exec_capture_byte_cap(max_chars);
let initial = byte_cap.min(8 * 1024);
let mut stdout_bytes: Vec<u8> = Vec::with_capacity(initial);
let mut stderr_bytes: Vec<u8> = Vec::with_capacity(initial);
let mut exit_code: Option<i32> = None;
let mut byte_trunc_stdout = false;
let mut byte_trunc_stderr = false;
while let Some(msg) = channel.wait().await {
// G-OS-03 / G-SHUT: cooperative cancel on SIGINT/SIGTERM mid-exec.
if crate::signals::should_stop() {
return Err(SshCliError::Config(
"operation cancelled by signal".to_string(),
));
}
if process_exec_message(
msg,
&mut stdout_bytes,
&mut stderr_bytes,
&mut exit_code,
byte_cap,
&mut byte_trunc_stdout,
&mut byte_trunc_stderr,
) {
break;
}
}
Ok::<_, SshCliError>((
stdout_bytes,
stderr_bytes,
exit_code,
byte_trunc_stdout,
byte_trunc_stderr,
))
})
.await;
let (stdout_bytes, stderr_bytes, exit_code, byte_trunc_stdout, byte_trunc_stderr) =
match result {
Ok(Ok(t)) => t,
Ok(Err(err)) => return Err(err),
Err(_) => {
if abort_on_timeout {
if let Some(pattern) = crate::ssh::packing::remote_abort_pattern(command)
{
let abort_cmd = crate::ssh::packing::pack_abort_pkill(&pattern);
tracing::warn!(
pattern = %pattern,
"local timeout; attempting best-effort remote abort"
);
let _ = self.try_remote_abort(&abort_cmd).await;
}
}
return Err(SshCliError::SshTimeout(self.cfg.timeout_ms.get()));
}
};
// Latency: reuse capture Vec as String on valid UTF-8 (no double-copy).
let (stdout_truncado, trunc_stdout_chars) =
take_utf8_capped(stdout_bytes, max_chars);
let (stderr_truncado, trunc_stderr_chars) =
take_utf8_capped(stderr_bytes, max_chars);
let truncated_stdout = trunc_stdout_chars || byte_trunc_stdout;
let truncated_stderr = trunc_stderr_chars || byte_trunc_stderr;
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
Ok(ExecutionOutput {
stdout: stdout_truncado,
stderr: stderr_truncado,
exit_code,
truncated_stdout,
truncated_stderr,
duration_ms,
})
}
}