Skip to main content

ssh_commander_core/ssh/
mod.rs

1use crate::file_entry::{
2    FileEntryType, RemoteFileEntry, format_permissions, format_unix_timestamp,
3};
4use anyhow::Result;
5use russh::keys::{HashAlg, PublicKeyBase64};
6use russh::*;
7use russh_sftp::client::SftpSession;
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::io::{AsyncReadExt, AsyncWriteExt};
13use tokio::sync::mpsc;
14use tokio_util::sync::CancellationToken;
15
16pub mod host_keys;
17pub mod shell;
18pub mod tunnel;
19pub use host_keys::{
20    HostKeyMismatch, HostKeyStore, HostKeyStoreAccessError, HostKeyVerificationFailure, Verdict,
21    VerificationFailureSlot,
22};
23pub use tunnel::{SshTunnel, SshTunnelRef};
24
25/// Chunk size used for streaming SFTP transfers. 32 KiB balances throughput
26/// against memory overhead for concurrent transfers. Larger sizes hit
27/// diminishing returns because SFTP's window management caps effective
28/// pipelining anyway.
29pub const SFTP_CHUNK_SIZE: usize = crate::file_entry::FILE_TRANSFER_CHUNK_SIZE;
30
31/// Preferred host-key algorithms advertised to the server, ordered from most to
32/// least preferred.  RSA variants (including the legacy `ssh-rsa` / SHA-1) are
33/// included so that older servers that only offer RSA host keys are still
34/// reachable.
35///
36/// In russh 0.61, host-key algorithms are represented as `ssh_key::Algorithm`
37/// (re-exported at `russh::keys::Algorithm`) instead of the old `key::Name` constants.
38pub static PREFERRED_HOST_KEY_ALGOS: &[russh::keys::Algorithm] = &[
39    russh::keys::Algorithm::Ed25519,
40    russh::keys::Algorithm::Ecdsa {
41        curve: russh::keys::EcdsaCurve::NistP256,
42    },
43    russh::keys::Algorithm::Ecdsa {
44        curve: russh::keys::EcdsaCurve::NistP521,
45    },
46    russh::keys::Algorithm::Rsa {
47        hash: Some(russh::keys::HashAlg::Sha256),
48    },
49    russh::keys::Algorithm::Rsa {
50        hash: Some(russh::keys::HashAlg::Sha512),
51    },
52    // Legacy ssh-rsa (SHA-1) for very old servers that advertise no modern RSA variant.
53    russh::keys::Algorithm::Rsa { hash: None },
54];
55
56/// Key-exchange algorithms offered to the server, most-preferred first.
57///
58/// russh's built-in DEFAULT only includes the post-2020 KEX methods (curve25519,
59/// dh-group14-sha256, dh-group16-sha512). Many enterprise / managed SFTP
60/// endpoints still require the SHA-1 variants and drop the TCP connection with
61/// "Connection reset by peer" during KEX if we don't offer them. Keeping the
62/// modern entries first means security-conscious servers still negotiate up.
63pub static PREFERRED_KEX_ALGOS: &[russh::kex::Name] = &[
64    russh::kex::CURVE25519,
65    russh::kex::CURVE25519_PRE_RFC_8731,
66    russh::kex::DH_G16_SHA512,
67    russh::kex::DH_G14_SHA256,
68    russh::kex::DH_G14_SHA1,
69    russh::kex::DH_G1_SHA1,
70    // Extension-negotiation markers — must remain in the offer list for
71    // strict-KEX and ext-info compatibility with modern servers.
72    russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
73    russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
74];
75
76#[derive(Clone, Serialize, Deserialize)]
77pub struct SshConfig {
78    pub host: String,
79    pub port: u16,
80    pub username: String,
81    pub auth_method: AuthMethod,
82}
83
84impl std::fmt::Debug for SshConfig {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("SshConfig")
87            .field("host", &self.host)
88            .field("port", &self.port)
89            .field("username", &self.username)
90            .field("auth_method", &self.auth_method)
91            .finish()
92    }
93}
94
95#[derive(Clone, Serialize, Deserialize)]
96#[serde(tag = "type")]
97pub enum AuthMethod {
98    Password {
99        password: String,
100    },
101    PublicKey {
102        key_path: String,
103        passphrase: Option<String>,
104    },
105    Agent {
106        identity_hint: Option<String>,
107    },
108}
109
110impl std::fmt::Debug for AuthMethod {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            AuthMethod::Password { .. } => f
114                .debug_struct("AuthMethod::Password")
115                .field("password", &"<redacted>")
116                .finish(),
117            AuthMethod::PublicKey {
118                key_path,
119                passphrase,
120            } => f
121                .debug_struct("AuthMethod::PublicKey")
122                .field("key_path", key_path)
123                .field(
124                    "passphrase",
125                    &passphrase
126                        .as_ref()
127                        .map(|_| "<redacted>")
128                        .unwrap_or("<none>"),
129                )
130                .finish(),
131            AuthMethod::Agent { identity_hint } => f
132                .debug_struct("AuthMethod::Agent")
133                .field("identity_hint", identity_hint)
134                .finish(),
135        }
136    }
137}
138
139pub struct SshClient {
140    session: Option<Arc<client::Handle<Client>>>,
141    host_keys: Arc<HostKeyStore>,
142    /// Cached SFTP subsystem channel, opened lazily on first file op and
143    /// reused thereafter. Avoids a channel-open round-trip per file op.
144    /// Cleared by `disconnect`.
145    sftp: tokio::sync::OnceCell<Arc<SftpSession>>,
146}
147
148/// Structured result of running a remote command. Callers that need to branch
149/// on the exit code or separate the streams should consume this directly; the
150/// convenience `execute_command` merges the streams for display.
151#[derive(Debug, Clone, Default)]
152pub struct CommandOutput {
153    pub stdout: String,
154    pub stderr: String,
155    pub exit_code: Option<u32>,
156}
157
158impl CommandOutput {
159    /// Did the command report a zero exit status?
160    pub fn is_success(&self) -> bool {
161        matches!(self.exit_code, Some(0))
162    }
163
164    /// stdout followed by stderr (separated by a newline only when both are
165    /// non-empty). This is the legacy shape `execute_command` returned before
166    /// the stderr fix, but now includes stderr instead of dropping it.
167    pub fn combined(&self) -> String {
168        if self.stderr.is_empty() {
169            self.stdout.clone()
170        } else if self.stdout.is_empty() {
171            self.stderr.clone()
172        } else {
173            let mut out = String::with_capacity(self.stdout.len() + self.stderr.len() + 1);
174            out.push_str(&self.stdout);
175            if !self.stdout.ends_with('\n') {
176                out.push('\n');
177            }
178            out.push_str(&self.stderr);
179            out
180        }
181    }
182}
183
184// PTY session handle for interactive shell
185pub struct PtySession {
186    pub input_tx: mpsc::Sender<Vec<u8>>,
187    pub output_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<Vec<u8>>>>,
188    /// Sender for resize requests (cols, rows) — forwarded to the SSH channel
189    pub resize_tx: mpsc::Sender<(u32, u32)>,
190    /// Cancellation token — cancelled when this session is torn down.
191    /// The WebSocket reader task should select on this to stop promptly.
192    pub cancel: CancellationToken,
193}
194
195/// russh Handler that verifies each server host key against a `HostKeyStore`.
196///
197/// On `Verdict::Known` the handshake proceeds. On `Verdict::Unknown` the key is
198/// TOFU-trusted and persisted. On `Verdict::Mismatch` the handshake is rejected
199/// and details are written to the shared verification-failure slot so the
200/// caller can build a descriptive user-facing error.
201pub struct Client {
202    host: String,
203    port: u16,
204    store: Arc<HostKeyStore>,
205    verification_failure_slot: VerificationFailureSlot,
206}
207
208impl Client {
209    pub fn new(
210        host: impl Into<String>,
211        port: u16,
212        store: Arc<HostKeyStore>,
213    ) -> (Self, VerificationFailureSlot) {
214        let slot: VerificationFailureSlot = Arc::new(std::sync::Mutex::new(None));
215        let client = Self {
216            host: host.into(),
217            port,
218            store,
219            verification_failure_slot: slot.clone(),
220        };
221        (client, slot)
222    }
223}
224
225// russh 0.61 uses RPITIT (no async_trait needed for the Handler trait itself).
226impl client::Handler for Client {
227    type Error = russh::Error;
228
229    async fn check_server_key(
230        &mut self,
231        server_public_key: &russh::keys::PublicKey,
232    ) -> Result<bool, Self::Error> {
233        match self
234            .store
235            .verify(&self.host, self.port, server_public_key)
236            .await
237        {
238            Ok(Verdict::Known) => {
239                tracing::debug!(
240                    "host key for {}:{} matches known_hosts",
241                    self.host,
242                    self.port
243                );
244                Ok(true)
245            }
246            Ok(Verdict::Unknown) => {
247                tracing::warn!(
248                    "TOFU: trusting new host key for {}:{} (fingerprint SHA256:{})",
249                    self.host,
250                    self.port,
251                    // fingerprint() now requires a HashAlg argument in russh 0.61.
252                    server_public_key.fingerprint(HashAlg::Sha256)
253                );
254                if let Err(e) = self
255                    .store
256                    .trust(&self.host, self.port, server_public_key)
257                    .await
258                {
259                    tracing::error!("failed to persist host key: {}", e);
260                    if let Ok(mut slot) = self.verification_failure_slot.lock() {
261                        *slot = Some(HostKeyVerificationFailure::StoreAccess(
262                            HostKeyStoreAccessError {
263                                host: self.host.clone(),
264                                port: self.port,
265                                store_path: self.store.path().to_path_buf(),
266                                operation: "write",
267                                source: e.to_string(),
268                            },
269                        ));
270                    }
271                    return Err(
272                        std::io::Error::other("failed to persist trusted SSH host key").into(),
273                    );
274                }
275                Ok(true)
276            }
277            Ok(Verdict::Mismatch {
278                expected_fingerprint,
279                got_fingerprint,
280            }) => {
281                tracing::error!(
282                    "host key mismatch for {}:{} — expected SHA256:{}, got SHA256:{}",
283                    self.host,
284                    self.port,
285                    expected_fingerprint,
286                    got_fingerprint
287                );
288                if let Ok(mut slot) = self.verification_failure_slot.lock() {
289                    *slot = Some(HostKeyVerificationFailure::Mismatch(HostKeyMismatch {
290                        host: self.host.clone(),
291                        port: self.port,
292                        expected_fingerprint,
293                        got_fingerprint,
294                        store_path: self.store.path().to_path_buf(),
295                    }));
296                }
297                Ok(false)
298            }
299            Err(e) => {
300                tracing::error!("failed to access host-key store: {}", e);
301                if let Ok(mut slot) = self.verification_failure_slot.lock() {
302                    *slot = Some(HostKeyVerificationFailure::StoreAccess(
303                        HostKeyStoreAccessError {
304                            host: self.host.clone(),
305                            port: self.port,
306                            store_path: self.store.path().to_path_buf(),
307                            operation: "read",
308                            source: e.to_string(),
309                        },
310                    ));
311                }
312                Err(std::io::Error::other("failed to access SSH host-key store").into())
313            }
314        }
315    }
316}
317
318/// A resolved authentication payload, shared between SSH and standalone-SFTP
319/// connection paths so they can use a single `connect_authenticated` helper
320/// instead of duplicating the config-building / error-wrapping logic.
321pub(crate) enum ResolvedAuth<'a> {
322    Password {
323        password: &'a str,
324    },
325    Key {
326        /// In russh 0.61 the key type is `ssh_key::PrivateKey` (re-exported as
327        /// `russh::keys::PrivateKey`), replacing the old `key::KeyPair`.
328        key: Box<russh::keys::PrivateKey>,
329        /// Optional hint for user-facing error messages (the key path the
330        /// user asked us to load). Not used for the authentication itself.
331        key_path_hint: Option<&'a str>,
332    },
333    Agent {
334        identity_hint: Option<&'a str>,
335    },
336}
337
338/// Perform the full SSH connect + authenticate sequence with host-key
339/// verification and a timeout. Produces a descriptive error if the key
340/// verification fails.
341pub(crate) async fn connect_authenticated(
342    host: &str,
343    port: u16,
344    username: &str,
345    auth: ResolvedAuth<'_>,
346    timeout: Duration,
347    host_keys: Arc<HostKeyStore>,
348) -> Result<client::Handle<Client>> {
349    let ssh_config = client::Config {
350        preferred: russh::Preferred {
351            // Cow::Borrowed is required in russh 0.61 — the fields changed from
352            // raw slices to Cow<'static, [...]>.
353            key: std::borrow::Cow::Borrowed(PREFERRED_HOST_KEY_ALGOS),
354            kex: std::borrow::Cow::Borrowed(PREFERRED_KEX_ALGOS),
355            ..russh::Preferred::DEFAULT
356        },
357        keepalive_interval: Some(Duration::from_secs(60)),
358        keepalive_max: 3,
359        ..client::Config::default()
360    };
361
362    let (handler, verification_failure_slot) = Client::new(host, port, host_keys);
363
364    let mut session = tokio::time::timeout(
365        timeout,
366        client::connect(Arc::new(ssh_config), (host, port), handler),
367    )
368    .await
369    .map_err(|_| {
370        anyhow::anyhow!(
371            "Connection timed out after {}s. Please check the host address and network.",
372            timeout.as_secs()
373        )
374    })?
375    .map_err(|e| {
376        if let Ok(mut guard) = verification_failure_slot.lock()
377            && let Some(failure) = guard.take()
378        {
379            return anyhow::anyhow!(format_verification_failure(&failure));
380        }
381
382        // "Connection reset by peer" during the initial handshake almost
383        // always means either (a) the server's IP allowlist is excluding
384        // us, or (b) an intermediate firewall / IDS is dropping the
385        // connection based on source address or protocol. No amount of
386        // KEX / cipher tuning on the client fixes either — the hint
387        // points the user at the real remediation path.
388        let msg = e.to_string();
389        let looks_like_reset = msg.contains("reset by peer")
390            || msg.contains("ConnectionReset")
391            || msg.contains("kex_exchange_identification");
392        if looks_like_reset {
393            return anyhow::anyhow!(
394                "The SSH server at {}:{} accepted the TCP connection but then \
395                 reset it during the handshake ({}).\n\n\
396                 This usually means the server is rejecting your source IP \
397                 or SSH client via a firewall / access list. Try:\n\
398                 - Confirm your public IP is on the server's allowlist (ask \
399                   the service operator).\n\
400                 - Connect over a VPN that terminates inside the allowed \
401                   network.\n\
402                 - Verify the host and port are correct for external access \
403                   (some services publish a different SFTP endpoint).",
404                host,
405                port,
406                e
407            );
408        }
409
410        anyhow::anyhow!("Failed to connect to {}:{}: {}", host, port, e)
411    })?;
412
413    // Capture what we need for the `!authenticated` error before moving `auth`
414    // into the matching branch.
415    let key_hint_for_error = match &auth {
416        ResolvedAuth::Password { .. } => None,
417        ResolvedAuth::Key { key_path_hint, .. } => key_path_hint.map(String::from),
418        ResolvedAuth::Agent { identity_hint } => Some(
419            identity_hint
420                .filter(|hint| !hint.is_empty())
421                .unwrap_or("SSH agent")
422                .to_string(),
423        ),
424    };
425
426    // russh 0.61: authenticate_* methods return AuthResult (Success / Failure enum)
427    // rather than bool. Call .success() to map to the bool the guard below expects.
428    let authenticated = match auth {
429        ResolvedAuth::Password { password } => session
430            .authenticate_password(username, password)
431            .await
432            .map_err(|e| anyhow::anyhow!("Password authentication failed: {}", e))?
433            .success(),
434        ResolvedAuth::Key { key, .. } => {
435            // russh 0.61: authenticate_publickey takes PrivateKeyWithHashAlg
436            // instead of Arc<KeyPair>. For RSA keys, hash_alg = None makes russh
437            // sign with SHA-1 (ssh-rsa), which OpenSSH >= 8.8 rejects by default
438            // — so we must offer the SHA-2 variants. Try SHA-512, then SHA-256,
439            // then SHA-1 for legacy servers. Non-RSA keys ignore the hash alg, so
440            // a single None attempt uses their natural algorithm.
441            let key = Arc::new(*key);
442            let hash_algs: &[Option<HashAlg>] =
443                if matches!(key.algorithm(), russh::keys::Algorithm::Rsa { .. }) {
444                    &[Some(HashAlg::Sha512), Some(HashAlg::Sha256), None]
445                } else {
446                    &[None]
447                };
448
449            let mut authenticated = false;
450            for hash_alg in hash_algs.iter().copied() {
451                let key_with_alg =
452                    russh::keys::key::PrivateKeyWithHashAlg::new(key.clone(), hash_alg);
453                let auth = session
454                    .authenticate_publickey(username, key_with_alg)
455                    .await
456                    .map_err(|e| {
457                        anyhow::anyhow!(
458                            "Public key authentication failed: {}. The key may not be authorized on the server.",
459                            e
460                        )
461                    })?;
462                if auth.success() {
463                    authenticated = true;
464                    break;
465                }
466            }
467            authenticated
468        }
469        ResolvedAuth::Agent { identity_hint } => {
470            // russh 0.61: authenticate_future is removed. Use authenticate_publickey_with,
471            // which takes a public key + optional hash_alg + a &mut Signer. AgentClient
472            // implements the Signer trait in russh 0.61 (russh::auth::Signer).
473            let mut agent = russh::keys::agent::client::AgentClient::connect_env()
474                .await
475                .map_err(|e| {
476                    anyhow::anyhow!(
477                        "SSH agent authentication is enabled, but r-shell could not connect to SSH_AUTH_SOCK: {}",
478                        e
479                    )
480                })?;
481            let identities = agent
482                .request_identities()
483                .await
484                .map_err(|e| anyhow::anyhow!("SSH agent did not return identities: {}", e))?;
485            let identity = select_agent_identity(identities, identity_hint).ok_or_else(|| {
486                if let Some(hint) = identity_hint.filter(|hint| !hint.is_empty()) {
487                    anyhow::anyhow!(
488                        "SSH agent has no identity matching '{}'. Add the key to your agent or clear the identity hint.",
489                        hint
490                    )
491                } else {
492                    anyhow::anyhow!("SSH agent has no identities. Add a key to your agent and try again.")
493                }
494            })?;
495            // Extract the public key from the agent identity for authenticate_publickey_with.
496            let public_key = identity.public_key().into_owned();
497            session
498                .authenticate_publickey_with(username, public_key, None, &mut agent)
499                .await
500                .map_err(|e| anyhow::anyhow!("SSH agent authentication failed: {}", e))?
501                .success()
502        }
503    };
504
505    if !authenticated {
506        return Err(match key_hint_for_error {
507            None => anyhow::anyhow!(
508                "Authentication failed for {}@{} with password authentication.",
509                username,
510                host
511            ),
512            Some(path) => anyhow::anyhow!(
513                "Authentication failed for {}@{} using public key {}.",
514                username,
515                host,
516                path
517            ),
518        });
519    }
520
521    Ok(session)
522}
523
524/// Select an agent identity based on an optional hint string.
525///
526/// In russh 0.61, `request_identities` returns `Vec<AgentIdentity>` (which may be
527/// a public key or a certificate) instead of `Vec<key::PublicKey>`. We compare
528/// against the base64 of the underlying public key to preserve the existing hint
529/// semantics.
530fn select_agent_identity(
531    identities: Vec<russh::keys::agent::AgentIdentity>,
532    identity_hint: Option<&str>,
533) -> Option<russh::keys::agent::AgentIdentity> {
534    let hint = identity_hint.map(str::trim).filter(|hint| !hint.is_empty());
535
536    match hint {
537        None => identities.into_iter().next(),
538        Some(hint) => identities.into_iter().find(|identity| {
539            let encoded = identity.public_key().public_key_base64();
540            encoded.contains(hint) || hint.contains(&encoded)
541        }),
542    }
543}
544
545/// Render a mismatch into a user-facing error message.
546pub fn format_mismatch(m: &HostKeyMismatch) -> String {
547    format!(
548        "Host key verification failed for {}:{}.\n\
549         Expected fingerprint (stored): SHA256:{}\n\
550         Offered fingerprint (server):  SHA256:{}\n\
551         If the remote host legitimately rotated its key, remove the entry from:\n  {}",
552        m.host,
553        m.port,
554        m.expected_fingerprint,
555        m.got_fingerprint,
556        m.store_path.display()
557    )
558}
559
560fn format_store_access_error(err: &HostKeyStoreAccessError) -> String {
561    format!(
562        "Host key verification could not complete for {}:{}.\n\
563         r-shell could not {} the trusted host-key store at:\n  {}\n\
564         Underlying error: {}\n\
565         Connection refused to avoid trusting a host key without a durable trust store.",
566        err.host,
567        err.port,
568        err.operation,
569        err.store_path.display(),
570        err.source
571    )
572}
573
574pub fn format_verification_failure(failure: &HostKeyVerificationFailure) -> String {
575    match failure {
576        HostKeyVerificationFailure::Mismatch(mismatch) => format_mismatch(mismatch),
577        HostKeyVerificationFailure::StoreAccess(err) => format_store_access_error(err),
578    }
579}
580
581/// Expand a leading `~/` to the user's home directory via `dirs::home_dir()`.
582/// Returns `None` if the path starts with `~/` but we cannot resolve home,
583/// letting callers produce a specific error instead of silently returning the
584/// literal tilde path.
585pub(crate) fn expand_home_path(path: &str) -> Option<String> {
586    if let Some(rest) = path.strip_prefix("~/") {
587        let home = dirs::home_dir()?;
588        Some(home.join(rest).to_string_lossy().into_owned())
589    } else if path == "~" {
590        dirs::home_dir().map(|h| h.to_string_lossy().into_owned())
591    } else {
592        Some(path.to_string())
593    }
594}
595
596/// Load a private key from disk, returning the russh 0.61 `PrivateKey` type
597/// (was `key::KeyPair` in older russh-keys).
598pub(crate) fn load_private_key(
599    key_path: &str,
600    passphrase: Option<&str>,
601) -> Result<russh::keys::PrivateKey> {
602    let expanded = expand_home_path(key_path).ok_or_else(|| {
603        anyhow::anyhow!(
604            "Cannot resolve '~' in SSH key path '{}': home directory unknown.",
605            key_path
606        )
607    })?;
608    let path = Path::new(&expanded);
609
610    // Surface both forms when they differ so the user can see that expansion
611    // happened and went where they expected.
612    let location = if expanded != key_path {
613        format!("{} (expanded from {})", expanded, key_path)
614    } else {
615        expanded.clone()
616    };
617
618    // Use `metadata()` (not `exists()`) so we can distinguish "not found" from
619    // "found but unreadable". On macOS the latter is usually a TCC / Full Disk
620    // Access denial, which the previous code silently reported as "not found".
621    match path.metadata() {
622        Ok(_) => {}
623        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
624            return Err(anyhow::anyhow!(
625                "SSH key file not found: {}. Please check the file path and try again.",
626                location
627            ));
628        }
629        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
630            return Err(anyhow::anyhow!(
631                "Permission denied reading SSH key at {}.\n\
632                 On macOS this usually means r-shell hasn't been granted access to this file. \
633                 Open System Settings → Privacy & Security → Full Disk Access (or App Management / Files and Folders), \
634                 add r-shell to the list, then try again.",
635                location
636            ));
637        }
638        Err(e) => {
639            return Err(anyhow::anyhow!(
640                "Cannot access SSH key at {}: {}",
641                location,
642                e
643            ));
644        }
645    }
646
647    // load_secret_key lives in russh::keys in russh 0.61 (was from russh_keys:: wildcard).
648    russh::keys::load_secret_key(path, passphrase).map_err(|e| {
649        let msg = e.to_string();
650        if msg.contains("encrypted") || msg.contains("passphrase") {
651            anyhow::anyhow!(
652                "Failed to decrypt SSH key at {}. The key may be encrypted. Please provide the correct passphrase.",
653                expanded
654            )
655        } else {
656            anyhow::anyhow!(
657                "Failed to load SSH key from {}: {}. Ensure the file is a valid SSH private key (RSA, Ed25519, or ECDSA).",
658                expanded, e
659            )
660        }
661    })
662}
663
664impl SshClient {
665    pub fn new(host_keys: Arc<HostKeyStore>) -> Self {
666        Self {
667            session: None,
668            host_keys,
669            sftp: tokio::sync::OnceCell::new(),
670        }
671    }
672
673    /// Return a handle to a cached SFTP session, opening the subsystem on
674    /// first call. Subsequent calls reuse the same session, saving the
675    /// channel-open + subsystem-negotiation round-trip.
676    async fn sftp_session(&self) -> Result<Arc<SftpSession>> {
677        let session = self
678            .session
679            .as_ref()
680            .ok_or_else(|| anyhow::anyhow!("Not connected"))?
681            .clone();
682        let sftp = self
683            .sftp
684            .get_or_try_init(|| async move {
685                let channel = session.channel_open_session().await?;
686                channel.request_subsystem(true, "sftp").await?;
687                let session = SftpSession::new(channel.into_stream()).await?;
688                Ok::<_, anyhow::Error>(Arc::new(session))
689            })
690            .await?;
691        Ok(sftp.clone())
692    }
693
694    pub async fn connect(&mut self, config: &SshConfig) -> Result<()> {
695        let auth = match &config.auth_method {
696            AuthMethod::Password { password } => ResolvedAuth::Password { password },
697            AuthMethod::PublicKey {
698                key_path,
699                passphrase,
700            } => ResolvedAuth::Key {
701                key: Box::new(load_private_key(key_path, passphrase.as_deref())?),
702                key_path_hint: Some(key_path.as_str()),
703            },
704            AuthMethod::Agent { identity_hint } => ResolvedAuth::Agent {
705                identity_hint: identity_hint.as_deref(),
706            },
707        };
708
709        let session = connect_authenticated(
710            &config.host,
711            config.port,
712            &config.username,
713            auth,
714            Duration::from_secs(10),
715            self.host_keys.clone(),
716        )
717        .await?;
718
719        self.session = Some(Arc::new(session));
720        Ok(())
721    }
722
723    /// Execute a remote command and return the combined stdout+stderr as a
724    /// string, matching the shell-convention where both streams are interleaved
725    /// in the user's view. Returns `Err` only for transport-level failures
726    /// (session gone, channel couldn't open) — a nonzero exit code is a valid
727    /// result, not an error, and its stdout/stderr is still returned.
728    ///
729    /// For callers that need to branch on the exit code or separate streams,
730    /// use [`SshClient::execute_command_full`] instead.
731    pub async fn execute_command(&self, command: &str) -> Result<String> {
732        let out = self.execute_command_full(command).await?;
733        Ok(out.combined())
734    }
735
736    /// Execute a remote command and return full stdout/stderr/exit-code.
737    pub async fn execute_command_full(&self, command: &str) -> Result<CommandOutput> {
738        let Some(session) = &self.session else {
739            return Err(anyhow::anyhow!("Not connected"));
740        };
741
742        let mut channel = session.channel_open_session().await?;
743        channel.exec(true, command).await?;
744
745        let mut stdout = String::new();
746        let mut stderr = String::new();
747        let mut exit_code: Option<u32> = None;
748        let mut eof_received = false;
749
750        loop {
751            let msg = channel.wait().await;
752            match msg {
753                Some(ChannelMsg::Data { ref data }) => {
754                    stdout.push_str(&String::from_utf8_lossy(data));
755                }
756                Some(ChannelMsg::ExtendedData { ref data, .. }) => {
757                    // Extended data channel 1 = stderr (per RFC 4254 §5.2).
758                    // Capture regardless of the `ext` code — servers occasionally
759                    // send other codes and dropping them silently is worse than
760                    // merging them into the stderr buffer.
761                    stderr.push_str(&String::from_utf8_lossy(data));
762                }
763                Some(ChannelMsg::ExitStatus { exit_status }) => {
764                    exit_code = Some(exit_status);
765                    if eof_received {
766                        break;
767                    }
768                }
769                Some(ChannelMsg::Eof) => {
770                    eof_received = true;
771                    if exit_code.is_some() {
772                        break;
773                    }
774                }
775                Some(ChannelMsg::Close) | None => {
776                    break;
777                }
778                _ => {}
779            }
780        }
781
782        Ok(CommandOutput {
783            stdout,
784            stderr,
785            exit_code,
786        })
787    }
788
789    /// Execute a remote command and stream its stdout line-by-line over
790    /// an mpsc channel. Returns the receiver and a cancellation token —
791    /// dropping the receiver or cancelling the token tears down the
792    /// channel.
793    ///
794    /// Used by long-running tools like `tcpdump` where the caller wants
795    /// real-time line output instead of waiting for the command to exit.
796    /// stderr lines are sent on the same channel prefixed with `"!"` so
797    /// the consumer can distinguish them; the convention keeps the FFI
798    /// surface a single string stream.
799    pub async fn execute_command_streaming(
800        &self,
801        command: &str,
802    ) -> Result<(mpsc::Receiver<String>, CancellationToken)> {
803        let Some(session) = &self.session else {
804            return Err(anyhow::anyhow!("Not connected"));
805        };
806
807        let mut channel = session.channel_open_session().await?;
808        channel.exec(true, command).await?;
809
810        let (tx, rx) = mpsc::channel::<String>(256);
811        let cancel = CancellationToken::new();
812        let cancel_task = cancel.clone();
813
814        tokio::spawn(async move {
815            // Buffers carry partial trailing lines across reads.
816            let mut stdout_buf = String::new();
817            let mut stderr_buf = String::new();
818            loop {
819                tokio::select! {
820                    _ = cancel_task.cancelled() => {
821                        let _ = channel.eof().await;
822                        let _ = channel.close().await;
823                        break;
824                    }
825                    msg = channel.wait() => {
826                        match msg {
827                            Some(ChannelMsg::Data { ref data }) => {
828                                stdout_buf.push_str(&String::from_utf8_lossy(data));
829                                while let Some(idx) = stdout_buf.find('\n') {
830                                    let line: String = stdout_buf.drain(..=idx).collect();
831                                    let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
832                                    if tx.send(trimmed).await.is_err() {
833                                        cancel_task.cancel();
834                                        break;
835                                    }
836                                }
837                            }
838                            Some(ChannelMsg::ExtendedData { ref data, .. }) => {
839                                stderr_buf.push_str(&String::from_utf8_lossy(data));
840                                while let Some(idx) = stderr_buf.find('\n') {
841                                    let line: String = stderr_buf.drain(..=idx).collect();
842                                    let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
843                                    if tx.send(format!("!{}", trimmed)).await.is_err() {
844                                        cancel_task.cancel();
845                                        break;
846                                    }
847                                }
848                            }
849                            Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
850                                if !stdout_buf.is_empty() {
851                                    let _ = tx.send(stdout_buf.trim_end_matches(['\r', '\n']).to_string()).await;
852                                }
853                                if !stderr_buf.is_empty() {
854                                    let _ = tx.send(format!("!{}", stderr_buf.trim_end_matches(['\r', '\n']))).await;
855                                }
856                                break;
857                            }
858                            _ => {}
859                        }
860                    }
861                }
862            }
863        });
864
865        Ok((rx, cancel))
866    }
867
868    pub async fn disconnect(&mut self) -> Result<()> {
869        // Drop the cached SFTP session first so its channel shuts cleanly
870        // before we tear down the underlying SSH transport.
871        self.sftp.take();
872
873        if let Some(session) = self.session.take() {
874            match Arc::try_unwrap(session) {
875                Ok(session) => {
876                    if let Err(e) = session.disconnect(Disconnect::ByApplication, "", "").await {
877                        tracing::warn!("SSH disconnect failed cleanly: {}", e);
878                    }
879                }
880                Err(arc_session) => {
881                    // Other references (typically spawned PTY tasks) still
882                    // exist. Drop ours — the session ends when the last
883                    // reference dies.
884                    tracing::debug!("SSH disconnect: other refs still alive, dropping handle");
885                    drop(arc_session);
886                }
887            }
888        }
889        Ok(())
890    }
891
892    /// Open a `direct-tcpip` channel that the SSH server bridges to
893    /// `(host, port)` as seen from the server's network. Used by the
894    /// Postgres tunnel to forward a local TCP listener through this
895    /// SSH session.
896    ///
897    /// `originator_address`/`originator_port` are reported to the server
898    /// for logging/auditing and may be `127.0.0.1:0` when the caller
899    /// doesn't have a meaningful client endpoint to advertise.
900    pub async fn open_direct_tcpip(
901        &self,
902        host: &str,
903        port: u16,
904    ) -> Result<russh::Channel<russh::client::Msg>> {
905        let Some(session) = &self.session else {
906            return Err(anyhow::anyhow!("Not connected"));
907        };
908        let channel = session
909            .channel_open_direct_tcpip(host.to_string(), port as u32, "127.0.0.1", 0)
910            .await?;
911        Ok(channel)
912    }
913
914    /// Create a persistent PTY shell session (like ttyd)
915    /// This enables interactive commands like vim, less, more, top, etc.
916    pub async fn create_pty_session(&self, cols: u32, rows: u32) -> Result<PtySession> {
917        if let Some(session) = &self.session {
918            // Open a new SSH channel
919            let mut channel = session.channel_open_session().await?;
920
921            // Request PTY with terminal type and dimensions
922            // Similar to ttyd's approach: xterm-256color terminal
923            channel
924                .request_pty(
925                    true,             // want_reply
926                    "xterm-256color", // terminal type (like ttyd)
927                    cols,             // columns
928                    rows,             // rows
929                    0,                // pixel_width (not used)
930                    0,                // pixel_height (not used)
931                    &[],              // terminal modes
932                )
933                .await?;
934
935            // Start interactive shell
936            channel.request_shell(true).await?;
937
938            // Create channels for bidirectional communication (like ttyd's pty_buf)
939            // Increased capacity for better buffering during fast input
940            let (input_tx, mut input_rx) = mpsc::channel::<Vec<u8>>(1000); // Increased from 100
941            let (output_tx, output_rx) = mpsc::channel::<Vec<u8>>(2000); // Increased from 1000
942
943            // Clone channel for input task
944            let input_channel = channel.make_writer();
945
946            // Create a channel for resize requests
947            let (resize_tx, mut resize_rx) = mpsc::channel::<(u32, u32)>(16);
948
949            // The cancel token is created here and shared with both spawned
950            // tasks *and* returned in `PtySession`. When `close_pty_connection`
951            // cancels, every long-lived future on this session unblocks.
952            let cancel = CancellationToken::new();
953
954            // Spawn task to handle input (frontend → SSH)
955            // This is similar to ttyd's pty_write and INPUT command handling
956            // Key: immediate write + flush for responsiveness
957            let input_cancel = cancel.clone();
958            tokio::spawn(async move {
959                let mut writer = input_channel;
960                loop {
961                    tokio::select! {
962                        biased;
963                        _ = input_cancel.cancelled() => {
964                            tracing::debug!("[PTY] input task cancelled");
965                            break;
966                        }
967                        maybe_data = input_rx.recv() => {
968                            let Some(data) = maybe_data else {
969                                // sender dropped — session torn down
970                                break;
971                            };
972                            if let Err(e) = writer.write_all(&data).await {
973                                tracing::error!("[PTY] failed to send data to SSH: {}", e);
974                                break;
975                            }
976                            if let Err(e) = writer.flush().await {
977                                tracing::error!("[PTY] failed to flush data to SSH: {}", e);
978                                break;
979                            }
980                        }
981                    }
982                }
983            });
984
985            // Spawn task to handle output (SSH → frontend) AND resize requests.
986            // The channel must stay in this task because `wait()` requires `&mut self`,
987            // but we also need `window_change()` which only requires `&self`.
988            // We use `tokio::select!` to multiplex between output reading, resize,
989            // and cancellation. Without the cancel arm the task would outlive
990            // `close_pty_connection` until the remote side eventually closes the
991            // channel — see audit finding #7.
992            let output_cancel = cancel.clone();
993            tokio::spawn(async move {
994                loop {
995                    tokio::select! {
996                        biased;
997                        _ = output_cancel.cancelled() => {
998                            tracing::debug!("[PTY] output task cancelled");
999                            break;
1000                        }
1001                        msg = channel.wait() => {
1002                            match msg {
1003                                Some(ChannelMsg::Data { data })
1004                                    if output_tx.send(data.to_vec()).await.is_err() =>
1005                                {
1006                                    break;
1007                                }
1008                                Some(ChannelMsg::ExtendedData { data, .. })
1009                                    if output_tx.send(data.to_vec()).await.is_err() =>
1010                                {
1011                                    // stderr data (also send to output)
1012                                    break;
1013                                }
1014                                Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
1015                                    tracing::debug!("[PTY] channel closed");
1016                                    break;
1017                                }
1018                                Some(ChannelMsg::ExitStatus { exit_status }) => {
1019                                    tracing::info!("[PTY] process exited with status: {}", exit_status);
1020                                }
1021                                _ => {}
1022                            }
1023                        }
1024                        resize = resize_rx.recv() => {
1025                            match resize {
1026                                Some((cols, rows)) => {
1027                                    if let Err(e) = channel.window_change(cols, rows, 0, 0).await {
1028                                        tracing::error!("[PTY] failed to send window change: {}", e);
1029                                    } else {
1030                                        tracing::debug!("[PTY] window changed to {}x{}", cols, rows);
1031                                    }
1032                                }
1033                                None => {
1034                                    // resize channel closed, session is being torn down
1035                                    break;
1036                                }
1037                            }
1038                        }
1039                    }
1040                }
1041            });
1042
1043            Ok(PtySession {
1044                input_tx,
1045                output_rx: Arc::new(tokio::sync::Mutex::new(output_rx)),
1046                resize_tx,
1047                cancel,
1048            })
1049        } else {
1050            Err(anyhow::anyhow!("Not connected"))
1051        }
1052    }
1053
1054    pub async fn list_dir(&self, path: &str) -> Result<Vec<RemoteFileEntry>> {
1055        let sftp = self.sftp_session().await?;
1056        let entries = sftp
1057            .read_dir(path)
1058            .await
1059            .map_err(|e| anyhow::anyhow!("Failed to list directory '{}': {}", path, e))?;
1060
1061        let mut result = Vec::new();
1062        for entry in entries {
1063            let name = entry.file_name();
1064            if name == "." || name == ".." {
1065                continue;
1066            }
1067
1068            let attrs = entry.metadata();
1069            let size = attrs.size.unwrap_or(0);
1070            let mtime_secs = attrs.mtime.map(|t| t as i64);
1071            let modified = mtime_secs.map(format_unix_timestamp);
1072            let permissions = attrs.permissions.map(format_permissions);
1073            let owner = attrs.uid.map(|u| u.to_string());
1074            let group = attrs.gid.map(|g| g.to_string());
1075
1076            let file_type = if attrs.is_dir() {
1077                FileEntryType::Directory
1078            } else if attrs.is_symlink() {
1079                FileEntryType::Symlink
1080            } else {
1081                FileEntryType::File
1082            };
1083
1084            result.push(RemoteFileEntry {
1085                name,
1086                size,
1087                modified,
1088                modified_unix: mtime_secs,
1089                permissions,
1090                owner,
1091                group,
1092                file_type,
1093            });
1094        }
1095
1096        result.sort_by(|a, b| {
1097            let a_is_dir = matches!(a.file_type, FileEntryType::Directory);
1098            let b_is_dir = matches!(b.file_type, FileEntryType::Directory);
1099            b_is_dir
1100                .cmp(&a_is_dir)
1101                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1102        });
1103
1104        Ok(result)
1105    }
1106
1107    pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result<u64> {
1108        self.download_file_with_progress(remote_path, local_path, |_| {}, None)
1109            .await
1110    }
1111
1112    /// Stream a remote file to disk, calling `progress` after every SFTP
1113    /// chunk with the running total of bytes transferred. Caller is
1114    /// responsible for knowing the file's total size — use `list_dir` /
1115    /// the entry's `size` field beforehand.
1116    ///
1117    /// The callback runs synchronously inside the read loop, so it must
1118    /// be cheap. The macOS bridge uses it to emit `TransferProgress`
1119    /// events on the event bus; the real work happens on the consumer
1120    /// thread that drains the bus, not here.
1121    ///
1122    /// `cancel`, when supplied, is checked between chunks. On
1123    /// cancellation the partial local file is left on disk (callers can
1124    /// delete it on receipt of the `TransferCancelled` error if they
1125    /// want clean removal — leaving it lets a future "resume" feature
1126    /// pick up where we left off).
1127    pub async fn download_file_with_progress(
1128        &self,
1129        remote_path: &str,
1130        local_path: &str,
1131        mut progress: impl FnMut(u64),
1132        cancel: Option<&CancellationToken>,
1133    ) -> Result<u64> {
1134        let sftp = self.sftp_session().await?;
1135        let mut remote_file = sftp.open(remote_path).await?;
1136        let mut local_file = tokio::fs::File::create(local_path).await?;
1137
1138        let mut buf = vec![0u8; SFTP_CHUNK_SIZE];
1139        let mut total_bytes = 0u64;
1140        loop {
1141            if let Some(token) = cancel
1142                && token.is_cancelled()
1143            {
1144                return Err(anyhow::anyhow!("Transfer cancelled"));
1145            }
1146            let n = remote_file.read(&mut buf).await?;
1147            if n == 0 {
1148                break;
1149            }
1150            local_file.write_all(&buf[..n]).await?;
1151            total_bytes += n as u64;
1152            progress(total_bytes);
1153        }
1154        local_file.flush().await?;
1155
1156        Ok(total_bytes)
1157    }
1158
1159    pub async fn download_file_to_memory(&self, remote_path: &str) -> Result<Vec<u8>> {
1160        let sftp = self.sftp_session().await?;
1161        let mut remote_file = sftp.open(remote_path).await?;
1162
1163        let mut buffer = Vec::new();
1164        let mut temp_buf = vec![0u8; SFTP_CHUNK_SIZE];
1165        loop {
1166            let n = remote_file.read(&mut temp_buf).await?;
1167            if n == 0 {
1168                break;
1169            }
1170            buffer.extend_from_slice(&temp_buf[..n]);
1171        }
1172        Ok(buffer)
1173    }
1174
1175    pub async fn upload_file(&self, local_path: &str, remote_path: &str) -> Result<u64> {
1176        self.upload_file_with_progress(local_path, remote_path, |_| {}, None)
1177            .await
1178    }
1179
1180    /// Stream a local file to the remote, calling `progress` after every
1181    /// SFTP chunk. See `download_file_with_progress` for the threading
1182    /// constraints — the callback is on the same task as the SFTP I/O.
1183    ///
1184    /// `cancel` checks between chunks; on cancellation the partial
1185    /// remote file is left in place (call `delete_file` afterwards if
1186    /// clean removal is wanted).
1187    pub async fn upload_file_with_progress(
1188        &self,
1189        local_path: &str,
1190        remote_path: &str,
1191        mut progress: impl FnMut(u64),
1192        cancel: Option<&CancellationToken>,
1193    ) -> Result<u64> {
1194        let sftp = self.sftp_session().await?;
1195        let mut local_file = tokio::fs::File::open(local_path).await?;
1196        let mut remote_file = sftp.create(remote_path).await?;
1197
1198        let mut buf = vec![0u8; SFTP_CHUNK_SIZE];
1199        let mut total_bytes = 0u64;
1200        loop {
1201            if let Some(token) = cancel
1202                && token.is_cancelled()
1203            {
1204                return Err(anyhow::anyhow!("Transfer cancelled"));
1205            }
1206            let n = local_file.read(&mut buf).await?;
1207            if n == 0 {
1208                break;
1209            }
1210            remote_file.write_all(&buf[..n]).await?;
1211            total_bytes += n as u64;
1212            progress(total_bytes);
1213        }
1214        remote_file.flush().await?;
1215
1216        Ok(total_bytes)
1217    }
1218
1219    pub async fn upload_file_from_bytes(&self, data: &[u8], remote_path: &str) -> Result<u64> {
1220        let sftp = self.sftp_session().await?;
1221        let mut remote_file = sftp.create(remote_path).await?;
1222
1223        for chunk in data.chunks(SFTP_CHUNK_SIZE) {
1224            remote_file.write_all(chunk).await?;
1225        }
1226        remote_file.flush().await?;
1227
1228        Ok(data.len() as u64)
1229    }
1230
1231    /// Create a directory on the remote. Fails if the parent doesn't
1232    /// exist or the path is already taken.
1233    pub async fn create_dir(&self, path: &str) -> Result<()> {
1234        let sftp = self.sftp_session().await?;
1235        sftp.create_dir(path)
1236            .await
1237            .map_err(|e| anyhow::anyhow!("Failed to create directory '{}': {}", path, e))?;
1238        Ok(())
1239    }
1240
1241    /// Rename a file or directory. SFTP RENAME is atomic when source
1242    /// and destination are on the same filesystem; cross-filesystem
1243    /// renames may copy-then-delete depending on the server.
1244    pub async fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
1245        let sftp = self.sftp_session().await?;
1246        sftp.rename(old_path, new_path).await.map_err(|e| {
1247            anyhow::anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e)
1248        })?;
1249        Ok(())
1250    }
1251
1252    /// Delete a regular file. For directories, use `delete_dir`.
1253    pub async fn delete_file(&self, path: &str) -> Result<()> {
1254        let sftp = self.sftp_session().await?;
1255        sftp.remove_file(path)
1256            .await
1257            .map_err(|e| anyhow::anyhow!("Failed to delete file '{}': {}", path, e))?;
1258        Ok(())
1259    }
1260
1261    /// Delete an empty directory. SFTP requires the directory be empty;
1262    /// recursive removal would need a list-then-delete loop, which
1263    /// belongs at the caller layer (with progress reporting).
1264    pub async fn delete_dir(&self, path: &str) -> Result<()> {
1265        let sftp = self.sftp_session().await?;
1266        sftp.remove_dir(path)
1267            .await
1268            .map_err(|e| anyhow::anyhow!("Failed to delete directory '{}': {}", path, e))?;
1269        Ok(())
1270    }
1271}
1272
1273#[cfg(test)]
1274mod expand_home_tests {
1275    use super::expand_home_path;
1276
1277    #[test]
1278    fn returns_non_tilde_paths_unchanged() {
1279        assert_eq!(
1280            expand_home_path("/absolute/path").as_deref(),
1281            Some("/absolute/path")
1282        );
1283        assert_eq!(
1284            expand_home_path("relative/dir").as_deref(),
1285            Some("relative/dir")
1286        );
1287        assert_eq!(expand_home_path("").as_deref(), Some(""));
1288    }
1289
1290    #[test]
1291    fn expands_tilde_slash_prefix_when_home_is_known() {
1292        // We can't rely on a specific home_dir() value here, but we can assert
1293        // that the tilde is replaced and the suffix is preserved.
1294        let expanded = expand_home_path("~/.ssh/id_rsa");
1295        // When CI doesn't set HOME, dirs::home_dir may return None — tolerate both
1296        // outcomes and only assert the happy path.
1297        if let Some(result) = expanded {
1298            assert!(
1299                !result.starts_with("~/"),
1300                "tilde must be expanded: {}",
1301                result
1302            );
1303            assert!(
1304                result.ends_with("/.ssh/id_rsa"),
1305                "suffix preserved: {}",
1306                result
1307            );
1308        }
1309    }
1310}
1311
1312#[cfg(test)]
1313mod command_output_tests {
1314    use super::CommandOutput;
1315
1316    #[test]
1317    fn is_success_requires_zero_exit() {
1318        assert!(
1319            CommandOutput {
1320                stdout: "x".into(),
1321                stderr: "".into(),
1322                exit_code: Some(0),
1323            }
1324            .is_success()
1325        );
1326        assert!(
1327            !CommandOutput {
1328                stdout: "x".into(),
1329                stderr: "".into(),
1330                exit_code: Some(1),
1331            }
1332            .is_success()
1333        );
1334        assert!(
1335            !CommandOutput {
1336                stdout: "x".into(),
1337                stderr: "".into(),
1338                exit_code: None,
1339            }
1340            .is_success()
1341        );
1342    }
1343
1344    #[test]
1345    fn combined_merges_streams_with_separator() {
1346        let c = CommandOutput {
1347            stdout: "out".into(),
1348            stderr: "err".into(),
1349            exit_code: Some(0),
1350        };
1351        assert_eq!(c.combined(), "out\nerr");
1352    }
1353
1354    #[test]
1355    fn combined_preserves_trailing_newline() {
1356        let c = CommandOutput {
1357            stdout: "out\n".into(),
1358            stderr: "err".into(),
1359            exit_code: Some(0),
1360        };
1361        assert_eq!(c.combined(), "out\nerr");
1362    }
1363
1364    #[test]
1365    fn combined_returns_single_stream_when_other_empty() {
1366        assert_eq!(
1367            CommandOutput {
1368                stdout: "only".into(),
1369                stderr: "".into(),
1370                exit_code: Some(0),
1371            }
1372            .combined(),
1373            "only"
1374        );
1375        assert_eq!(
1376            CommandOutput {
1377                stdout: "".into(),
1378                stderr: "only-err".into(),
1379                exit_code: Some(1),
1380            }
1381            .combined(),
1382            "only-err"
1383        );
1384    }
1385}
1386
1387#[cfg(test)]
1388mod redaction_tests {
1389    use super::{AuthMethod, SshConfig};
1390
1391    #[test]
1392    fn debug_redacts_password() {
1393        let cfg = SshConfig {
1394            host: "h".into(),
1395            port: 22,
1396            username: "u".into(),
1397            auth_method: AuthMethod::Password {
1398                password: "super-secret-123".into(),
1399            },
1400        };
1401        let rendered = format!("{:?}", cfg);
1402        assert!(
1403            !rendered.contains("super-secret-123"),
1404            "password must not appear in Debug output: {}",
1405            rendered
1406        );
1407        assert!(rendered.contains("<redacted>"), "expected redaction marker");
1408    }
1409
1410    #[test]
1411    fn debug_redacts_passphrase() {
1412        let m = AuthMethod::PublicKey {
1413            key_path: "/tmp/id".into(),
1414            passphrase: Some("xyz-passphrase".into()),
1415        };
1416        let rendered = format!("{:?}", m);
1417        assert!(!rendered.contains("xyz-passphrase"));
1418        assert!(rendered.contains("<redacted>"));
1419        assert!(rendered.contains("/tmp/id"));
1420    }
1421
1422    #[test]
1423    fn debug_shows_none_when_no_passphrase() {
1424        let m = AuthMethod::PublicKey {
1425            key_path: "/tmp/id".into(),
1426            passphrase: None,
1427        };
1428        let rendered = format!("{:?}", m);
1429        assert!(rendered.contains("<none>"));
1430    }
1431}
1432
1433#[cfg(test)]
1434mod key_loading_tests {
1435    use super::load_private_key;
1436    use std::io::Write;
1437    use tempfile::NamedTempFile;
1438
1439    const TEST_OPENSSH_PRIVATE_KEY: &str = "\
1440-----BEGIN OPENSSH PRIVATE KEY-----\n\
1441b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n\
1442QyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYgAAAJgAIAxdACAM\n\
1443XQAAAAtzc2gtZWQyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYg\n\
1444AAAEC2BsIi0QwW2uFscKTUUXNHLsYX4FxlaSDSblbAj7WR7bM+rvN+ot98qgEN796jTiQf\n\
1445ZfG1KaT0PtFDJ/XFSqtiAAAAEHVzZXJAZXhhbXBsZS5jb20BAgMEBQ==\n\
1446-----END OPENSSH PRIVATE KEY-----\n";
1447
1448    #[test]
1449    fn load_private_key_reads_key_file_contents() {
1450        let mut key_file = NamedTempFile::new().expect("failed to create temp key file");
1451        key_file
1452            .write_all(TEST_OPENSSH_PRIVATE_KEY.as_bytes())
1453            .expect("failed to write temp key file");
1454
1455        let key = load_private_key(
1456            key_file
1457                .path()
1458                .to_str()
1459                .expect("temp key path must be valid UTF-8"),
1460            None,
1461        )
1462        .expect("expected key file to load successfully");
1463
1464        // russh 0.61: PrivateKey uses algorithm() → Algorithm instead of name() → &str.
1465        // Algorithm implements Display, producing the wire-format algorithm name.
1466        assert_eq!(key.algorithm().to_string(), "ssh-ed25519");
1467    }
1468}
1469
1470#[cfg(test)]
1471mod tests;