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