Skip to main content

anvil_ssh/
session.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3// Updated 2026-04-12: added verified_fingerprint tracking for SFRS JSON output
4//! SSH session management (FR-1 through FR-5, FR-9 through FR-17).
5//!
6//! [`AnvilSession`] wraps a russh [`client::Handle`] and exposes the
7//! operations Gitway needs: connect, authenticate, exec, and close.
8//!
9//! Host-key verification is performed inside [`GitwayHandler::check_server_key`]
10//! using the fingerprints collected by [`crate::hostkey`].
11
12use std::borrow::Cow;
13use std::fmt;
14use std::sync::{Arc, Mutex};
15
16use russh::client;
17use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
18use russh::{cipher, kex, Disconnect, Preferred};
19
20use std::path::PathBuf;
21
22use crate::config::AnvilConfig;
23use crate::error::{AnvilError, AnvilErrorKind};
24use crate::hostkey;
25use crate::relay;
26use crate::ssh_config::StrictHostKeyChecking;
27
28// ── Handler ───────────────────────────────────────────────────────────────────
29
30/// russh client event handler.
31///
32/// Validates the server host key (FR-6, FR-7, FR-8) and captures any
33/// authentication banner the server sends before confirming the session.
34struct GitwayHandler {
35    /// Expected SHA-256 fingerprints for the target host.  May be empty
36    /// in [`StrictHostKeyChecking::AcceptNew`] mode for an unknown host
37    /// — the handler will record the first fingerprint it sees in that
38    /// case.
39    fingerprints: Vec<String>,
40    /// SHA-256 fingerprints explicitly revoked for this host (M14, FR-64).
41    /// Checked **before** the policy and fingerprint paths: a presented
42    /// key that hits one of these is rejected unconditionally — even
43    /// [`StrictHostKeyChecking::No`] cannot override a `@revoked`
44    /// entry.
45    revoked: Vec<String>,
46    /// Host-key verification policy (FR-8).
47    policy: StrictHostKeyChecking,
48    /// Hostname being connected to — needed by the
49    /// [`StrictHostKeyChecking::AcceptNew`] write path so the new
50    /// fingerprint line can be labelled with the right host.
51    host: String,
52    /// Path to the user-configured `known_hosts` file, if any.  Required
53    /// for [`StrictHostKeyChecking::AcceptNew`] writes; if `None`, the
54    /// handler downgrades to [`StrictHostKeyChecking::Yes`] semantics
55    /// with a warning.
56    custom_known_hosts: Option<PathBuf>,
57    /// Buffer for the last authentication banner received from the server.
58    ///
59    /// GitHub sends "Hi <user>! You've successfully authenticated…" here.
60    auth_banner: Arc<Mutex<Option<String>>>,
61    /// The SHA-256 fingerprint of the server key that passed verification.
62    ///
63    /// Set during `check_server_key`; exposed via
64    /// [`AnvilSession::verified_fingerprint`] for structured JSON output.
65    verified_fingerprint: Arc<Mutex<Option<String>>>,
66}
67
68impl fmt::Debug for GitwayHandler {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.debug_struct("GitwayHandler")
71            .field("fingerprints", &self.fingerprints)
72            .field("revoked", &self.revoked)
73            .field("policy", &self.policy)
74            .field("host", &self.host)
75            .field("custom_known_hosts", &self.custom_known_hosts)
76            .field("auth_banner", &self.auth_banner)
77            .field("verified_fingerprint", &self.verified_fingerprint)
78            .finish()
79    }
80}
81
82impl client::Handler for GitwayHandler {
83    type Error = AnvilError;
84
85    async fn check_server_key(
86        &mut self,
87        server_public_key: &russh::keys::ssh_key::PublicKey,
88    ) -> Result<bool, Self::Error> {
89        let fp = server_public_key.fingerprint(HashAlg::Sha256).to_string();
90        // `Algorithm::as_str` borrows from a temporary; convert to
91        // an owned String so the value lives for the structured
92        // tracing event below.
93        let alg = server_public_key.algorithm().as_str().to_owned();
94        // FR-66: structured event — host + fingerprint + algorithm
95        // surfaced under the `kex` category at trace level so a
96        // `gitway -vvv --debug-categories=kex` consumer sees the
97        // full host-key handshake without scraping log lines.
98        tracing::trace!(
99            target: crate::log::CAT_KEX,
100            host = %self.host,
101            fp = %fp,
102            alg = %alg,
103            "check_server_key entry",
104        );
105        log::debug!("session: checking server host key {fp}");
106
107        // M14 / FR-64: a `@revoked` entry beats every other policy —
108        // even `StrictHostKeyChecking::No` cannot override an explicit
109        // revocation.  This runs first so a compromised key can't be
110        // accepted via the insecure-skip path.
111        if self.revoked.iter().any(|r| r == &fp) {
112            tracing::warn!(
113                target: crate::log::CAT_AUTH,
114                host = %self.host,
115                fp = %fp,
116                verdict = "revoked",
117                "host key in @revoked list",
118            );
119            return Err(AnvilError::host_key_mismatch(fp.clone()).with_hint(format!(
120                "{fp} is listed in a @revoked entry for {} in the known_hosts \
121                 file (M14, FR-64). Refusing the connection unconditionally — \
122                 the key has been explicitly blocklisted. Remove the @revoked \
123                 line if the revocation was a mistake, or rotate the upstream \
124                 host key.",
125                self.host,
126            )));
127        }
128
129        // StrictHostKeyChecking=No: accept any key.  Equivalent to the
130        // 0.2.x `--insecure-skip-host-check` path.  Reached only after
131        // the `@revoked` check above.
132        if matches!(self.policy, StrictHostKeyChecking::No) {
133            tracing::warn!(
134                target: crate::log::CAT_AUTH,
135                host = %self.host,
136                fp = %fp,
137                verdict = "skipped",
138                "host-key verification skipped (StrictHostKeyChecking=No)",
139            );
140            log::warn!("host-key verification skipped (StrictHostKeyChecking=No)");
141            if let Ok(mut guard) = self.verified_fingerprint.lock() {
142                *guard = Some(fp);
143            }
144            return Ok(true);
145        }
146
147        // Match against the pinned/known set first.  This path is
148        // identical for `Yes` and `AcceptNew`: a verified existing
149        // fingerprint always passes.
150        if self.fingerprints.iter().any(|f| f == &fp) {
151            tracing::debug!(
152                target: crate::log::CAT_AUTH,
153                host = %self.host,
154                fp = %fp,
155                verdict = "verified",
156                "host key matches pinned fingerprint",
157            );
158            log::debug!("session: host key verified: {fp}");
159            if let Ok(mut guard) = self.verified_fingerprint.lock() {
160                *guard = Some(fp);
161            }
162            return Ok(true);
163        }
164
165        // No match.  In `AcceptNew` mode with a fully-unknown host (no
166        // existing fingerprints at all) AND a writable
167        // `custom_known_hosts` path, record the new fingerprint and
168        // accept.  Any other case is a hard mismatch.
169        if matches!(self.policy, StrictHostKeyChecking::AcceptNew) && self.fingerprints.is_empty() {
170            if let Some(path) = &self.custom_known_hosts {
171                hostkey::append_known_host(path, &self.host, &fp)?;
172                tracing::info!(
173                    target: crate::log::CAT_AUTH,
174                    host = %self.host,
175                    fp = %fp,
176                    path = %path.display(),
177                    verdict = "accepted_new",
178                    "host-key first-use accepted (AcceptNew)",
179                );
180                log::info!(
181                    "host-key first-use accepted: {} -> {} (recorded in {})",
182                    self.host,
183                    fp,
184                    path.display(),
185                );
186                if let Ok(mut guard) = self.verified_fingerprint.lock() {
187                    *guard = Some(fp);
188                }
189                return Ok(true);
190            }
191            log::warn!(
192                "StrictHostKeyChecking=accept-new requested but no \
193                 custom_known_hosts path is set; downgrading to Yes \
194                 semantics for {}",
195                self.host,
196            );
197        }
198
199        tracing::warn!(
200            target: crate::log::CAT_AUTH,
201            host = %self.host,
202            fp = %fp,
203            verdict = "mismatch",
204            "host-key fingerprint did not match any pinned entry",
205        );
206        Err(AnvilError::host_key_mismatch(fp))
207    }
208
209    async fn auth_banner(
210        &mut self,
211        banner: &str,
212        _session: &mut client::Session,
213    ) -> Result<(), Self::Error> {
214        let trimmed = banner.trim().to_owned();
215        log::info!("server banner: {banner}");
216        if let Ok(mut guard) = self.auth_banner.lock() {
217            *guard = Some(trimmed);
218        }
219        Ok(())
220    }
221}
222
223// ── Session ───────────────────────────────────────────────────────────────────
224
225/// An active SSH session connected to a GitHub (or GHE) host.
226///
227/// # Typical Usage
228///
229/// ```no_run
230/// use anvil_ssh::{AnvilConfig, AnvilSession};
231///
232/// # async fn doc() -> Result<(), anvil_ssh::AnvilError> {
233/// let config = AnvilConfig::github();
234/// let mut session = AnvilSession::connect(&config).await?;
235/// // authenticate, exec, close…
236/// # Ok(())
237/// # }
238/// ```
239pub struct AnvilSession {
240    handle: client::Handle<GitwayHandler>,
241    /// Authentication banner received from the server, if any.
242    auth_banner: Arc<Mutex<Option<String>>>,
243    /// SHA-256 fingerprint of the server key that passed verification, if any.
244    verified_fingerprint: Arc<Mutex<Option<String>>>,
245    /// Per-attempt history captured by [`crate::retry::run`] during
246    /// the connect path (M18, FR-83).  Empty when the first attempt
247    /// succeeded; otherwise one entry per failed attempt that
248    /// triggered a retry, plus the final attempt that succeeded.
249    /// Surfaced via [`Self::retry_history`] for the
250    /// `gitway --test --json` envelope.
251    retry_history: Vec<crate::retry::RetryAttempt>,
252}
253
254/// Manual Debug impl because `client::Handle<H>` does not implement `Debug`.
255impl fmt::Debug for AnvilSession {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        f.debug_struct("AnvilSession").finish_non_exhaustive()
258    }
259}
260
261/// The pre-handshake state every constructor on [`AnvilSession`]
262/// builds before driving russh.  Factoring it out keeps `connect`,
263/// `connect_via_proxy_command`, and `connect_via_jump_hosts` (M13.4)
264/// in lock-step on host-key handling and the `auth_banner` /
265/// `verified_fingerprint` mutexes the public getters expose.
266struct HandlerPieces {
267    russh_cfg: Arc<client::Config>,
268    handler: GitwayHandler,
269    auth_banner: Arc<Mutex<Option<String>>>,
270    verified_fingerprint: Arc<Mutex<Option<String>>>,
271}
272
273impl AnvilSession {
274    // ── Construction ─────────────────────────────────────────────────────────
275
276    /// Builds the russh config + handler used by every constructor.
277    ///
278    /// Centralises host-key fingerprint lookup (with the
279    /// [`StrictHostKeyChecking::AcceptNew`] tolerance for unknown hosts
280    /// when a writable `custom_known_hosts` path is set) and the shared
281    /// `auth_banner` / `verified_fingerprint` mutex pair.
282    fn build_handler_pieces(config: &AnvilConfig) -> Result<HandlerPieces, AnvilError> {
283        let russh_cfg = Arc::new(build_russh_config(config));
284        // M14: pull the trust view (direct fingerprints + revoked
285        // entries) in one pass.  For
286        // `StrictHostKeyChecking::AcceptNew` with a writable
287        // `custom_known_hosts` path an empty fingerprint set is
288        // tolerated — the handler will record the first fingerprint
289        // it sees.  Every other policy (Yes / No) treats a fully-
290        // empty trust set as fatal, with the long-form hint copied
291        // from `fingerprints_for_host`.
292        let trust = hostkey::host_key_trust(&config.host, &config.custom_known_hosts)?;
293        let revoked: Vec<String> = trust.revoked.into_iter().map(|r| r.fingerprint).collect();
294
295        let fingerprints = if !trust.fingerprints.is_empty() {
296            trust.fingerprints
297        } else if matches!(
298            config.strict_host_key_checking,
299            StrictHostKeyChecking::AcceptNew
300        ) && config.custom_known_hosts.is_some()
301        {
302            log::info!(
303                "session: no fingerprints known for {}; \
304                 accept-new will record on first connection",
305                config.host,
306            );
307            Vec::new()
308        } else {
309            return Err(AnvilError::invalid_config(format!(
310                "no fingerprints known for host '{}'",
311                config.host
312            ))
313            .with_hint(format!(
314                "Gitway refuses to connect to hosts whose SSH fingerprint it can't \
315                         verify (no trust-on-first-use). Either you typed the hostname wrong, \
316                         or this is a self-hosted server and you need to pin its fingerprint: \
317                         fetch it from the provider's docs (GitHub, GitLab, Codeberg publish \
318                         them) and append one line to ~/.config/gitway/known_hosts:\n\
319                         \n\
320                             {} SHA256:<base64-fingerprint>\n\
321                         \n\
322                         As a last resort, re-run with --insecure-skip-host-check (not \
323                         recommended — this disables MITM protection).",
324                config.host,
325            )));
326        };
327
328        let auth_banner = Arc::new(Mutex::new(None));
329        let verified_fingerprint = Arc::new(Mutex::new(None));
330
331        let handler = GitwayHandler {
332            fingerprints,
333            revoked,
334            policy: config.strict_host_key_checking,
335            host: config.host.clone(),
336            custom_known_hosts: config.custom_known_hosts.clone(),
337            auth_banner: Arc::clone(&auth_banner),
338            verified_fingerprint: Arc::clone(&verified_fingerprint),
339        };
340
341        Ok(HandlerPieces {
342            russh_cfg,
343            handler,
344            auth_banner,
345            verified_fingerprint,
346        })
347    }
348
349    /// Establishes a TCP connection to the host in `config` and completes the
350    /// SSH handshake (including host-key verification).
351    ///
352    /// Does **not** authenticate; call [`authenticate`](Self::authenticate) or
353    /// [`authenticate_best`](Self::authenticate_best) after this.
354    ///
355    /// **M18 / FR-80 / FR-81 / FR-82**: the TCP connect is wrapped in
356    /// a [`crate::retry::run`] loop with per-attempt
357    /// [`tokio::time::timeout`] (when `config.connect_timeout` is
358    /// `Some`).  Transient failures (`ECONNREFUSED`, `ETIMEDOUT`,
359    /// DNS NXDOMAIN, …) are retried with jittered exponential
360    /// backoff; auth / host-key / protocol errors are fatal and
361    /// surface immediately.  See [`Self::retry_history`] for the
362    /// per-attempt history captured during the loop.
363    ///
364    /// # Errors
365    ///
366    /// Returns an error on terminal network failure (after exhausting
367    /// the retry budget) or if the server's host key does not
368    /// match any pinned fingerprint (fatal — never retried).
369    pub async fn connect(config: &AnvilConfig) -> Result<Self, AnvilError> {
370        let policy = retry_policy_from_config(config);
371
372        log::debug!("session: connecting to {}:{}", config.host, config.port);
373
374        // Each attempt rebuilds `pieces` because russh's
375        // `client::connect` consumes the handler.  This is cheap —
376        // `build_handler_pieces` is pure setup, no I/O.
377        let ((handle, pieces), retry_history) = crate::retry::run(&policy, || async {
378            let pieces = Self::build_handler_pieces(config)?;
379            let connect_fut = client::connect(
380                pieces.russh_cfg,
381                (config.host.as_str(), config.port),
382                pieces.handler,
383            );
384            let handle = match policy.connect_timeout {
385                Some(t) => match tokio::time::timeout(t, connect_fut).await {
386                    Ok(Ok(h)) => h,
387                    Ok(Err(e)) => return Err(e),
388                    Err(_elapsed) => {
389                        return Err(AnvilError::new(crate::error::AnvilErrorKind::Io(
390                            std::io::Error::from(std::io::ErrorKind::TimedOut),
391                        )));
392                    }
393                },
394                None => connect_fut.await?,
395            };
396            Ok((
397                handle,
398                ConnectArtifacts {
399                    auth_banner: pieces.auth_banner,
400                    verified_fingerprint: pieces.verified_fingerprint,
401                },
402            ))
403        })
404        .await?;
405
406        log::debug!("session: SSH handshake complete with {}", config.host);
407
408        Ok(Self {
409            handle,
410            auth_banner: pieces.auth_banner,
411            verified_fingerprint: pieces.verified_fingerprint,
412            retry_history,
413        })
414    }
415
416    /// Returns the [`crate::retry::RetryAttempt`] history captured
417    /// during the most-recent `connect*` call on this session.
418    ///
419    /// Empty when the first attempt succeeded.  Non-empty when the
420    /// retry loop fired at least once; the last entry's `attempt`
421    /// matches the attempt number that ultimately succeeded.
422    /// Surfaced by `gitway --test --json`'s `data.retry_attempts`
423    /// envelope (FR-83).
424    #[must_use]
425    pub fn retry_history(&self) -> &[crate::retry::RetryAttempt] {
426        &self.retry_history
427    }
428
429    /// Establishes the SSH session through a chain of `ProxyJump`
430    /// bastion hops (FR-56).
431    ///
432    /// For each hop in `jumps`:
433    ///
434    /// 1. Build a per-hop [`AnvilConfig`] from the [`JumpHost`] fields,
435    ///    inheriting `strict_host_key_checking`, `custom_known_hosts`,
436    ///    and `verbose` from the primary `config`.  Per-hop user and
437    ///    `identity_files` come from the [`JumpHost`] when set, else
438    ///    from the primary config.
439    /// 2. Connect: the *first* hop uses [`russh::client::connect`] over
440    ///    TCP; subsequent hops use the *previous* hop's
441    ///    `direct-tcpip` channel as the underlying transport via
442    ///    [`russh::client::connect_stream`].
443    /// 3. Run host-key verification — every hop runs the full
444    ///    [`GitwayHandler::check_server_key`] path independently
445    ///    (NFR-17: failure at hop `n+1` aborts the entire chain;
446    ///    no partial-success path).
447    /// 4. Authenticate the hop with [`AnvilSession::authenticate_best`]
448    ///    so the chain can open `direct-tcpip` to the next hop.
449    ///
450    /// After the loop, the *last* bastion's handle is used to open
451    /// `direct-tcpip` to the primary `config.host` / `config.port`,
452    /// and the resulting [`ChannelStream`] becomes the SSH transport
453    /// for the final session this method returns.
454    ///
455    /// # Per-hop `ssh_config`
456    ///
457    /// This method does NOT re-resolve `ssh_config` per hop — that
458    /// requires the caller's [`SshConfigPaths`], which the session
459    /// module deliberately does not depend on.  The CLI dispatcher
460    /// (M13.6) is responsible for populating
461    /// [`JumpHost::identity_files`] (and any other per-hop overrides)
462    /// from per-hop [`crate::ssh_config::resolve`] calls before
463    /// invoking this method.
464    ///
465    /// # Errors
466    /// Returns the first error encountered.  An empty `jumps` slice is
467    /// rejected with a clear message — callers should use
468    /// [`Self::connect`] when no chain is in play.  Authentication
469    /// failures at any intermediate hop terminate the whole chain.
470    /// `ChannelStream`-based transport errors propagate via the
471    /// usual russh / [`AnvilError`] mapping.
472    ///
473    /// # Panics
474    /// Does not panic.  An internal `expect` fires only on a logic bug
475    /// (the empty-`jumps` check at the top of the function would have
476    /// already returned).
477    #[allow(
478        clippy::too_many_lines,
479        reason = "Single multi-step async chain orchestrator for per-hop connect / auth / direct-tcpip; extracting helpers would just shuffle the same logic across short fns and obscure the read-flow. M15.2 added 12 lines of FR-66 instrumentation — splitting here is a future cleanup, not an M15.2 concern."
480    )]
481    pub async fn connect_via_jump_hosts(
482        config: &AnvilConfig,
483        jumps: &[crate::proxy::JumpHost],
484    ) -> Result<Self, AnvilError> {
485        if jumps.is_empty() {
486            return Err(AnvilError::invalid_config(
487                "ProxyJump: empty jump-host list; call AnvilSession::connect instead",
488            ));
489        }
490
491        // FR-66 (channel category): one structured "chain start" event so
492        // a `gitway -vvv --debug-categories=channel` consumer can see the
493        // chain shape before the per-hop events fire.
494        tracing::debug!(
495            target: crate::log::CAT_CHANNEL,
496            target_host = %config.host,
497            target_port = config.port,
498            hop_count = jumps.len(),
499            "ProxyJump chain start",
500        );
501        log::debug!(
502            "session: connecting to {}:{} via {} bastion hop(s)",
503            config.host,
504            config.port,
505            jumps.len(),
506        );
507
508        let mut prev_handle: Option<client::Handle<GitwayHandler>> = None;
509
510        for (idx, hop) in jumps.iter().enumerate() {
511            let hop_config = jump_to_config(hop, config);
512            let pieces = Self::build_handler_pieces(&hop_config)?;
513
514            // FR-66: per-hop "connecting" event under the channel
515            // category, with hop index + target so the chain can be
516            // reconstructed from the JSONL stream.
517            tracing::debug!(
518                target: crate::log::CAT_CHANNEL,
519                hop_index = idx + 1,
520                hop_total = jumps.len(),
521                hop_host = %hop.host,
522                hop_port = hop.port,
523                "ProxyJump hop connecting",
524            );
525            log::debug!(
526                "session: bastion hop {}/{}: connecting to {}:{}",
527                idx + 1,
528                jumps.len(),
529                hop.host,
530                hop.port,
531            );
532
533            let handle = match prev_handle.take() {
534                None => {
535                    // First hop: regular TCP connect.
536                    client::connect(
537                        pieces.russh_cfg,
538                        (hop.host.as_str(), hop.port),
539                        pieces.handler,
540                    )
541                    .await?
542                }
543                Some(prev) => {
544                    // Subsequent hop: open `direct-tcpip` on the
545                    // previous bastion, treat the channel as the
546                    // transport for the next session.
547                    let channel = prev
548                        .channel_open_direct_tcpip(
549                            hop.host.clone(),
550                            u32::from(hop.port),
551                            "127.0.0.1",
552                            0_u32,
553                        )
554                        .await?;
555                    client::connect_stream(pieces.russh_cfg, channel.into_stream(), pieces.handler)
556                        .await?
557                }
558            };
559
560            // Authenticate this bastion so we can open the next hop's
561            // direct-tcpip channel through it.  Wrap in a temporary
562            // AnvilSession to reuse the existing auth surface.
563            let mut hop_session = Self {
564                handle,
565                auth_banner: pieces.auth_banner,
566                verified_fingerprint: pieces.verified_fingerprint,
567                // Per-hop retry not yet wired through M18.2 — see
568                // M18.2 commit body.  Empty history; consumers
569                // reading retry_history() see only the primary
570                // connect's attempts.
571                retry_history: Vec::new(),
572            };
573            hop_session
574                .authenticate_best(&hop_config)
575                .await
576                .map_err(|e| {
577                    e.with_hint(format!(
578                        "ProxyJump: authentication failed at bastion hop {}/{} ({}:{})",
579                        idx + 1,
580                        jumps.len(),
581                        hop.host,
582                        hop.port,
583                    ))
584                })?;
585
586            prev_handle = Some(hop_session.handle);
587        }
588
589        // Final hop: open `direct-tcpip` from the last bastion to the
590        // target, run the SSH handshake over that channel.
591        let prev = prev_handle
592            .expect("loop body ran at least once because jumps is non-empty (checked above)");
593
594        let target_pieces = Self::build_handler_pieces(config)?;
595
596        log::debug!(
597            "session: connecting to target {}:{} via last bastion",
598            config.host,
599            config.port,
600        );
601
602        let channel = prev
603            .channel_open_direct_tcpip(
604                config.host.clone(),
605                u32::from(config.port),
606                "127.0.0.1",
607                0_u32,
608            )
609            .await?;
610        let final_handle = client::connect_stream(
611            target_pieces.russh_cfg,
612            channel.into_stream(),
613            target_pieces.handler,
614        )
615        .await?;
616
617        log::debug!(
618            "session: SSH handshake complete with {} (via {} bastion hop(s))",
619            config.host,
620            jumps.len(),
621        );
622
623        Ok(Self {
624            handle: final_handle,
625            auth_banner: target_pieces.auth_banner,
626            verified_fingerprint: target_pieces.verified_fingerprint,
627            // M18.2: ProxyJump chain doesn't yet have retry wired
628            // for the per-hop connects (each hop's
629            // direct-tcpip channel makes the retry semantics
630            // murkier).  Documented as scoped-out in the M18.2
631            // commit body; deferred to a follow-up.
632            retry_history: Vec::new(),
633        })
634    }
635
636    /// Establishes the SSH session over a child process spawned from a
637    /// `ProxyCommand` template (FR-55).
638    ///
639    /// `proxy_command_template` is the raw template (typically from
640    /// [`crate::ssh_config::ResolvedSshConfig::proxy_command`] or a CLI
641    /// override).  `%h`, `%p`, `%r`, `%n`, and `%%` are expanded against
642    /// `config.host`, `config.port`, `config.username`, and `alias`
643    /// respectively before the platform shell (`sh -c` / `cmd /C`)
644    /// spawns the command.  The child's stdin/stdout become the SSH
645    /// transport via [`russh::client::connect_stream`].
646    ///
647    /// `alias` is the original argument the user typed before
648    /// `HostName` resolution — it powers the `%n` token.  Pass
649    /// `config.host` if you do not track the alias separately.
650    ///
651    /// The literal value `"none"` (case-insensitive) is recognized as
652    /// the FR-59 disable sentinel: this method returns an error
653    /// directing the caller to use [`Self::connect`] instead.  In
654    /// practice the caller's dispatcher should never invoke this
655    /// method in that case, but the guard keeps the spawn path safe
656    /// against accidental "none" input.
657    ///
658    /// # Errors
659    /// Returns an error on shell-spawn failure, on a host-key
660    /// mismatch, or on any russh handshake failure.
661    pub async fn connect_via_proxy_command(
662        config: &AnvilConfig,
663        proxy_command_template: &str,
664        alias: &str,
665    ) -> Result<Self, AnvilError> {
666        if proxy_command_template.eq_ignore_ascii_case("none") {
667            return Err(AnvilError::invalid_config(
668                "ProxyCommand=none is the disable sentinel; \
669                 call AnvilSession::connect instead",
670            ));
671        }
672
673        let pieces = Self::build_handler_pieces(config)?;
674
675        log::debug!(
676            "session: connecting to {} via ProxyCommand template `{proxy_command_template}`",
677            config.host,
678        );
679
680        let stream = crate::proxy::command::spawn_proxy_command(
681            proxy_command_template,
682            &config.host,
683            config.port,
684            &config.username,
685            alias,
686        )?;
687
688        let handle = client::connect_stream(pieces.russh_cfg, stream, pieces.handler).await?;
689
690        log::debug!(
691            "session: SSH handshake complete with {} (via ProxyCommand)",
692            config.host,
693        );
694
695        Ok(Self {
696            handle,
697            auth_banner: pieces.auth_banner,
698            verified_fingerprint: pieces.verified_fingerprint,
699            // M18.2: ProxyCommand connect path doesn't yet have
700            // retry wired (the subprocess lifecycle complicates
701            // re-spawning per attempt).  Documented as scoped-out
702            // in the M18.2 commit body; deferred to a follow-up.
703            retry_history: Vec::new(),
704        })
705    }
706
707    // ── Authentication ────────────────────────────────────────────────────────
708
709    /// Authenticates with an explicit key.
710    ///
711    /// Use [`authenticate_best`] to let the library discover the key
712    /// automatically.
713    ///
714    /// # Errors
715    ///
716    /// Returns an error on SSH protocol failures.  Returns
717    /// [`AnvilError::is_authentication_failed`] when the server accepts the
718    /// exchange but rejects the key.
719    pub async fn authenticate(
720        &mut self,
721        username: &str,
722        key: PrivateKeyWithHashAlg,
723    ) -> Result<(), AnvilError> {
724        // FR-66: capture algorithm + fingerprint of the key being
725        // tried before handing it to russh so the structured event
726        // names exactly which identity was attempted, not just a
727        // generic "authenticating" line.
728        let alg = key.algorithm().as_str().to_owned();
729        let fp = key.public_key().fingerprint(HashAlg::Sha256).to_string();
730        tracing::debug!(
731            target: crate::log::CAT_AUTH,
732            user = %username,
733            alg = %alg,
734            fp = %fp,
735            "trying public-key authentication",
736        );
737        log::debug!("session: authenticating as {username}");
738
739        let result = self.handle.authenticate_publickey(username, key).await?;
740
741        if result.success() {
742            tracing::info!(
743                target: crate::log::CAT_AUTH,
744                user = %username,
745                alg = %alg,
746                fp = %fp,
747                verdict = "accepted",
748                "public-key authentication succeeded",
749            );
750            log::debug!("session: authentication succeeded for {username}");
751            Ok(())
752        } else {
753            tracing::warn!(
754                target: crate::log::CAT_AUTH,
755                user = %username,
756                alg = %alg,
757                fp = %fp,
758                verdict = "rejected",
759                "public-key authentication rejected",
760            );
761            Err(AnvilError::authentication_failed())
762        }
763    }
764
765    /// Authenticates with a private key and an accompanying OpenSSH certificate
766    /// (FR-12).
767    ///
768    /// The certificate is presented to the server in place of the raw public
769    /// key.  This is typically used with organisation-issued certificates that
770    /// grant access without requiring the public key to be listed in
771    /// `authorized_keys`.
772    ///
773    /// # Errors
774    ///
775    /// Returns an error on SSH protocol failures or if the server rejects the
776    /// certificate.
777    pub async fn authenticate_with_cert(
778        &mut self,
779        username: &str,
780        key: russh::keys::PrivateKey,
781        cert: russh::keys::Certificate,
782    ) -> Result<(), AnvilError> {
783        log::debug!("session: authenticating as {username} with OpenSSH certificate");
784
785        let result = self
786            .handle
787            .authenticate_openssh_cert(username, Arc::new(key), cert)
788            .await?;
789
790        if result.success() {
791            log::debug!("session: certificate authentication succeeded for {username}");
792            Ok(())
793        } else {
794            Err(AnvilError::authentication_failed())
795        }
796    }
797
798    /// Discovers the best available key and authenticates using it.
799    ///
800    /// Priority order (FR-9):
801    /// 1. Explicit `--identity` path from config.
802    /// 2. Default `.ssh` paths (`id_ed25519` → `id_ecdsa` → `id_rsa`).
803    /// 3. SSH agent via `$SSH_AUTH_SOCK` (Unix only).
804    ///
805    /// If a certificate path is configured in `config.cert_file`, certificate
806    /// authentication (FR-12) is used instead of raw public-key authentication
807    /// for file-based keys.
808    ///
809    /// When the chosen key requires a passphrase this method returns an error
810    /// whose [`is_key_encrypted`](AnvilError::is_key_encrypted) predicate is
811    /// `true`; the caller (CLI layer) should then prompt and call
812    /// [`authenticate_with_passphrase`](Self::authenticate_with_passphrase).
813    ///
814    /// # Errors
815    ///
816    /// Returns [`AnvilError::is_no_key_found`] when no key is available via
817    /// any discovery method.
818    pub async fn authenticate_best(&mut self, config: &AnvilConfig) -> Result<(), AnvilError> {
819        use crate::auth::{find_identity, wrap_key, IdentityResolution};
820
821        let resolution = find_identity(config)?;
822
823        match resolution {
824            IdentityResolution::Found { key, .. } => {
825                return self.auth_key_or_cert(config, key).await;
826            }
827            IdentityResolution::Encrypted { path } => {
828                log::debug!(
829                    "session: key at {} is passphrase-protected; trying SSH agent first",
830                    path.display()
831                );
832                // Try the agent before asking for a passphrase.  The key may
833                // already be loaded via `ssh-add`, and a passphrase prompt is
834                // impossible when gitway is spawned by Git without a terminal.
835                #[cfg(unix)]
836                {
837                    use crate::auth::connect_agent;
838                    if let Some(conn) = connect_agent().await? {
839                        match self.authenticate_with_agent(&config.username, conn).await {
840                            Ok(()) => return Ok(()),
841                            Err(e) if e.is_authentication_failed() => {
842                                log::debug!(
843                                    "session: agent could not authenticate; \
844                                     will request passphrase for {}",
845                                    path.display()
846                                );
847                            }
848                            Err(e) => return Err(e),
849                        }
850                    }
851                }
852                return Err(AnvilError::new(AnvilErrorKind::Keys(
853                    russh::keys::Error::KeyIsEncrypted,
854                )));
855            }
856            IdentityResolution::NotFound => {
857                // Fall through to agent (below).
858            }
859        }
860
861        // Priority 3: SSH agent — reached only when no file-based key exists (FR-9).
862        #[cfg(unix)]
863        {
864            use crate::auth::connect_agent;
865            if let Some(conn) = connect_agent().await? {
866                return self.authenticate_with_agent(&config.username, conn).await;
867            }
868        }
869
870        // For RSA keys, ask the server which hash algorithm it prefers (FR-11).
871        // This branch is only reached when we must still try a key via wrap_key
872        // after exhausting the above — currently unused, but kept for clarity.
873        let _ = wrap_key; // suppress unused-import warning on non-Unix builds
874        Err(AnvilError::no_key_found())
875    }
876
877    /// Loads an encrypted key with `passphrase` and authenticates.
878    ///
879    /// Call this after [`authenticate_best`] returns an encrypted-key error
880    /// and the CLI has collected the passphrase from the terminal.
881    ///
882    /// If `config.cert_file` is set, certificate authentication is used
883    /// (FR-12).
884    ///
885    /// # Errors
886    ///
887    /// Returns an error if the passphrase is wrong or authentication fails.
888    pub async fn authenticate_with_passphrase(
889        &mut self,
890        config: &AnvilConfig,
891        path: &std::path::Path,
892        passphrase: &str,
893    ) -> Result<(), AnvilError> {
894        use crate::auth::load_encrypted_key;
895
896        let key = load_encrypted_key(path, passphrase)?;
897        self.auth_key_or_cert(config, key).await
898    }
899
900    /// Tries each identity held in `conn` until one succeeds or all are
901    /// exhausted.
902    ///
903    /// On Unix this is called automatically by [`authenticate_best`] when no
904    /// file-based key is found.  For plain public-key identities the signing
905    /// challenge is forwarded to the agent; for certificate identities the
906    /// full certificate is presented alongside the agent-signed challenge.
907    ///
908    /// # Errors
909    ///
910    /// Returns [`AnvilError::is_authentication_failed`] if all identities are
911    /// rejected, or [`AnvilError::is_no_key_found`] if the agent was empty.
912    #[cfg(unix)]
913    pub async fn authenticate_with_agent(
914        &mut self,
915        username: &str,
916        mut conn: crate::auth::AgentConnection,
917    ) -> Result<(), AnvilError> {
918        use russh::keys::agent::AgentIdentity;
919
920        for identity in conn.identities.clone() {
921            let result = match &identity {
922                AgentIdentity::PublicKey { key, .. } => {
923                    let hash_alg = if key.algorithm().is_rsa() {
924                        self.handle
925                            .best_supported_rsa_hash()
926                            .await?
927                            .flatten()
928                            // Fall back to SHA-256 when the server offers no guidance (FR-11).
929                            .or(Some(HashAlg::Sha256))
930                    } else {
931                        None
932                    };
933                    self.handle
934                        .authenticate_publickey_with(
935                            username,
936                            key.clone(),
937                            hash_alg,
938                            &mut conn.client,
939                        )
940                        .await
941                        .map_err(AnvilError::from)
942                }
943                AgentIdentity::Certificate { certificate, .. } => self
944                    .handle
945                    .authenticate_certificate_with(
946                        username,
947                        certificate.clone(),
948                        None,
949                        &mut conn.client,
950                    )
951                    .await
952                    .map_err(AnvilError::from),
953            };
954
955            match result? {
956                r if r.success() => {
957                    log::debug!("session: agent authentication succeeded");
958                    return Ok(());
959                }
960                _ => {
961                    log::debug!("session: agent identity rejected; trying next");
962                }
963            }
964        }
965
966        Err(AnvilError::no_key_found())
967    }
968
969    // ── Exec / relay ──────────────────────────────────────────────────────────
970
971    /// Opens a session channel, executes `command`, and relays stdio
972    /// bidirectionally until the remote process exits.
973    ///
974    /// Returns the remote exit code (FR-16).  Exit-via-signal returns
975    /// `128 + signal_number` (FR-17).
976    ///
977    /// # Errors
978    ///
979    /// Returns an error on channel open failure or SSH protocol errors.
980    pub async fn exec(&mut self, command: &str) -> Result<u32, AnvilError> {
981        log::debug!("session: opening exec channel for '{command}'");
982
983        let channel = self.handle.channel_open_session().await?;
984        channel.exec(true, command).await?;
985
986        let exit_code = relay::relay_channel(channel).await?;
987
988        log::debug!("session: command '{command}' exited with code {exit_code}");
989
990        Ok(exit_code)
991    }
992
993    // ── Lifecycle ─────────────────────────────────────────────────────────────
994
995    /// Sends a graceful `SSH_MSG_DISCONNECT` and closes the connection.
996    ///
997    /// # Errors
998    ///
999    /// Returns an error if the disconnect message cannot be sent.
1000    pub async fn close(self) -> Result<(), AnvilError> {
1001        self.handle
1002            .disconnect(Disconnect::ByApplication, "", "English")
1003            .await?;
1004        Ok(())
1005    }
1006
1007    // ── Accessors ─────────────────────────────────────────────────────────────
1008
1009    /// Returns the authentication banner last received from the server (if any).
1010    ///
1011    /// For GitHub.com this contains the "Hi <user>!" welcome message.
1012    ///
1013    /// # Panics
1014    ///
1015    /// Panics if the internal mutex is poisoned, which can only occur if another
1016    /// thread panicked while holding the lock — a programming error.
1017    #[must_use]
1018    pub fn auth_banner(&self) -> Option<String> {
1019        self.auth_banner
1020            .lock()
1021            .expect("auth_banner lock is not poisoned")
1022            .clone()
1023    }
1024
1025    /// Returns the SHA-256 fingerprint of the server key that was verified.
1026    ///
1027    /// Available after a successful [`connect`](Self::connect).  Returns `None`
1028    /// when host-key verification was skipped (`--insecure-skip-host-check`).
1029    ///
1030    /// # Panics
1031    ///
1032    /// Panics if the internal mutex is poisoned — a programming error.
1033    #[must_use]
1034    pub fn verified_fingerprint(&self) -> Option<String> {
1035        self.verified_fingerprint
1036            .lock()
1037            .expect("verified_fingerprint lock is not poisoned")
1038            .clone()
1039    }
1040
1041    // ── Internal helpers ──────────────────────────────────────────────────────
1042
1043    /// Authenticates with `key`, using certificate auth if `config.cert_file`
1044    /// is set (FR-12), otherwise plain public-key auth (FR-11).
1045    async fn auth_key_or_cert(
1046        &mut self,
1047        config: &AnvilConfig,
1048        key: russh::keys::PrivateKey,
1049    ) -> Result<(), AnvilError> {
1050        use crate::auth::{load_cert, wrap_key};
1051
1052        if let Some(ref cert_path) = config.cert_file {
1053            let cert = load_cert(cert_path)?;
1054            return self
1055                .authenticate_with_cert(&config.username, key, cert)
1056                .await;
1057        }
1058
1059        // For RSA keys, ask the server which hash algorithm it prefers (FR-11).
1060        let rsa_hash = if key.algorithm().is_rsa() {
1061            self.handle
1062                .best_supported_rsa_hash()
1063                .await?
1064                .flatten()
1065                .or(Some(HashAlg::Sha256))
1066        } else {
1067            None
1068        };
1069
1070        let wrapped = wrap_key(key, rsa_hash);
1071        self.authenticate(&config.username, wrapped).await
1072    }
1073}
1074
1075// ── russh config builder ──────────────────────────────────────────────────────
1076
1077/// Constructs a russh [`client::Config`] with Gitway's preferred
1078/// algorithms — sourced from `config`'s per-category preferences
1079/// (M17, PRD §5.8.6 FR-76) when set, falling back to Anvil's curated
1080/// defaults otherwise.
1081///
1082/// Algorithm preferences (FR-2, FR-3, FR-4):
1083/// - Key exchange: `curve25519-sha256` (RFC 8731) with
1084///   `curve25519-sha256@libssh.org` as fallback.
1085/// - Cipher: `chacha20-poly1305@openssh.com`.
1086/// - `ext-info-c` advertises server-sig-algs extension support.
1087///
1088/// CLI overrides (`--kex` / `--ciphers` / `--macs` /
1089/// `--host-key-algorithms`) populate `config.{kex_algorithms,
1090/// ciphers, macs, host_key_algorithms}` — already filtered through
1091/// [`crate::algorithms::apply_overrides`] (so the FR-78 denylist is
1092/// applied).  Unknown algorithm strings (names russh doesn't have a
1093/// constant for) are silently dropped here because russh's `Name`
1094/// types only accept `&'static str`; a future v1.1 may surface
1095/// these via a hard error at the override-validation stage.
1096/// Per-attempt artifacts captured during a successful connect inside
1097/// [`AnvilSession::connect`]'s retry loop.  Keeps the closure
1098/// signature concise and avoids leaking `HandlerPieces`'s internals
1099/// (e.g. the consumed-on-connect `russh_cfg` Arc) past the loop
1100/// boundary.
1101struct ConnectArtifacts {
1102    auth_banner: Arc<Mutex<Option<String>>>,
1103    verified_fingerprint: Arc<Mutex<Option<String>>>,
1104}
1105
1106/// Builds a [`crate::retry::RetryPolicy`] from the connect-related
1107/// fields on [`AnvilConfig`] (M18, FR-80 / FR-81).  Each `None`
1108/// field falls through to the corresponding `RetryPolicy::default()`
1109/// value.
1110fn retry_policy_from_config(config: &AnvilConfig) -> crate::retry::RetryPolicy {
1111    let mut policy = crate::retry::RetryPolicy::default();
1112    if let Some(t) = config.connect_timeout {
1113        policy.connect_timeout = Some(t);
1114    }
1115    if let Some(n) = config.connection_attempts {
1116        policy.attempts = n.max(1);
1117    }
1118    if let Some(w) = config.max_retry_window {
1119        policy.max_window = w;
1120    }
1121    policy
1122}
1123
1124fn build_russh_config(config: &AnvilConfig) -> client::Config {
1125    let kex_strings = config
1126        .kex_algorithms
1127        .clone()
1128        .unwrap_or_else(crate::algorithms::anvil_default_kex);
1129    let cipher_strings = config
1130        .ciphers
1131        .clone()
1132        .unwrap_or_else(crate::algorithms::anvil_default_ciphers);
1133    let mac_strings = config
1134        .macs
1135        .clone()
1136        .unwrap_or_else(crate::algorithms::anvil_default_macs);
1137    let host_key_strings = config
1138        .host_key_algorithms
1139        .clone()
1140        .unwrap_or_else(crate::algorithms::anvil_default_host_keys);
1141
1142    // FR-66 (M15) / M17 instrumentation: emit the offered
1143    // preference vectors at trace level under `CAT_KEX` so a
1144    // `gitway -vvv --debug-categories=kex` consumer sees what was
1145    // sent before the negotiation event from M15.2 fires.
1146    tracing::trace!(
1147        target: crate::log::CAT_KEX,
1148        kex = ?kex_strings,
1149        cipher = ?cipher_strings,
1150        mac = ?mac_strings,
1151        host_key = ?host_key_strings,
1152        "negotiating with offered algorithm sets",
1153    );
1154
1155    let kex_list: Vec<kex::Name> = kex_strings
1156        .iter()
1157        .filter_map(|s| russh_kex_name(s))
1158        .collect();
1159    let cipher_list: Vec<cipher::Name> = cipher_strings
1160        .iter()
1161        .filter_map(|s| russh_cipher_name(s))
1162        .collect();
1163    let mac_list: Vec<russh::mac::Name> = mac_strings
1164        .iter()
1165        .filter_map(|s| russh_mac_name(s))
1166        .collect();
1167    // Host-key uses russh::keys::Algorithm (an enum) which has a
1168    // FromStr impl that round-trips unknown names via Algorithm::Other.
1169    let host_key_list: Vec<russh::keys::Algorithm> = host_key_strings
1170        .iter()
1171        .filter_map(|s| s.parse::<russh::keys::Algorithm>().ok())
1172        .collect();
1173
1174    client::Config {
1175        // 60 s matches GitHub's server-side idle threshold.
1176        // Lowering below ~10 s risks spurious timeouts on high-latency links.
1177        inactivity_timeout: Some(config.inactivity_timeout),
1178        preferred: Preferred {
1179            kex: Cow::Owned(kex_list),
1180            cipher: Cow::Owned(cipher_list),
1181            mac: Cow::Owned(mac_list),
1182            key: Cow::Owned(host_key_list),
1183            ..Default::default()
1184        },
1185        ..Default::default()
1186    }
1187}
1188
1189/// Maps a kex algorithm name string to the matching `russh::kex::Name`
1190/// constant, or `None` for unknown names.  Russh's `Name` types wrap
1191/// `&'static str`, so we cannot construct them from owned strings —
1192/// only the published constants work.  Unknown names land outside
1193/// this lookup and are silently dropped from the negotiation set.
1194fn russh_kex_name(s: &str) -> Option<kex::Name> {
1195    let s = s.trim();
1196    Some(match s {
1197        "curve25519-sha256" => kex::CURVE25519,
1198        "curve25519-sha256@libssh.org" => kex::CURVE25519_PRE_RFC_8731,
1199        "diffie-hellman-group-exchange-sha256" => kex::DH_GEX_SHA256,
1200        "diffie-hellman-group-exchange-sha1" => kex::DH_GEX_SHA1,
1201        "diffie-hellman-group1-sha1" => kex::DH_G1_SHA1,
1202        "diffie-hellman-group14-sha1" => kex::DH_G14_SHA1,
1203        "diffie-hellman-group14-sha256" => kex::DH_G14_SHA256,
1204        "diffie-hellman-group15-sha512" => kex::DH_G15_SHA512,
1205        "diffie-hellman-group16-sha512" => kex::DH_G16_SHA512,
1206        "diffie-hellman-group17-sha512" => kex::DH_G17_SHA512,
1207        "diffie-hellman-group18-sha512" => kex::DH_G18_SHA512,
1208        "ext-info-c" => kex::EXTENSION_SUPPORT_AS_CLIENT,
1209        _ => return None,
1210    })
1211}
1212
1213/// Maps a cipher algorithm name string to the matching
1214/// `russh::cipher::Name` constant.  See [`russh_kex_name`] for the
1215/// `&'static str` rationale.
1216fn russh_cipher_name(s: &str) -> Option<cipher::Name> {
1217    let s = s.trim();
1218    Some(match s {
1219        "chacha20-poly1305@openssh.com" => cipher::CHACHA20_POLY1305,
1220        "aes128-ctr" => cipher::AES_128_CTR,
1221        "aes192-ctr" => cipher::AES_192_CTR,
1222        "aes256-ctr" => cipher::AES_256_CTR,
1223        "aes128-cbc" => cipher::AES_128_CBC,
1224        "aes192-cbc" => cipher::AES_192_CBC,
1225        "aes256-cbc" => cipher::AES_256_CBC,
1226        "aes128-gcm@openssh.com" => cipher::AES_128_GCM,
1227        "aes256-gcm@openssh.com" => cipher::AES_256_GCM,
1228        // Note: cipher::TRIPLE_DES_CBC is intentionally NOT mapped.
1229        // Even if a buggy upstream override slipped a "3des-cbc"
1230        // past the FR-78 denylist, this lookup would still drop it.
1231        _ => return None,
1232    })
1233}
1234
1235/// Maps a MAC algorithm name string to the matching
1236/// `russh::mac::Name` constant.
1237fn russh_mac_name(s: &str) -> Option<russh::mac::Name> {
1238    let s = s.trim();
1239    Some(match s {
1240        "hmac-sha2-512-etm@openssh.com" => russh::mac::HMAC_SHA512_ETM,
1241        "hmac-sha2-256-etm@openssh.com" => russh::mac::HMAC_SHA256_ETM,
1242        "hmac-sha1-etm@openssh.com" => russh::mac::HMAC_SHA1_ETM,
1243        "hmac-sha2-512" => russh::mac::HMAC_SHA512,
1244        "hmac-sha2-256" => russh::mac::HMAC_SHA256,
1245        "hmac-sha1" => russh::mac::HMAC_SHA1,
1246        _ => return None,
1247    })
1248}
1249
1250// ── Jump-host helper (M13.4) ─────────────────────────────────────────────────
1251
1252/// Builds the per-hop [`AnvilConfig`] used inside
1253/// `AnvilSession::connect_via_jump_hosts`.
1254///
1255/// Inherits security knobs — `strict_host_key_checking`,
1256/// `custom_known_hosts`, `verbose` — from the *primary* config so a
1257/// user's connection-wide policy (e.g. `--insecure-skip-host-check`)
1258/// applies to every hop.  Per-hop fields (`user`, `identity_files`)
1259/// come from the [`crate::proxy::JumpHost`] when set, else from the
1260/// primary config: a CLI `--user alice` thus propagates to every
1261/// bastion that did not override the user in its own `Host` block.
1262fn jump_to_config(hop: &crate::proxy::JumpHost, primary: &AnvilConfig) -> AnvilConfig {
1263    let mut builder = AnvilConfig::builder(&hop.host)
1264        .port(hop.port)
1265        .strict_host_key_checking(primary.strict_host_key_checking)
1266        .verbose(primary.verbose);
1267
1268    let username = hop.user.clone().unwrap_or_else(|| primary.username.clone());
1269    builder = builder.username(username);
1270
1271    let identity_files: Vec<_> = if hop.identity_files.is_empty() {
1272        primary.identity_files.clone()
1273    } else {
1274        hop.identity_files.clone()
1275    };
1276    builder = builder.identity_files(identity_files);
1277
1278    if let Some(p) = &primary.custom_known_hosts {
1279        builder = builder.custom_known_hosts(p.clone());
1280    }
1281
1282    builder.build()
1283}
1284
1285// ── Tests ─────────────────────────────────────────────────────────────────────
1286
1287#[cfg(test)]
1288mod tests {
1289    use super::*;
1290
1291    // ── NFR-6: legacy algorithm exclusion ────────────────────────────────────
1292
1293    /// 3DES-CBC must never appear in the negotiated cipher list (NFR-6).
1294    ///
1295    /// Our explicit cipher override contains only chacha20-poly1305, so 3DES
1296    /// cannot be selected even if the server offers it.
1297    #[test]
1298    fn config_cipher_excludes_3des() {
1299        let anvil_config = AnvilConfig::builder("test.example").build();
1300        let config = build_russh_config(&anvil_config);
1301        let found = config
1302            .preferred
1303            .cipher
1304            .iter()
1305            .any(|c| c.as_ref() == "3des-cbc");
1306        assert!(
1307            !found,
1308            "3DES-CBC must not appear in the cipher list (NFR-6)"
1309        );
1310    }
1311
1312    /// DSA must never appear in the key-algorithm list (NFR-6).
1313    ///
1314    /// russh's `Preferred::DEFAULT` already omits DSA; this test locks that
1315    /// invariant so a russh upgrade cannot silently re-introduce it.
1316    #[test]
1317    fn config_key_algorithms_exclude_dsa() {
1318        use russh::keys::Algorithm;
1319
1320        let anvil_config = AnvilConfig::builder("test.example").build();
1321        let config = build_russh_config(&anvil_config);
1322        assert!(
1323            !config.preferred.key.contains(&Algorithm::Dsa),
1324            "DSA must not appear in the key-algorithm list (NFR-6)"
1325        );
1326    }
1327
1328    // ── FR-2 / FR-3 positive assertions ─────────────────────────────────────
1329
1330    /// curve25519-sha256 must be in the kex list (FR-2).
1331    #[test]
1332    fn config_kex_includes_curve25519() {
1333        let anvil_config = AnvilConfig::builder("test.example").build();
1334        let config = build_russh_config(&anvil_config);
1335        let found = config
1336            .preferred
1337            .kex
1338            .iter()
1339            .any(|k| k.as_ref() == "curve25519-sha256");
1340        assert!(found, "curve25519-sha256 must be in the kex list (FR-2)");
1341    }
1342
1343    /// chacha20-poly1305@openssh.com must be in the cipher list (FR-3).
1344    #[test]
1345    fn config_cipher_includes_chacha20_poly1305() {
1346        let anvil_config = AnvilConfig::builder("test.example").build();
1347        let config = build_russh_config(&anvil_config);
1348        let found = config
1349            .preferred
1350            .cipher
1351            .iter()
1352            .any(|c| c.as_ref() == "chacha20-poly1305@openssh.com");
1353        assert!(
1354            found,
1355            "chacha20-poly1305@openssh.com must be in the cipher list (FR-3)"
1356        );
1357    }
1358}