Skip to main content

ferrule_sql/
tunnel.rs

1//! SSH tunnel support — types and lifecycle.
2//!
3//! [`SshConfig`] is the validated output of merging profile keys and
4//! CLI flags. It is the type backends consume to set up a tunnel
5//! before opening their underlying connection.
6//!
7//! The russh-backed transport (session, channel, port forwarding,
8//! `TunneledConnection` wrapper) lives behind the `ssh` Cargo
9//! feature. The hybrid transport architecture is documented inline at
10//! `TunnelTransport`:
11//!
12//! - **`LocalListener`** — binds `127.0.0.1:0`, pumps bytes through
13//!   an SSH direct-tcpip channel. Used by every backend whose driver
14//!   does not expose a custom-stream injection API
15//!   (`mysql_async`, `tiberius`, `rusqlite`, `oracle`).
16//! - **`Stream`** — hands back a `TunnelStream` suitable for
17//!   `tokio_postgres::Config::connect_raw`. Avoids the local TCP hop
18//!   for Postgres specifically.
19
20/// Resolved SSH tunnel configuration.
21///
22/// All fields have their defaults filled in by the merge step in
23/// `ferrule-cli`, so consumers do not need to handle `Option`s or
24/// env-var lookups when this value reaches the tunnel layer.
25#[derive(Debug, Clone)]
26pub struct SshConfig {
27    /// SSH bastion hostname or IP.
28    pub host: String,
29    /// SSH server port. Defaulted to 22 by the merger when omitted.
30    pub port: u16,
31    /// SSH login username. Defaulted to `$USER` by the merger.
32    pub user: String,
33    /// Path to the SSH private key. `None` means resolve through the
34    /// key stack (`~/.ssh/id_ed25519`, `~/.ssh/id_rsa`, then
35    /// `SSH_AUTH_SOCK`) at connect time.
36    pub key_path: Option<String>,
37}
38
39#[cfg(feature = "ssh")]
40mod ssh_impl {
41    use super::SshConfig;
42    use secrecy::{ExposeSecret, SecretString};
43    use std::io;
44    use std::path::PathBuf;
45    use std::pin::Pin;
46    use std::sync::Arc;
47    use std::task::{Context, Poll};
48
49    /// Where the SSH session sources its private key from. The CLI's
50    /// resolution stack collapses `--ssh-key`, profile entries,
51    /// `FERRULE_<NAME>_SSH_KEY`, default identity files, and
52    /// `SSH_AUTH_SOCK` into one of these variants before reaching
53    /// `setup_tunnel`.
54    #[derive(Debug, Clone)]
55    pub enum KeySource {
56        /// A private key file on disk. The russh layer loads and (if
57        /// encrypted) decrypts it via [`russh::keys::load_secret_key`].
58        /// `None` means probe first and error if encrypted;
59        /// `Some` means attempt decryption with the provided passphrase.
60        File(PathBuf, Option<SecretString>),
61        /// SSH agent socket. The russh layer routes signing requests
62        /// through the agent at this socket path.
63        Agent(PathBuf),
64    }
65
66    /// Selects which transport `setup_tunnel` returns. See the
67    /// module-level docs for when to pick each.
68    #[derive(Debug, Clone, Copy)]
69    pub enum TunnelTransport {
70        /// Bind a local TCP listener; pump bytes through SSH.
71        LocalListener,
72        /// Hand back a [`TunnelStream`] for direct injection into a
73        /// driver that exposes a custom-stream API (Postgres only
74        /// today via `tokio_postgres::Config::connect_raw`).
75        Stream,
76    }
77
78    /// Errors raised by the tunnel layer.
79    ///
80    /// `From<russh::Error>` is required by the `russh::client::Handler`
81    /// associated `Error` bound, so the dedicated `Russh` variant is
82    /// the conversion target — `Session`/`Auth`/`Key`/`Channel` are
83    /// for diagnostics the tunnel layer raises itself.
84    #[derive(Debug, thiserror::Error)]
85    pub enum TunnelError {
86        /// Host key on file matches the server's advertised key.
87        #[error("The server key has changed at line {line}")]
88        HostKeyMismatch {
89            host: String,
90            port: u16,
91            line: usize,
92        },
93        /// Host not present in known_hosts — TOFU prompt required.
94        #[error(
95            "The authenticity of host '{host}:{port}' can't be established.\n\
96             {algorithm} key fingerprint is {fingerprint}."
97        )]
98        UnknownHost {
99            host: String,
100            port: u16,
101            algorithm: String,
102            fingerprint: String,
103            /// Boxed to keep `TunnelError` (and the `Result`s that carry
104            /// it) small now that the tunnel setup path is synchronous.
105            key: Box<russh::keys::ssh_key::PublicKey>,
106        },
107        #[error("SSH session error: {0}")]
108        Session(String),
109        #[error("SSH authentication failed: {0}")]
110        Auth(String),
111        #[error("SSH key load error: {0}")]
112        Key(String),
113        #[error("SSH channel error: {0}")]
114        Channel(String),
115        #[error("russh error: {0}")]
116        Russh(#[from] russh::Error),
117        #[error("I/O error: {0}")]
118        Io(#[from] io::Error),
119    }
120
121    /// Outcome of comparing a server public key against
122    /// `~/.ssh/known_hosts`.
123    pub enum HostKeyStatus {
124        /// Key matches an existing entry.
125        Match,
126        /// Host is present but the key differs (possible MITM).
127        Mismatch { line: usize },
128        /// Host is not present in known_hosts.
129        Unknown,
130    }
131
132    /// Check `host:port` against the user's `~/.ssh/known_hosts`.
133    pub fn check_host_key(
134        host: &str,
135        port: u16,
136        pubkey: &russh::keys::ssh_key::PublicKey,
137    ) -> Result<HostKeyStatus, TunnelError> {
138        match russh::keys::check_known_hosts(host, port, pubkey) {
139            Ok(true) => Ok(HostKeyStatus::Match),
140            Ok(false) => Ok(HostKeyStatus::Unknown),
141            Err(russh::keys::Error::KeyChanged { line }) => Ok(HostKeyStatus::Mismatch { line }),
142            Err(e) => Err(TunnelError::Session(format!(
143                "known_hosts check for {host}:{port}: {e}"
144            ))),
145        }
146    }
147
148    /// Write a host's public key into `~/.ssh/known_hosts` (TOFU).
149    pub fn learn_host_key(
150        host: &str,
151        port: u16,
152        pubkey: &russh::keys::ssh_key::PublicKey,
153    ) -> Result<(), TunnelError> {
154        russh::keys::known_hosts::learn_known_hosts(host, port, pubkey).map_err(|e| {
155            TunnelError::Session(format!(
156                "failed to write host key to ~/.ssh/known_hosts: {e}"
157            ))
158        })
159    }
160
161    /// `AsyncRead + AsyncWrite` wrapper around a russh direct-tcpip
162    /// channel. Suitable for feeding into
163    /// `tokio_postgres::Config::connect_raw`.
164    pub struct TunnelStream {
165        pub inner: russh::ChannelStream<russh::client::Msg>,
166    }
167
168    impl tokio::io::AsyncRead for TunnelStream {
169        fn poll_read(
170            self: Pin<&mut Self>,
171            cx: &mut Context<'_>,
172            buf: &mut tokio::io::ReadBuf<'_>,
173        ) -> Poll<io::Result<()>> {
174            Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
175        }
176    }
177
178    impl tokio::io::AsyncWrite for TunnelStream {
179        fn poll_write(
180            self: Pin<&mut Self>,
181            cx: &mut Context<'_>,
182            buf: &[u8],
183        ) -> Poll<io::Result<usize>> {
184            Pin::new(&mut self.get_mut().inner).poll_write(cx, buf)
185        }
186
187        fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
188            Pin::new(&mut self.get_mut().inner).poll_flush(cx)
189        }
190
191        fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
192            Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
193        }
194    }
195
196    /// Holds the russh session for the tunnel's lifetime. Dropping
197    /// this terminates the session and tears down all channels using
198    /// it — standard Rust ownership instead of an explicit close
199    /// protocol.
200    pub struct SshSession {
201        pub handle: std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
202    }
203
204    /// Outcome of `setup_tunnel`. The session is held alongside
205    /// the transport-specific resources so callers only need to
206    /// keep one value alive — when [`TunnelHandle`] drops, the SSH
207    /// session and (for path a) the forwarder task drop with it.
208    ///
209    /// [`SshSession`] is hoisted out of [`TunnelTransport`] (which
210    /// would otherwise carry it in both variants) to keep the
211    /// transport enum's variants small — `russh::client::Handle`
212    /// is hundreds of bytes and would trip
213    /// `clippy::large_enum_variant` if duplicated per variant.
214    pub struct TunnelHandle {
215        pub session: SshSession,
216        pub transport: TunnelTransportResult,
217    }
218
219    /// Transport-specific resources returned alongside the SSH
220    /// session.
221    pub enum TunnelTransportResult {
222        /// (a) Local TCP listener path. Point the existing driver at
223        /// `127.0.0.1:port`; `forwarder` pumps bytes between the
224        /// listener and a russh direct-tcpip channel.
225        LocalPort {
226            port: u16,
227            forwarder: tokio::task::JoinHandle<()>,
228        },
229        /// (b) Direct stream path. Hand `stream` to a driver that
230        /// accepts a pre-built `AsyncRead + AsyncWrite + Unpin + Send
231        /// + 'static` (Postgres via `connect_raw`).
232        ///
233        /// Boxed so the enum's variants stay roughly the same size —
234        /// `TunnelStream` wraps a `russh::ChannelStream` whose
235        /// internals (channels, JoinHandles) make it large compared
236        /// to the `LocalPort` variant.
237        Stream { stream: Box<TunnelStream> },
238    }
239
240    /// Wraps a backend `AsyncConnection` (the crate-private async driver
241    /// trait) plus the
242    /// SSH session (and, for the LocalListener transport, the
243    /// forwarder task) so the entire stack drops together.
244    ///
245    /// Why this is non-generic: the connect dispatcher returns
246    /// `Box<dyn AsyncConnection>` regardless of backend, so an outer
247    /// wrapper that already holds the inner as `Box<dyn
248    /// AsyncConnection>` saves us from adding a blanket `impl<C:
249    /// AsyncConnection> AsyncConnection for TunneledConnection<C>` and
250    /// the matching `impl<C> AsyncConnection for Box<C>` (which
251    /// `async_trait` doesn't synthesize).
252    pub struct TunneledConnection {
253        pub(crate) inner: Box<dyn crate::connection::AsyncConnection>,
254        /// Held for `Drop` only — lifetime guard for the SSH session.
255        pub(crate) session: SshSession,
256        /// `Some` for the LocalListener transport, `None` for the
257        /// Stream transport (Postgres feeds the stream directly into
258        /// `tokio_postgres::Connection`'s task, no separate
259        /// forwarder needed).
260        pub(crate) forwarder: Option<tokio::task::JoinHandle<()>>,
261    }
262
263    #[async_trait::async_trait]
264    impl crate::connection::AsyncConnection for TunneledConnection {
265        async fn execute(&mut self, sql: &str) -> Result<crate::ExecutionSummary, crate::SqlError> {
266            self.inner.execute(sql).await
267        }
268
269        async fn query(&mut self, sql: &str) -> Result<crate::QueryResult, crate::SqlError> {
270            self.inner.query(sql).await
271        }
272
273        /// Forward the streaming cursor to the inner (tunneled)
274        /// connection; the tunnel adds no row buffering of its own.
275        async fn query_stream(
276            &mut self,
277            sql: &str,
278        ) -> Result<(Vec<crate::ColumnInfo>, crate::BoxRowStream<'_>), crate::SqlError> {
279            self.inner.query_stream(sql).await
280        }
281
282        async fn execute_multi(
283            &mut self,
284            sql: &str,
285        ) -> Result<Vec<crate::StatementResult>, crate::SqlError> {
286            self.inner.execute_multi(sql).await
287        }
288
289        async fn ping(&mut self) -> Result<(), crate::SqlError> {
290            self.inner.ping().await
291        }
292
293        async fn list_tables(
294            &mut self,
295            schema: Option<&str>,
296        ) -> Result<Vec<String>, crate::SqlError> {
297            self.inner.list_tables(schema).await
298        }
299
300        async fn list_schemas(
301            &mut self,
302        ) -> Result<Vec<crate::connection::SchemaInfo>, crate::SqlError> {
303            self.inner.list_schemas().await
304        }
305
306        async fn describe_table(
307            &mut self,
308            schema: Option<&str>,
309            table: &str,
310        ) -> Result<crate::QueryResult, crate::SqlError> {
311            self.inner.describe_table(schema, table).await
312        }
313
314        async fn primary_key(
315            &mut self,
316            schema: Option<&str>,
317            table: &str,
318        ) -> Result<Vec<String>, crate::SqlError> {
319            self.inner.primary_key(schema, table).await
320        }
321
322        async fn list_foreign_keys(
323            &mut self,
324            schema: Option<&str>,
325        ) -> Result<Vec<crate::ForeignKey>, crate::SqlError> {
326            self.inner.list_foreign_keys(schema).await
327        }
328
329        async fn bulk_insert_rows(
330            &mut self,
331            target: crate::connection::BulkInsert<'_>,
332        ) -> Result<usize, crate::SqlError> {
333            self.inner.bulk_insert_rows(target).await
334        }
335    }
336
337    /// russh client handler.
338    ///
339    /// `check_server_key` compares the server's public key against
340    /// the user's `~/.ssh/known_hosts` via russh's native parser.
341    /// Match → silent accept; mismatch → fatal error; unknown →
342    /// `Err(TunnelError::UnknownHost)` so the CLI layer can prompt
343    /// for TOFU and retry.
344    pub struct ClientHandler {
345        pub host: String,
346        pub port: u16,
347    }
348
349    impl russh::client::Handler for ClientHandler {
350        type Error = TunnelError;
351
352        async fn check_server_key(
353            &mut self,
354            server_public_key: &russh::keys::ssh_key::PublicKey,
355        ) -> Result<bool, Self::Error> {
356            match check_host_key(&self.host, self.port, server_public_key)? {
357                HostKeyStatus::Match => Ok(true),
358                HostKeyStatus::Mismatch { line } => Err(TunnelError::HostKeyMismatch {
359                    host: self.host.clone(),
360                    port: self.port,
361                    line,
362                }),
363                HostKeyStatus::Unknown => {
364                    let fingerprint = server_public_key
365                        .fingerprint(russh::keys::ssh_key::HashAlg::Sha256)
366                        .to_string();
367                    Err(TunnelError::UnknownHost {
368                        host: self.host.clone(),
369                        port: self.port,
370                        algorithm: server_public_key.algorithm().to_string(),
371                        fingerprint,
372                        key: Box::new(server_public_key.clone()),
373                    })
374                }
375            }
376        }
377    }
378
379    /// Establish an SSH session and a direct-tcpip channel to
380    /// `target_host:target_port`. Returns a [`TunnelHandle`] whose
381    /// shape depends on `transport`.
382    ///
383    /// Auth flow:
384    /// - [`KeySource::File`] — load via [`russh::keys::load_secret_key`]
385    ///   (no passphrase support yet — encrypted keys error out with a
386    ///   diagnostic), then `authenticate_publickey`. RSA hash
387    ///   algorithm is auto-negotiated via
388    ///   [`russh::client::Handle::best_supported_rsa_hash`] and
389    ///   defaults to SHA-256 when the server doesn't advertise.
390    /// - [`KeySource::Agent`] — connect to the agent socket, request
391    ///   identities, try `authenticate_publickey_with` against each
392    ///   public key (skipping certificate identities for now) until
393    ///   one succeeds.
394    pub async fn setup_tunnel(
395        config: &SshConfig,
396        key_source: &KeySource,
397        target_host: &str,
398        target_port: u16,
399        transport: TunnelTransport,
400        proxy: Option<&crate::proxy::ProxyConfig>,
401    ) -> Result<TunnelHandle, TunnelError> {
402        use russh::client;
403        use russh::client::AuthResult;
404        use russh::keys::agent::AgentIdentity;
405        use russh::keys::agent::client::AgentClient;
406        use russh::keys::{HashAlg, PrivateKeyWithHashAlg, load_secret_key};
407
408        let cfg = Arc::new(client::Config::default());
409        let mut handle = if let Some(proxy) = proxy {
410            let proxy_stream = crate::proxy::http_connect(proxy, &config.host, config.port)
411                .await
412                .map_err(|e| TunnelError::Session(format!("proxy: {e}")))?;
413            client::connect_stream(
414                cfg,
415                proxy_stream,
416                ClientHandler {
417                    host: config.host.clone(),
418                    port: config.port,
419                },
420            )
421            .await?
422        } else {
423            client::connect(
424                cfg,
425                (config.host.as_str(), config.port),
426                ClientHandler {
427                    host: config.host.clone(),
428                    port: config.port,
429                },
430            )
431            .await
432            .map_err(|e| match e {
433                TunnelError::HostKeyMismatch { .. } | TunnelError::UnknownHost { .. } => e,
434                other => TunnelError::Session(format!(
435                    "connect to {}:{}: {}",
436                    config.host, config.port, other
437                )),
438            })?
439        };
440
441        // RSA hash auto-negotiation. Server's advertised value wins;
442        // fall back to SHA-256 (modern default) if the server didn't
443        // send `server-sig-algs`. ed25519 / ecdsa keys ignore this.
444        let rsa_hash = match handle.best_supported_rsa_hash().await {
445            Ok(Some(advertised)) => advertised,
446            Ok(None) | Err(_) => Some(HashAlg::Sha256),
447        };
448
449        match key_source {
450            KeySource::File(path, passphrase) => {
451                let key = load_secret_key(path, passphrase.as_ref().map(|s| s.expose_secret()))
452                    .map_err(|e| {
453                        TunnelError::Key(format!("load SSH key from {}: {}", path.display(), e))
454                    })?;
455                let auth = handle
456                    .authenticate_publickey(
457                        &config.user,
458                        PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash),
459                    )
460                    .await?;
461                if !auth.success() {
462                    return Err(TunnelError::Auth(format!(
463                        "publickey auth failed for user '{}' (server rejected key {})",
464                        config.user,
465                        path.display()
466                    )));
467                }
468            }
469            KeySource::Agent(sock_path) => {
470                let mut agent = AgentClient::connect_uds(sock_path).await.map_err(|e| {
471                    TunnelError::Auth(format!(
472                        "connect to SSH agent at {}: {}",
473                        sock_path.display(),
474                        e
475                    ))
476                })?;
477                let identities = agent
478                    .request_identities()
479                    .await
480                    .map_err(|e| TunnelError::Auth(format!("agent request_identities: {}", e)))?;
481                if identities.is_empty() {
482                    return Err(TunnelError::Auth(format!(
483                        "SSH agent at {} has no identities loaded",
484                        sock_path.display()
485                    )));
486                }
487                let mut authed = false;
488                let mut last_err: Option<String> = None;
489                for ident in &identities {
490                    let pk = match ident {
491                        AgentIdentity::PublicKey { key, .. } => key.clone(),
492                        // Certificate identities require a different
493                        // auth call (`authenticate_certificate_with`);
494                        // skip for now to keep commit B narrow.
495                        AgentIdentity::Certificate { .. } => continue,
496                    };
497                    match handle
498                        .authenticate_publickey_with(&config.user, pk, rsa_hash, &mut agent)
499                        .await
500                    {
501                        Ok(AuthResult::Success) => {
502                            authed = true;
503                            break;
504                        }
505                        Ok(AuthResult::Failure { .. }) => continue,
506                        Err(e) => {
507                            last_err = Some(format!("{:?}", e));
508                        }
509                    }
510                }
511                if !authed {
512                    return Err(TunnelError::Auth(format!(
513                        "agent publickey auth failed for user '{}' \
514                         (all {} identit{} rejected{})",
515                        config.user,
516                        identities.len(),
517                        if identities.len() == 1 { "y" } else { "ies" },
518                        last_err
519                            .map(|e| format!(": last error: {e}"))
520                            .unwrap_or_default(),
521                    )));
522                }
523            }
524        }
525
526        // Wrap the authenticated handle in Arc<Mutex<>> so the
527        // LocalListener forwarder can open fresh direct-tcpip
528        // channels for each accepted connection while the Stream
529        // path opens a single channel upfront.
530        let handle = Arc::new(tokio::sync::Mutex::new(handle));
531        let session = SshSession {
532            handle: Arc::clone(&handle),
533        };
534
535        match transport {
536            TunnelTransport::Stream => {
537                let channel = handle
538                    .lock()
539                    .await
540                    .channel_open_direct_tcpip(target_host, u32::from(target_port), "127.0.0.1", 0)
541                    .await
542                    .map_err(|e| {
543                        TunnelError::Channel(format!(
544                            "direct-tcpip to {}:{}: {}",
545                            target_host, target_port, e
546                        ))
547                    })?;
548                Ok(TunnelHandle {
549                    session,
550                    transport: TunnelTransportResult::Stream {
551                        stream: Box::new(TunnelStream {
552                            inner: channel.into_stream(),
553                        }),
554                    },
555                })
556            }
557            TunnelTransport::LocalListener => {
558                let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
559                let port = listener.local_addr()?.port();
560                let target_host = target_host.to_string();
561                let handle = Arc::clone(&handle);
562                let forwarder = tokio::spawn(async move {
563                    loop {
564                        let (mut tcp, _addr) = match listener.accept().await {
565                            Ok(pair) => pair,
566                            Err(e) => {
567                                eprintln!("[ferrule] SSH tunnel listener accept failed: {}", e);
568                                return;
569                            }
570                        };
571                        let handle = Arc::clone(&handle);
572                        let target_host = target_host.clone();
573                        tokio::spawn(async move {
574                            let guard = handle.lock().await;
575                            let channel = match guard
576                                .channel_open_direct_tcpip(
577                                    &target_host,
578                                    u32::from(target_port),
579                                    "127.0.0.1",
580                                    0,
581                                )
582                                .await
583                            {
584                                Ok(ch) => ch,
585                                Err(e) => {
586                                    eprintln!("[ferrule] SSH tunnel direct-tcpip failed: {}", e);
587                                    return;
588                                }
589                            };
590                            drop(guard);
591                            let mut ssh = channel.into_stream();
592                            if let Err(e) = tokio::io::copy_bidirectional(&mut tcp, &mut ssh).await
593                            {
594                                // Normal close is expected; don't spam stderr.
595                                let _ = e;
596                            }
597                        });
598                    }
599                });
600                Ok(TunnelHandle {
601                    session,
602                    transport: TunnelTransportResult::LocalPort { port, forwarder },
603                })
604            }
605        }
606    }
607
608    /// Probe whether an SSH private key file requires a passphrase.
609    ///
610    /// Returns `Ok(true)` if the key is encrypted, `Ok(false)` if it
611    /// loads without a passphrase, and `Err(TunnelError::Key(...))`
612    /// for I/O or parse errors.
613    pub fn ssh_key_needs_passphrase(
614        path: impl AsRef<std::path::Path>,
615    ) -> Result<bool, TunnelError> {
616        match russh::keys::load_secret_key(path.as_ref(), None) {
617            Ok(_) => Ok(false),
618            Err(russh::keys::Error::KeyIsEncrypted) => Ok(true),
619            Err(e) => Err(TunnelError::Key(format!(
620                "load SSH key from {}: {}",
621                path.as_ref().display(),
622                e
623            ))),
624        }
625    }
626}
627
628// Async tunnel-transport primitives are crate-internal: the public,
629// blocking connection API (`connect_with_tunnel`) is the only sanctioned
630// entry point, so embedders never await `setup_tunnel` directly.
631#[cfg(feature = "ssh")]
632pub(crate) use ssh_impl::setup_tunnel;
633#[cfg(feature = "ssh")]
634pub use ssh_impl::{
635    ClientHandler, KeySource, SshSession, TunnelError, TunnelHandle, TunnelStream, TunnelTransport,
636    TunnelTransportResult, TunneledConnection, check_host_key, learn_host_key,
637    ssh_key_needs_passphrase,
638};
639
640#[cfg(feature = "ssh")]
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn ssh_key_needs_passphrase_unencrypted() {
647        let path = std::path::PathBuf::from("/tmp/ferrule-test-unencrypted");
648        if path.exists() {
649            assert!(!ssh_key_needs_passphrase(&path).unwrap());
650        }
651    }
652
653    #[test]
654    fn ssh_key_needs_passphrase_encrypted() {
655        let path = std::path::PathBuf::from("/tmp/ferrule-test-encrypted");
656        if path.exists() {
657            assert!(ssh_key_needs_passphrase(&path).unwrap());
658        }
659    }
660}