Skip to main content

ssh_cli/ssh/
client_real_core.rs

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