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}