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>. Pass None for hash_alg so non-RSA keys
437            // use their natural algorithm and RSA falls back to SHA-1 (caller
438            // can upgrade by setting Some(HashAlg::Sha256) when desired).
439            let key_with_alg = russh::keys::key::PrivateKeyWithHashAlg::new(Arc::new(*key), None);
440            session
441                .authenticate_publickey(username, key_with_alg)
442                .await
443                .map_err(|e| {
444                    anyhow::anyhow!(
445                        "Public key authentication failed: {}. The key may not be authorized on the server.",
446                        e
447                    )
448                })?
449                .success()
450        }
451        ResolvedAuth::Agent { identity_hint } => {
452            // russh 0.61: authenticate_future is removed. Use authenticate_publickey_with,
453            // which takes a public key + optional hash_alg + a &mut Signer. AgentClient
454            // implements the Signer trait in russh 0.61 (russh::auth::Signer).
455            let mut agent = russh::keys::agent::client::AgentClient::connect_env()
456                .await
457                .map_err(|e| {
458                    anyhow::anyhow!(
459                        "SSH agent authentication is enabled, but r-shell could not connect to SSH_AUTH_SOCK: {}",
460                        e
461                    )
462                })?;
463            let identities = agent
464                .request_identities()
465                .await
466                .map_err(|e| anyhow::anyhow!("SSH agent did not return identities: {}", e))?;
467            let identity = select_agent_identity(identities, identity_hint).ok_or_else(|| {
468                if let Some(hint) = identity_hint.filter(|hint| !hint.is_empty()) {
469                    anyhow::anyhow!(
470                        "SSH agent has no identity matching '{}'. Add the key to your agent or clear the identity hint.",
471                        hint
472                    )
473                } else {
474                    anyhow::anyhow!("SSH agent has no identities. Add a key to your agent and try again.")
475                }
476            })?;
477            // Extract the public key from the agent identity for authenticate_publickey_with.
478            let public_key = identity.public_key().into_owned();
479            session
480                .authenticate_publickey_with(username, public_key, None, &mut agent)
481                .await
482                .map_err(|e| anyhow::anyhow!("SSH agent authentication failed: {}", e))?
483                .success()
484        }
485    };
486
487    if !authenticated {
488        return Err(match key_hint_for_error {
489            None => anyhow::anyhow!(
490                "Authentication failed for {}@{} with password authentication.",
491                username,
492                host
493            ),
494            Some(path) => anyhow::anyhow!(
495                "Authentication failed for {}@{} using public key {}.",
496                username,
497                host,
498                path
499            ),
500        });
501    }
502
503    Ok(session)
504}
505
506/// Select an agent identity based on an optional hint string.
507///
508/// In russh 0.61, `request_identities` returns `Vec<AgentIdentity>` (which may be
509/// a public key or a certificate) instead of `Vec<key::PublicKey>`. We compare
510/// against the base64 of the underlying public key to preserve the existing hint
511/// semantics.
512fn select_agent_identity(
513    identities: Vec<russh::keys::agent::AgentIdentity>,
514    identity_hint: Option<&str>,
515) -> Option<russh::keys::agent::AgentIdentity> {
516    let hint = identity_hint.map(str::trim).filter(|hint| !hint.is_empty());
517
518    match hint {
519        None => identities.into_iter().next(),
520        Some(hint) => identities.into_iter().find(|identity| {
521            let encoded = identity.public_key().public_key_base64();
522            encoded.contains(hint) || hint.contains(&encoded)
523        }),
524    }
525}
526
527/// Render a mismatch into a user-facing error message.
528pub fn format_mismatch(m: &HostKeyMismatch) -> String {
529    format!(
530        "Host key verification failed for {}:{}.\n\
531         Expected fingerprint (stored): SHA256:{}\n\
532         Offered fingerprint (server):  SHA256:{}\n\
533         If the remote host legitimately rotated its key, remove the entry from:\n  {}",
534        m.host,
535        m.port,
536        m.expected_fingerprint,
537        m.got_fingerprint,
538        m.store_path.display()
539    )
540}
541
542fn format_store_access_error(err: &HostKeyStoreAccessError) -> String {
543    format!(
544        "Host key verification could not complete for {}:{}.\n\
545         r-shell could not {} the trusted host-key store at:\n  {}\n\
546         Underlying error: {}\n\
547         Connection refused to avoid trusting a host key without a durable trust store.",
548        err.host,
549        err.port,
550        err.operation,
551        err.store_path.display(),
552        err.source
553    )
554}
555
556pub fn format_verification_failure(failure: &HostKeyVerificationFailure) -> String {
557    match failure {
558        HostKeyVerificationFailure::Mismatch(mismatch) => format_mismatch(mismatch),
559        HostKeyVerificationFailure::StoreAccess(err) => format_store_access_error(err),
560    }
561}
562
563/// Expand a leading `~/` to the user's home directory via `dirs::home_dir()`.
564/// Returns `None` if the path starts with `~/` but we cannot resolve home,
565/// letting callers produce a specific error instead of silently returning the
566/// literal tilde path.
567pub(crate) fn expand_home_path(path: &str) -> Option<String> {
568    if let Some(rest) = path.strip_prefix("~/") {
569        let home = dirs::home_dir()?;
570        Some(home.join(rest).to_string_lossy().into_owned())
571    } else if path == "~" {
572        dirs::home_dir().map(|h| h.to_string_lossy().into_owned())
573    } else {
574        Some(path.to_string())
575    }
576}
577
578/// Load a private key from disk, returning the russh 0.61 `PrivateKey` type
579/// (was `key::KeyPair` in older russh-keys).
580pub(crate) fn load_private_key(
581    key_path: &str,
582    passphrase: Option<&str>,
583) -> Result<russh::keys::PrivateKey> {
584    let expanded = expand_home_path(key_path).ok_or_else(|| {
585        anyhow::anyhow!(
586            "Cannot resolve '~' in SSH key path '{}': home directory unknown.",
587            key_path
588        )
589    })?;
590    let path = Path::new(&expanded);
591
592    // Surface both forms when they differ so the user can see that expansion
593    // happened and went where they expected.
594    let location = if expanded != key_path {
595        format!("{} (expanded from {})", expanded, key_path)
596    } else {
597        expanded.clone()
598    };
599
600    // Use `metadata()` (not `exists()`) so we can distinguish "not found" from
601    // "found but unreadable". On macOS the latter is usually a TCC / Full Disk
602    // Access denial, which the previous code silently reported as "not found".
603    match path.metadata() {
604        Ok(_) => {}
605        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
606            return Err(anyhow::anyhow!(
607                "SSH key file not found: {}. Please check the file path and try again.",
608                location
609            ));
610        }
611        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
612            return Err(anyhow::anyhow!(
613                "Permission denied reading SSH key at {}.\n\
614                 On macOS this usually means r-shell hasn't been granted access to this file. \
615                 Open System Settings → Privacy & Security → Full Disk Access (or App Management / Files and Folders), \
616                 add r-shell to the list, then try again.",
617                location
618            ));
619        }
620        Err(e) => {
621            return Err(anyhow::anyhow!(
622                "Cannot access SSH key at {}: {}",
623                location,
624                e
625            ));
626        }
627    }
628
629    // load_secret_key lives in russh::keys in russh 0.61 (was from russh_keys:: wildcard).
630    russh::keys::load_secret_key(path, passphrase).map_err(|e| {
631        let msg = e.to_string();
632        if msg.contains("encrypted") || msg.contains("passphrase") {
633            anyhow::anyhow!(
634                "Failed to decrypt SSH key at {}. The key may be encrypted. Please provide the correct passphrase.",
635                expanded
636            )
637        } else {
638            anyhow::anyhow!(
639                "Failed to load SSH key from {}: {}. Ensure the file is a valid SSH private key (RSA, Ed25519, or ECDSA).",
640                expanded, e
641            )
642        }
643    })
644}
645
646impl SshClient {
647    pub fn new(host_keys: Arc<HostKeyStore>) -> Self {
648        Self {
649            session: None,
650            host_keys,
651            sftp: tokio::sync::OnceCell::new(),
652        }
653    }
654
655    /// Return a handle to a cached SFTP session, opening the subsystem on
656    /// first call. Subsequent calls reuse the same session, saving the
657    /// channel-open + subsystem-negotiation round-trip.
658    async fn sftp_session(&self) -> Result<Arc<SftpSession>> {
659        let session = self
660            .session
661            .as_ref()
662            .ok_or_else(|| anyhow::anyhow!("Not connected"))?
663            .clone();
664        let sftp = self
665            .sftp
666            .get_or_try_init(|| async move {
667                let channel = session.channel_open_session().await?;
668                channel.request_subsystem(true, "sftp").await?;
669                let session = SftpSession::new(channel.into_stream()).await?;
670                Ok::<_, anyhow::Error>(Arc::new(session))
671            })
672            .await?;
673        Ok(sftp.clone())
674    }
675
676    pub async fn connect(&mut self, config: &SshConfig) -> Result<()> {
677        let auth = match &config.auth_method {
678            AuthMethod::Password { password } => ResolvedAuth::Password { password },
679            AuthMethod::PublicKey {
680                key_path,
681                passphrase,
682            } => ResolvedAuth::Key {
683                key: Box::new(load_private_key(key_path, passphrase.as_deref())?),
684                key_path_hint: Some(key_path.as_str()),
685            },
686            AuthMethod::Agent { identity_hint } => ResolvedAuth::Agent {
687                identity_hint: identity_hint.as_deref(),
688            },
689        };
690
691        let session = connect_authenticated(
692            &config.host,
693            config.port,
694            &config.username,
695            auth,
696            Duration::from_secs(10),
697            self.host_keys.clone(),
698        )
699        .await?;
700
701        self.session = Some(Arc::new(session));
702        Ok(())
703    }
704
705    /// Execute a remote command and return the combined stdout+stderr as a
706    /// string, matching the shell-convention where both streams are interleaved
707    /// in the user's view. Returns `Err` only for transport-level failures
708    /// (session gone, channel couldn't open) — a nonzero exit code is a valid
709    /// result, not an error, and its stdout/stderr is still returned.
710    ///
711    /// For callers that need to branch on the exit code or separate streams,
712    /// use [`SshClient::execute_command_full`] instead.
713    pub async fn execute_command(&self, command: &str) -> Result<String> {
714        let out = self.execute_command_full(command).await?;
715        Ok(out.combined())
716    }
717
718    /// Execute a remote command and return full stdout/stderr/exit-code.
719    pub async fn execute_command_full(&self, command: &str) -> Result<CommandOutput> {
720        let Some(session) = &self.session else {
721            return Err(anyhow::anyhow!("Not connected"));
722        };
723
724        let mut channel = session.channel_open_session().await?;
725        channel.exec(true, command).await?;
726
727        let mut stdout = String::new();
728        let mut stderr = String::new();
729        let mut exit_code: Option<u32> = None;
730        let mut eof_received = false;
731
732        loop {
733            let msg = channel.wait().await;
734            match msg {
735                Some(ChannelMsg::Data { ref data }) => {
736                    stdout.push_str(&String::from_utf8_lossy(data));
737                }
738                Some(ChannelMsg::ExtendedData { ref data, .. }) => {
739                    // Extended data channel 1 = stderr (per RFC 4254 §5.2).
740                    // Capture regardless of the `ext` code — servers occasionally
741                    // send other codes and dropping them silently is worse than
742                    // merging them into the stderr buffer.
743                    stderr.push_str(&String::from_utf8_lossy(data));
744                }
745                Some(ChannelMsg::ExitStatus { exit_status }) => {
746                    exit_code = Some(exit_status);
747                    if eof_received {
748                        break;
749                    }
750                }
751                Some(ChannelMsg::Eof) => {
752                    eof_received = true;
753                    if exit_code.is_some() {
754                        break;
755                    }
756                }
757                Some(ChannelMsg::Close) | None => {
758                    break;
759                }
760                _ => {}
761            }
762        }
763
764        Ok(CommandOutput {
765            stdout,
766            stderr,
767            exit_code,
768        })
769    }
770
771    /// Execute a remote command and stream its stdout line-by-line over
772    /// an mpsc channel. Returns the receiver and a cancellation token —
773    /// dropping the receiver or cancelling the token tears down the
774    /// channel.
775    ///
776    /// Used by long-running tools like `tcpdump` where the caller wants
777    /// real-time line output instead of waiting for the command to exit.
778    /// stderr lines are sent on the same channel prefixed with `"!"` so
779    /// the consumer can distinguish them; the convention keeps the FFI
780    /// surface a single string stream.
781    pub async fn execute_command_streaming(
782        &self,
783        command: &str,
784    ) -> Result<(mpsc::Receiver<String>, CancellationToken)> {
785        let Some(session) = &self.session else {
786            return Err(anyhow::anyhow!("Not connected"));
787        };
788
789        let mut channel = session.channel_open_session().await?;
790        channel.exec(true, command).await?;
791
792        let (tx, rx) = mpsc::channel::<String>(256);
793        let cancel = CancellationToken::new();
794        let cancel_task = cancel.clone();
795
796        tokio::spawn(async move {
797            // Buffers carry partial trailing lines across reads.
798            let mut stdout_buf = String::new();
799            let mut stderr_buf = String::new();
800            loop {
801                tokio::select! {
802                    _ = cancel_task.cancelled() => {
803                        let _ = channel.eof().await;
804                        let _ = channel.close().await;
805                        break;
806                    }
807                    msg = channel.wait() => {
808                        match msg {
809                            Some(ChannelMsg::Data { ref data }) => {
810                                stdout_buf.push_str(&String::from_utf8_lossy(data));
811                                while let Some(idx) = stdout_buf.find('\n') {
812                                    let line: String = stdout_buf.drain(..=idx).collect();
813                                    let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
814                                    if tx.send(trimmed).await.is_err() {
815                                        cancel_task.cancel();
816                                        break;
817                                    }
818                                }
819                            }
820                            Some(ChannelMsg::ExtendedData { ref data, .. }) => {
821                                stderr_buf.push_str(&String::from_utf8_lossy(data));
822                                while let Some(idx) = stderr_buf.find('\n') {
823                                    let line: String = stderr_buf.drain(..=idx).collect();
824                                    let trimmed = line.trim_end_matches(['\r', '\n']).to_string();
825                                    if tx.send(format!("!{}", trimmed)).await.is_err() {
826                                        cancel_task.cancel();
827                                        break;
828                                    }
829                                }
830                            }
831                            Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
832                                if !stdout_buf.is_empty() {
833                                    let _ = tx.send(stdout_buf.trim_end_matches(['\r', '\n']).to_string()).await;
834                                }
835                                if !stderr_buf.is_empty() {
836                                    let _ = tx.send(format!("!{}", stderr_buf.trim_end_matches(['\r', '\n']))).await;
837                                }
838                                break;
839                            }
840                            _ => {}
841                        }
842                    }
843                }
844            }
845        });
846
847        Ok((rx, cancel))
848    }
849
850    pub async fn disconnect(&mut self) -> Result<()> {
851        // Drop the cached SFTP session first so its channel shuts cleanly
852        // before we tear down the underlying SSH transport.
853        self.sftp.take();
854
855        if let Some(session) = self.session.take() {
856            match Arc::try_unwrap(session) {
857                Ok(session) => {
858                    if let Err(e) = session.disconnect(Disconnect::ByApplication, "", "").await {
859                        tracing::warn!("SSH disconnect failed cleanly: {}", e);
860                    }
861                }
862                Err(arc_session) => {
863                    // Other references (typically spawned PTY tasks) still
864                    // exist. Drop ours — the session ends when the last
865                    // reference dies.
866                    tracing::debug!("SSH disconnect: other refs still alive, dropping handle");
867                    drop(arc_session);
868                }
869            }
870        }
871        Ok(())
872    }
873
874    /// Open a `direct-tcpip` channel that the SSH server bridges to
875    /// `(host, port)` as seen from the server's network. Used by the
876    /// Postgres tunnel to forward a local TCP listener through this
877    /// SSH session.
878    ///
879    /// `originator_address`/`originator_port` are reported to the server
880    /// for logging/auditing and may be `127.0.0.1:0` when the caller
881    /// doesn't have a meaningful client endpoint to advertise.
882    pub async fn open_direct_tcpip(
883        &self,
884        host: &str,
885        port: u16,
886    ) -> Result<russh::Channel<russh::client::Msg>> {
887        let Some(session) = &self.session else {
888            return Err(anyhow::anyhow!("Not connected"));
889        };
890        let channel = session
891            .channel_open_direct_tcpip(host.to_string(), port as u32, "127.0.0.1", 0)
892            .await?;
893        Ok(channel)
894    }
895
896    /// Create a persistent PTY shell session (like ttyd)
897    /// This enables interactive commands like vim, less, more, top, etc.
898    pub async fn create_pty_session(&self, cols: u32, rows: u32) -> Result<PtySession> {
899        if let Some(session) = &self.session {
900            // Open a new SSH channel
901            let mut channel = session.channel_open_session().await?;
902
903            // Request PTY with terminal type and dimensions
904            // Similar to ttyd's approach: xterm-256color terminal
905            channel
906                .request_pty(
907                    true,             // want_reply
908                    "xterm-256color", // terminal type (like ttyd)
909                    cols,             // columns
910                    rows,             // rows
911                    0,                // pixel_width (not used)
912                    0,                // pixel_height (not used)
913                    &[],              // terminal modes
914                )
915                .await?;
916
917            // Start interactive shell
918            channel.request_shell(true).await?;
919
920            // Create channels for bidirectional communication (like ttyd's pty_buf)
921            // Increased capacity for better buffering during fast input
922            let (input_tx, mut input_rx) = mpsc::channel::<Vec<u8>>(1000); // Increased from 100
923            let (output_tx, output_rx) = mpsc::channel::<Vec<u8>>(2000); // Increased from 1000
924
925            // Clone channel for input task
926            let input_channel = channel.make_writer();
927
928            // Create a channel for resize requests
929            let (resize_tx, mut resize_rx) = mpsc::channel::<(u32, u32)>(16);
930
931            // The cancel token is created here and shared with both spawned
932            // tasks *and* returned in `PtySession`. When `close_pty_connection`
933            // cancels, every long-lived future on this session unblocks.
934            let cancel = CancellationToken::new();
935
936            // Spawn task to handle input (frontend → SSH)
937            // This is similar to ttyd's pty_write and INPUT command handling
938            // Key: immediate write + flush for responsiveness
939            let input_cancel = cancel.clone();
940            tokio::spawn(async move {
941                let mut writer = input_channel;
942                loop {
943                    tokio::select! {
944                        biased;
945                        _ = input_cancel.cancelled() => {
946                            tracing::debug!("[PTY] input task cancelled");
947                            break;
948                        }
949                        maybe_data = input_rx.recv() => {
950                            let Some(data) = maybe_data else {
951                                // sender dropped — session torn down
952                                break;
953                            };
954                            if let Err(e) = writer.write_all(&data).await {
955                                tracing::error!("[PTY] failed to send data to SSH: {}", e);
956                                break;
957                            }
958                            if let Err(e) = writer.flush().await {
959                                tracing::error!("[PTY] failed to flush data to SSH: {}", e);
960                                break;
961                            }
962                        }
963                    }
964                }
965            });
966
967            // Spawn task to handle output (SSH → frontend) AND resize requests.
968            // The channel must stay in this task because `wait()` requires `&mut self`,
969            // but we also need `window_change()` which only requires `&self`.
970            // We use `tokio::select!` to multiplex between output reading, resize,
971            // and cancellation. Without the cancel arm the task would outlive
972            // `close_pty_connection` until the remote side eventually closes the
973            // channel — see audit finding #7.
974            let output_cancel = cancel.clone();
975            tokio::spawn(async move {
976                loop {
977                    tokio::select! {
978                        biased;
979                        _ = output_cancel.cancelled() => {
980                            tracing::debug!("[PTY] output task cancelled");
981                            break;
982                        }
983                        msg = channel.wait() => {
984                            match msg {
985                                Some(ChannelMsg::Data { data })
986                                    if output_tx.send(data.to_vec()).await.is_err() =>
987                                {
988                                    break;
989                                }
990                                Some(ChannelMsg::ExtendedData { data, .. })
991                                    if output_tx.send(data.to_vec()).await.is_err() =>
992                                {
993                                    // stderr data (also send to output)
994                                    break;
995                                }
996                                Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
997                                    tracing::debug!("[PTY] channel closed");
998                                    break;
999                                }
1000                                Some(ChannelMsg::ExitStatus { exit_status }) => {
1001                                    tracing::info!("[PTY] process exited with status: {}", exit_status);
1002                                }
1003                                _ => {}
1004                            }
1005                        }
1006                        resize = resize_rx.recv() => {
1007                            match resize {
1008                                Some((cols, rows)) => {
1009                                    if let Err(e) = channel.window_change(cols, rows, 0, 0).await {
1010                                        tracing::error!("[PTY] failed to send window change: {}", e);
1011                                    } else {
1012                                        tracing::debug!("[PTY] window changed to {}x{}", cols, rows);
1013                                    }
1014                                }
1015                                None => {
1016                                    // resize channel closed, session is being torn down
1017                                    break;
1018                                }
1019                            }
1020                        }
1021                    }
1022                }
1023            });
1024
1025            Ok(PtySession {
1026                input_tx,
1027                output_rx: Arc::new(tokio::sync::Mutex::new(output_rx)),
1028                resize_tx,
1029                cancel,
1030            })
1031        } else {
1032            Err(anyhow::anyhow!("Not connected"))
1033        }
1034    }
1035
1036    pub async fn list_dir(&self, path: &str) -> Result<Vec<RemoteFileEntry>> {
1037        let sftp = self.sftp_session().await?;
1038        let entries = sftp
1039            .read_dir(path)
1040            .await
1041            .map_err(|e| anyhow::anyhow!("Failed to list directory '{}': {}", path, e))?;
1042
1043        let mut result = Vec::new();
1044        for entry in entries {
1045            let name = entry.file_name();
1046            if name == "." || name == ".." {
1047                continue;
1048            }
1049
1050            let attrs = entry.metadata();
1051            let size = attrs.size.unwrap_or(0);
1052            let mtime_secs = attrs.mtime.map(|t| t as i64);
1053            let modified = mtime_secs.map(format_unix_timestamp);
1054            let permissions = attrs.permissions.map(format_permissions);
1055            let owner = attrs.uid.map(|u| u.to_string());
1056            let group = attrs.gid.map(|g| g.to_string());
1057
1058            let file_type = if attrs.is_dir() {
1059                FileEntryType::Directory
1060            } else if attrs.is_symlink() {
1061                FileEntryType::Symlink
1062            } else {
1063                FileEntryType::File
1064            };
1065
1066            result.push(RemoteFileEntry {
1067                name,
1068                size,
1069                modified,
1070                modified_unix: mtime_secs,
1071                permissions,
1072                owner,
1073                group,
1074                file_type,
1075            });
1076        }
1077
1078        result.sort_by(|a, b| {
1079            let a_is_dir = matches!(a.file_type, FileEntryType::Directory);
1080            let b_is_dir = matches!(b.file_type, FileEntryType::Directory);
1081            b_is_dir
1082                .cmp(&a_is_dir)
1083                .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
1084        });
1085
1086        Ok(result)
1087    }
1088
1089    pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result<u64> {
1090        self.download_file_with_progress(remote_path, local_path, |_| {}, None)
1091            .await
1092    }
1093
1094    /// Stream a remote file to disk, calling `progress` after every SFTP
1095    /// chunk with the running total of bytes transferred. Caller is
1096    /// responsible for knowing the file's total size — use `list_dir` /
1097    /// the entry's `size` field beforehand.
1098    ///
1099    /// The callback runs synchronously inside the read loop, so it must
1100    /// be cheap. The macOS bridge uses it to emit `TransferProgress`
1101    /// events on the event bus; the real work happens on the consumer
1102    /// thread that drains the bus, not here.
1103    ///
1104    /// `cancel`, when supplied, is checked between chunks. On
1105    /// cancellation the partial local file is left on disk (callers can
1106    /// delete it on receipt of the `TransferCancelled` error if they
1107    /// want clean removal — leaving it lets a future "resume" feature
1108    /// pick up where we left off).
1109    pub async fn download_file_with_progress(
1110        &self,
1111        remote_path: &str,
1112        local_path: &str,
1113        mut progress: impl FnMut(u64),
1114        cancel: Option<&CancellationToken>,
1115    ) -> Result<u64> {
1116        let sftp = self.sftp_session().await?;
1117        let mut remote_file = sftp.open(remote_path).await?;
1118        let mut local_file = tokio::fs::File::create(local_path).await?;
1119
1120        let mut buf = vec![0u8; SFTP_CHUNK_SIZE];
1121        let mut total_bytes = 0u64;
1122        loop {
1123            if let Some(token) = cancel
1124                && token.is_cancelled()
1125            {
1126                return Err(anyhow::anyhow!("Transfer cancelled"));
1127            }
1128            let n = remote_file.read(&mut buf).await?;
1129            if n == 0 {
1130                break;
1131            }
1132            local_file.write_all(&buf[..n]).await?;
1133            total_bytes += n as u64;
1134            progress(total_bytes);
1135        }
1136        local_file.flush().await?;
1137
1138        Ok(total_bytes)
1139    }
1140
1141    pub async fn download_file_to_memory(&self, remote_path: &str) -> Result<Vec<u8>> {
1142        let sftp = self.sftp_session().await?;
1143        let mut remote_file = sftp.open(remote_path).await?;
1144
1145        let mut buffer = Vec::new();
1146        let mut temp_buf = vec![0u8; SFTP_CHUNK_SIZE];
1147        loop {
1148            let n = remote_file.read(&mut temp_buf).await?;
1149            if n == 0 {
1150                break;
1151            }
1152            buffer.extend_from_slice(&temp_buf[..n]);
1153        }
1154        Ok(buffer)
1155    }
1156
1157    pub async fn upload_file(&self, local_path: &str, remote_path: &str) -> Result<u64> {
1158        self.upload_file_with_progress(local_path, remote_path, |_| {}, None)
1159            .await
1160    }
1161
1162    /// Stream a local file to the remote, calling `progress` after every
1163    /// SFTP chunk. See `download_file_with_progress` for the threading
1164    /// constraints — the callback is on the same task as the SFTP I/O.
1165    ///
1166    /// `cancel` checks between chunks; on cancellation the partial
1167    /// remote file is left in place (call `delete_file` afterwards if
1168    /// clean removal is wanted).
1169    pub async fn upload_file_with_progress(
1170        &self,
1171        local_path: &str,
1172        remote_path: &str,
1173        mut progress: impl FnMut(u64),
1174        cancel: Option<&CancellationToken>,
1175    ) -> Result<u64> {
1176        let sftp = self.sftp_session().await?;
1177        let mut local_file = tokio::fs::File::open(local_path).await?;
1178        let mut remote_file = sftp.create(remote_path).await?;
1179
1180        let mut buf = vec![0u8; SFTP_CHUNK_SIZE];
1181        let mut total_bytes = 0u64;
1182        loop {
1183            if let Some(token) = cancel
1184                && token.is_cancelled()
1185            {
1186                return Err(anyhow::anyhow!("Transfer cancelled"));
1187            }
1188            let n = local_file.read(&mut buf).await?;
1189            if n == 0 {
1190                break;
1191            }
1192            remote_file.write_all(&buf[..n]).await?;
1193            total_bytes += n as u64;
1194            progress(total_bytes);
1195        }
1196        remote_file.flush().await?;
1197
1198        Ok(total_bytes)
1199    }
1200
1201    pub async fn upload_file_from_bytes(&self, data: &[u8], remote_path: &str) -> Result<u64> {
1202        let sftp = self.sftp_session().await?;
1203        let mut remote_file = sftp.create(remote_path).await?;
1204
1205        for chunk in data.chunks(SFTP_CHUNK_SIZE) {
1206            remote_file.write_all(chunk).await?;
1207        }
1208        remote_file.flush().await?;
1209
1210        Ok(data.len() as u64)
1211    }
1212
1213    /// Create a directory on the remote. Fails if the parent doesn't
1214    /// exist or the path is already taken.
1215    pub async fn create_dir(&self, path: &str) -> Result<()> {
1216        let sftp = self.sftp_session().await?;
1217        sftp.create_dir(path)
1218            .await
1219            .map_err(|e| anyhow::anyhow!("Failed to create directory '{}': {}", path, e))?;
1220        Ok(())
1221    }
1222
1223    /// Rename a file or directory. SFTP RENAME is atomic when source
1224    /// and destination are on the same filesystem; cross-filesystem
1225    /// renames may copy-then-delete depending on the server.
1226    pub async fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
1227        let sftp = self.sftp_session().await?;
1228        sftp.rename(old_path, new_path).await.map_err(|e| {
1229            anyhow::anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e)
1230        })?;
1231        Ok(())
1232    }
1233
1234    /// Delete a regular file. For directories, use `delete_dir`.
1235    pub async fn delete_file(&self, path: &str) -> Result<()> {
1236        let sftp = self.sftp_session().await?;
1237        sftp.remove_file(path)
1238            .await
1239            .map_err(|e| anyhow::anyhow!("Failed to delete file '{}': {}", path, e))?;
1240        Ok(())
1241    }
1242
1243    /// Delete an empty directory. SFTP requires the directory be empty;
1244    /// recursive removal would need a list-then-delete loop, which
1245    /// belongs at the caller layer (with progress reporting).
1246    pub async fn delete_dir(&self, path: &str) -> Result<()> {
1247        let sftp = self.sftp_session().await?;
1248        sftp.remove_dir(path)
1249            .await
1250            .map_err(|e| anyhow::anyhow!("Failed to delete directory '{}': {}", path, e))?;
1251        Ok(())
1252    }
1253}
1254
1255#[cfg(test)]
1256mod expand_home_tests {
1257    use super::expand_home_path;
1258
1259    #[test]
1260    fn returns_non_tilde_paths_unchanged() {
1261        assert_eq!(
1262            expand_home_path("/absolute/path").as_deref(),
1263            Some("/absolute/path")
1264        );
1265        assert_eq!(
1266            expand_home_path("relative/dir").as_deref(),
1267            Some("relative/dir")
1268        );
1269        assert_eq!(expand_home_path("").as_deref(), Some(""));
1270    }
1271
1272    #[test]
1273    fn expands_tilde_slash_prefix_when_home_is_known() {
1274        // We can't rely on a specific home_dir() value here, but we can assert
1275        // that the tilde is replaced and the suffix is preserved.
1276        let expanded = expand_home_path("~/.ssh/id_rsa");
1277        // When CI doesn't set HOME, dirs::home_dir may return None — tolerate both
1278        // outcomes and only assert the happy path.
1279        if let Some(result) = expanded {
1280            assert!(
1281                !result.starts_with("~/"),
1282                "tilde must be expanded: {}",
1283                result
1284            );
1285            assert!(
1286                result.ends_with("/.ssh/id_rsa"),
1287                "suffix preserved: {}",
1288                result
1289            );
1290        }
1291    }
1292}
1293
1294#[cfg(test)]
1295mod command_output_tests {
1296    use super::CommandOutput;
1297
1298    #[test]
1299    fn is_success_requires_zero_exit() {
1300        assert!(
1301            CommandOutput {
1302                stdout: "x".into(),
1303                stderr: "".into(),
1304                exit_code: Some(0),
1305            }
1306            .is_success()
1307        );
1308        assert!(
1309            !CommandOutput {
1310                stdout: "x".into(),
1311                stderr: "".into(),
1312                exit_code: Some(1),
1313            }
1314            .is_success()
1315        );
1316        assert!(
1317            !CommandOutput {
1318                stdout: "x".into(),
1319                stderr: "".into(),
1320                exit_code: None,
1321            }
1322            .is_success()
1323        );
1324    }
1325
1326    #[test]
1327    fn combined_merges_streams_with_separator() {
1328        let c = CommandOutput {
1329            stdout: "out".into(),
1330            stderr: "err".into(),
1331            exit_code: Some(0),
1332        };
1333        assert_eq!(c.combined(), "out\nerr");
1334    }
1335
1336    #[test]
1337    fn combined_preserves_trailing_newline() {
1338        let c = CommandOutput {
1339            stdout: "out\n".into(),
1340            stderr: "err".into(),
1341            exit_code: Some(0),
1342        };
1343        assert_eq!(c.combined(), "out\nerr");
1344    }
1345
1346    #[test]
1347    fn combined_returns_single_stream_when_other_empty() {
1348        assert_eq!(
1349            CommandOutput {
1350                stdout: "only".into(),
1351                stderr: "".into(),
1352                exit_code: Some(0),
1353            }
1354            .combined(),
1355            "only"
1356        );
1357        assert_eq!(
1358            CommandOutput {
1359                stdout: "".into(),
1360                stderr: "only-err".into(),
1361                exit_code: Some(1),
1362            }
1363            .combined(),
1364            "only-err"
1365        );
1366    }
1367}
1368
1369#[cfg(test)]
1370mod redaction_tests {
1371    use super::{AuthMethod, SshConfig};
1372
1373    #[test]
1374    fn debug_redacts_password() {
1375        let cfg = SshConfig {
1376            host: "h".into(),
1377            port: 22,
1378            username: "u".into(),
1379            auth_method: AuthMethod::Password {
1380                password: "super-secret-123".into(),
1381            },
1382        };
1383        let rendered = format!("{:?}", cfg);
1384        assert!(
1385            !rendered.contains("super-secret-123"),
1386            "password must not appear in Debug output: {}",
1387            rendered
1388        );
1389        assert!(rendered.contains("<redacted>"), "expected redaction marker");
1390    }
1391
1392    #[test]
1393    fn debug_redacts_passphrase() {
1394        let m = AuthMethod::PublicKey {
1395            key_path: "/tmp/id".into(),
1396            passphrase: Some("xyz-passphrase".into()),
1397        };
1398        let rendered = format!("{:?}", m);
1399        assert!(!rendered.contains("xyz-passphrase"));
1400        assert!(rendered.contains("<redacted>"));
1401        assert!(rendered.contains("/tmp/id"));
1402    }
1403
1404    #[test]
1405    fn debug_shows_none_when_no_passphrase() {
1406        let m = AuthMethod::PublicKey {
1407            key_path: "/tmp/id".into(),
1408            passphrase: None,
1409        };
1410        let rendered = format!("{:?}", m);
1411        assert!(rendered.contains("<none>"));
1412    }
1413}
1414
1415#[cfg(test)]
1416mod key_loading_tests {
1417    use super::load_private_key;
1418    use std::io::Write;
1419    use tempfile::NamedTempFile;
1420
1421    const TEST_OPENSSH_PRIVATE_KEY: &str = "\
1422-----BEGIN OPENSSH PRIVATE KEY-----\n\
1423b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n\
1424QyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYgAAAJgAIAxdACAM\n\
1425XQAAAAtzc2gtZWQyNTUxOQAAACCzPq7zfqLffKoBDe/eo04kH2XxtSmk9D7RQyf1xUqrYg\n\
1426AAAEC2BsIi0QwW2uFscKTUUXNHLsYX4FxlaSDSblbAj7WR7bM+rvN+ot98qgEN796jTiQf\n\
1427ZfG1KaT0PtFDJ/XFSqtiAAAAEHVzZXJAZXhhbXBsZS5jb20BAgMEBQ==\n\
1428-----END OPENSSH PRIVATE KEY-----\n";
1429
1430    #[test]
1431    fn load_private_key_reads_key_file_contents() {
1432        let mut key_file = NamedTempFile::new().expect("failed to create temp key file");
1433        key_file
1434            .write_all(TEST_OPENSSH_PRIVATE_KEY.as_bytes())
1435            .expect("failed to write temp key file");
1436
1437        let key = load_private_key(
1438            key_file
1439                .path()
1440                .to_str()
1441                .expect("temp key path must be valid UTF-8"),
1442            None,
1443        )
1444        .expect("expected key file to load successfully");
1445
1446        // russh 0.61: PrivateKey uses algorithm() → Algorithm instead of name() → &str.
1447        // Algorithm implements Display, producing the wire-format algorithm name.
1448        assert_eq!(key.algorithm().to_string(), "ssh-ed25519");
1449    }
1450}
1451
1452#[cfg(test)]
1453mod tests;