Skip to main content

anvil_ssh/
config.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Configuration builder for an [`AnvilSession`](crate::AnvilSession).
4//!
5//! # 0.3.0 API break
6//!
7//! Two fields changed shape in 0.3.0 to align with `ssh_config(5)`:
8//!
9//! - `identity_file: Option<PathBuf>` -> `identity_files: Vec<PathBuf>`.
10//!   OpenSSH allows multiple `IdentityFile` directives; the resolver and
11//!   the auth path now honour the full list in order.  Reads of the old
12//!   single-path getter still work via the `#[deprecated]` shim.
13//! - `skip_host_check: bool` -> `strict_host_key_checking:
14//!   StrictHostKeyChecking`.  The new enum encodes `Yes` / `No` /
15//!   `AcceptNew`, matching `ssh_config(5)`.  The old boolean getter and
16//!   builder method continue to work via deprecation shims.
17//!
18//! # Examples
19//!
20//! ```rust
21//! use anvil_ssh::AnvilConfig;
22//! use std::time::Duration;
23//!
24//! // Connect to GitHub (default):
25//! let config = AnvilConfig::github();
26//!
27//! // Connect to GitLab:
28//! let config = AnvilConfig::gitlab();
29//!
30//! // Connect to Codeberg:
31//! let config = AnvilConfig::codeberg();
32//!
33//! // Connect to any host with a custom port:
34//! let config = AnvilConfig::builder("git.example.com")
35//!     .port(22)
36//!     .username("git")
37//!     .inactivity_timeout(Duration::from_secs(60))
38//!     .build();
39//! ```
40
41use std::path::{Path, PathBuf};
42use std::time::Duration;
43
44use crate::hostkey::{
45    DEFAULT_CODEBERG_HOST, DEFAULT_GITHUB_HOST, DEFAULT_GITLAB_HOST, DEFAULT_PORT, FALLBACK_PORT,
46    GITHUB_FALLBACK_HOST, GITLAB_FALLBACK_HOST,
47};
48use crate::ssh_config::{ResolvedSshConfig, StrictHostKeyChecking};
49
50// ── Public config type ────────────────────────────────────────────────────────
51
52/// Immutable configuration for an [`AnvilSession`](crate::AnvilSession).
53///
54/// Construct via [`AnvilConfig::builder`], or use one of the convenience
55/// constructors ([`github`](Self::github), [`gitlab`](Self::gitlab),
56/// [`codeberg`](Self::codeberg)) for the most common targets.
57#[derive(Debug, Clone)]
58pub struct AnvilConfig {
59    /// Primary SSH host (e.g. `github.com`, `gitlab.com`, `codeberg.org`).
60    pub host: String,
61    /// Primary SSH port (default: 22).
62    pub port: u16,
63    /// Remote username (always `git` for hosted services; FR-13).
64    pub username: String,
65    /// Ordered list of identity-file paths.  Tried in source order during
66    /// authentication; an empty list falls through to the default search
67    /// path (`~/.ssh/id_ed25519`, `id_ecdsa`, `id_rsa`).  Populated by
68    /// `IdentityFile` directives from `ssh_config`, by the
69    /// [`AnvilConfigBuilder::add_identity_file`] /
70    /// [`AnvilConfigBuilder::identity_files`] builder methods, and (for
71    /// 0.2.x compatibility) by the deprecated
72    /// [`AnvilConfigBuilder::identity_file`] method.
73    pub identity_files: Vec<PathBuf>,
74    /// OpenSSH certificate path supplied via `--cert` (FR-12).
75    pub cert_file: Option<PathBuf>,
76    /// Host-key verification policy.  Defaults to
77    /// [`StrictHostKeyChecking::Yes`].
78    pub strict_host_key_checking: StrictHostKeyChecking,
79    /// Inactivity timeout for the SSH session (FR-5).
80    ///
81    /// GitHub's idle threshold is around 60 s; this is the configured
82    /// client-side inactivity timeout, not a per-packet deadline.
83    pub inactivity_timeout: Duration,
84    /// Path to a `known_hosts`-style file for custom or self-hosted instances
85    /// (FR-7).  Format: one `hostname SHA256:<fp>` entry per line.
86    pub custom_known_hosts: Option<PathBuf>,
87    /// Enable verbose debug logging when `true`.
88    pub verbose: bool,
89    /// Optional fallback host when port 22 is unavailable (FR-1).
90    ///
91    /// GitHub: `ssh.github.com:443`. GitLab: `altssh.gitlab.com:443`.
92    /// Codeberg has no published port-443 fallback.
93    pub fallback: Option<(String, u16)>,
94    /// Key-exchange algorithm preference (PRD §5.8.6 FR-76).
95    ///
96    /// `None` selects [`crate::algorithms::anvil_default_kex`] — the
97    /// curated default.  `Some(list)` overrides; the list has
98    /// already passed through
99    /// [`crate::algorithms::apply_overrides`] (so any `+`/`-`/`^`
100    /// prefix has been resolved and the FR-78 denylist applied).
101    pub kex_algorithms: Option<Vec<String>>,
102    /// Cipher preference (PRD §5.8.6 FR-76).  `None` → curated default.
103    pub ciphers: Option<Vec<String>>,
104    /// MAC preference (PRD §5.8.6 FR-76).  `None` → curated default.
105    /// Mostly cosmetic for AEAD ciphers (chacha20-poly1305, AES-GCM)
106    /// since they carry their own auth tag.
107    pub macs: Option<Vec<String>>,
108    /// Host-key algorithm preference (PRD §5.8.6 FR-76).  `None` →
109    /// curated default.
110    pub host_key_algorithms: Option<Vec<String>>,
111}
112
113impl AnvilConfig {
114    /// Begin building a config targeting `host`.
115    ///
116    /// All optional fields default to sensible values. No fallback host is
117    /// set by default; use the provider-specific convenience constructors
118    /// ([`github`](Self::github), [`gitlab`](Self::gitlab)) if you want the
119    /// port-443 fallback pre-configured.
120    pub fn builder(host: impl Into<String>) -> AnvilConfigBuilder {
121        AnvilConfigBuilder::new(host.into())
122    }
123
124    /// Convenience constructor for the default GitHub target (`github.com:22`).
125    ///
126    /// Includes the `ssh.github.com:443` fallback pre-configured.
127    #[must_use]
128    pub fn github() -> Self {
129        Self::builder(DEFAULT_GITHUB_HOST)
130            .fallback(Some((GITHUB_FALLBACK_HOST.to_owned(), FALLBACK_PORT)))
131            .build()
132    }
133
134    /// Convenience constructor for the default GitLab target (`gitlab.com:22`).
135    ///
136    /// Includes the `altssh.gitlab.com:443` fallback pre-configured.
137    #[must_use]
138    pub fn gitlab() -> Self {
139        Self::builder(DEFAULT_GITLAB_HOST)
140            .fallback(Some((GITLAB_FALLBACK_HOST.to_owned(), FALLBACK_PORT)))
141            .build()
142    }
143
144    /// Convenience constructor for Codeberg (`codeberg.org:22`).
145    ///
146    /// Codeberg has no published port-443 SSH fallback; no fallback is set.
147    #[must_use]
148    pub fn codeberg() -> Self {
149        Self::builder(DEFAULT_CODEBERG_HOST).build()
150    }
151
152    /// First identity-file path, or `None` if [`Self::identity_files`] is
153    /// empty.  Provided as a 0.2.x compatibility shim — new code should
154    /// read [`Self::identity_files`] directly.
155    #[deprecated(since = "0.3.0", note = "read `identity_files` directly")]
156    #[must_use]
157    pub fn identity_file(&self) -> Option<&Path> {
158        self.identity_files.first().map(PathBuf::as_path)
159    }
160
161    /// `true` when [`Self::strict_host_key_checking`] is
162    /// [`StrictHostKeyChecking::No`].  Provided as a 0.2.x compatibility
163    /// shim — new code should read [`Self::strict_host_key_checking`]
164    /// directly.
165    #[deprecated(since = "0.3.0", note = "read `strict_host_key_checking` directly")]
166    #[must_use]
167    pub fn skip_host_check(&self) -> bool {
168        matches!(self.strict_host_key_checking, StrictHostKeyChecking::No)
169    }
170}
171
172// ── Builder ───────────────────────────────────────────────────────────────────
173
174/// Builder for [`AnvilConfig`].
175///
176/// Obtained via [`AnvilConfig::builder`].
177#[derive(Debug)]
178#[must_use]
179pub struct AnvilConfigBuilder {
180    host: String,
181    port: u16,
182    username: String,
183    identity_files: Vec<PathBuf>,
184    cert_file: Option<PathBuf>,
185    strict_host_key_checking: StrictHostKeyChecking,
186    inactivity_timeout: Duration,
187    custom_known_hosts: Option<PathBuf>,
188    verbose: bool,
189    fallback: Option<(String, u16)>,
190    kex_algorithms: Option<Vec<String>>,
191    ciphers: Option<Vec<String>>,
192    macs: Option<Vec<String>>,
193    host_key_algorithms: Option<Vec<String>>,
194}
195
196impl AnvilConfigBuilder {
197    fn new(host: String) -> Self {
198        Self {
199            host,
200            port: DEFAULT_PORT,
201            username: "git".to_owned(),
202            identity_files: Vec::new(),
203            cert_file: None,
204            strict_host_key_checking: StrictHostKeyChecking::Yes,
205            // 60 seconds — large enough to survive slow host responses.
206            // Changing this below ~10 s risks spurious timeouts on congested
207            // links.
208            inactivity_timeout: Duration::from_secs(60),
209            custom_known_hosts: None,
210            verbose: false,
211            // No fallback by default; provider-specific convenience
212            // constructors set this when a known fallback exists.
213            fallback: None,
214            // Algorithm preferences default to None (= use the
215            // curated `algorithms::anvil_default_*` lists at session
216            // build time).  M17.4 CLI flags overwrite these via the
217            // four setters below.
218            kex_algorithms: None,
219            ciphers: None,
220            macs: None,
221            host_key_algorithms: None,
222        }
223    }
224
225    /// Override the target SSH port (default: 22, FR-1).
226    pub fn port(mut self, port: u16) -> Self {
227        self.port = port;
228        self
229    }
230
231    /// Override the remote username (default: `"git"`, FR-13).
232    pub fn username(mut self, username: impl Into<String>) -> Self {
233        self.username = username.into();
234        self
235    }
236
237    /// Append `path` to the ordered identity-file list (FR-9).
238    ///
239    /// Use this to add CLI-supplied keys; ssh_config-supplied keys flow
240    /// in through [`Self::apply_ssh_config`].  Both can coexist; auth
241    /// tries them in the order they were added.
242    pub fn add_identity_file(mut self, path: impl Into<PathBuf>) -> Self {
243        self.identity_files.push(path.into());
244        self
245    }
246
247    /// Replace the entire identity-file list with `paths`.  Existing
248    /// entries are discarded.
249    pub fn identity_files(mut self, paths: Vec<PathBuf>) -> Self {
250        self.identity_files = paths;
251        self
252    }
253
254    /// Set a single identity-file path, replacing any existing entries.
255    ///
256    /// 0.2.x compatibility shim.  New code should use
257    /// [`Self::add_identity_file`] (additive) or [`Self::identity_files`]
258    /// (replace-all) for clarity.
259    #[deprecated(
260        since = "0.3.0",
261        note = "use `add_identity_file` or `identity_files` for the multi-key API"
262    )]
263    pub fn identity_file(mut self, path: impl Into<PathBuf>) -> Self {
264        self.identity_files.clear();
265        self.identity_files.push(path.into());
266        self
267    }
268
269    /// Set an OpenSSH certificate path (FR-12).
270    pub fn cert_file(mut self, path: impl Into<PathBuf>) -> Self {
271        self.cert_file = Some(path.into());
272        self
273    }
274
275    /// Set the host-key verification policy (FR-8).
276    pub fn strict_host_key_checking(mut self, policy: StrictHostKeyChecking) -> Self {
277        self.strict_host_key_checking = policy;
278        self
279    }
280
281    /// Disable host-key verification.  **Use only for emergencies** (FR-8).
282    ///
283    /// `true` maps to [`StrictHostKeyChecking::No`]; `false` to
284    /// [`StrictHostKeyChecking::Yes`].  Lossless from the 0.2.x boolean
285    /// shape (which only encoded those two states).
286    #[deprecated(
287        since = "0.3.0",
288        note = "use `strict_host_key_checking(StrictHostKeyChecking::No)` for clarity"
289    )]
290    pub fn skip_host_check(mut self, skip: bool) -> Self {
291        self.strict_host_key_checking = if skip {
292            StrictHostKeyChecking::No
293        } else {
294            StrictHostKeyChecking::Yes
295        };
296        self
297    }
298
299    /// Override the session inactivity timeout (FR-5).
300    pub fn inactivity_timeout(mut self, timeout: Duration) -> Self {
301        self.inactivity_timeout = timeout;
302        self
303    }
304
305    /// Path to a custom `known_hosts`-style file for self-hosted instances
306    /// (FR-7).
307    pub fn custom_known_hosts(mut self, path: impl Into<PathBuf>) -> Self {
308        self.custom_known_hosts = Some(path.into());
309        self
310    }
311
312    /// Enable verbose debug logging.
313    pub fn verbose(mut self, verbose: bool) -> Self {
314        self.verbose = verbose;
315        self
316    }
317
318    /// Override the fallback host/port.  Pass `None` to disable fallback.
319    pub fn fallback(mut self, fallback: Option<(String, u16)>) -> Self {
320        self.fallback = fallback;
321        self
322    }
323
324    /// Override the key-exchange algorithm preference (PRD §5.8.6 FR-76).
325    ///
326    /// Pass `None` to keep the curated default
327    /// ([`crate::algorithms::anvil_default_kex`]).  The list is
328    /// expected to have already passed through
329    /// [`crate::algorithms::apply_overrides`] so any
330    /// `+`/`-`/`^` prefix is resolved and the FR-78 denylist applied.
331    pub fn kex_algorithms(mut self, list: Option<Vec<String>>) -> Self {
332        self.kex_algorithms = list;
333        self
334    }
335
336    /// Override the cipher preference (PRD §5.8.6 FR-76).
337    pub fn ciphers(mut self, list: Option<Vec<String>>) -> Self {
338        self.ciphers = list;
339        self
340    }
341
342    /// Override the MAC preference (PRD §5.8.6 FR-76).
343    pub fn macs(mut self, list: Option<Vec<String>>) -> Self {
344        self.macs = list;
345        self
346    }
347
348    /// Override the host-key algorithm preference (PRD §5.8.6 FR-76).
349    pub fn host_key_algorithms(mut self, list: Option<Vec<String>>) -> Self {
350        self.host_key_algorithms = list;
351        self
352    }
353
354    /// Layer values from a [`ResolvedSshConfig`] into this builder.
355    ///
356    /// Provides ssh_config-derived defaults that subsequent builder calls
357    /// can still override (call this *before* CLI-derived overrides if
358    /// you want CLI to win).  The following mappings are applied:
359    ///
360    /// | `ssh_config` directive | Builder field |
361    /// |---|---|
362    /// | `HostName` | `host` (overridden) |
363    /// | `Port` | `port` (overridden) |
364    /// | `User` | `username` (overridden) |
365    /// | `IdentityFile` (multi) | `identity_files` (extended) |
366    /// | `StrictHostKeyChecking` | `strict_host_key_checking` (overridden) |
367    /// | `UserKnownHostsFile` (first) | `custom_known_hosts` (filled if `None`) |
368    ///
369    /// Algorithm directives (`HostKeyAlgorithms`, `KexAlgorithms`,
370    /// `Ciphers`, `MACs`) are honored as of M17 (PRD §5.8.6 FR-76):
371    /// each parsed `AlgList` is fed through
372    /// [`crate::algorithms::apply_overrides`] against the matching
373    /// curated default, so an `ssh_config` value of `+algo,algo`
374    /// appends to Anvil's defaults rather than replacing them.
375    /// `ConnectTimeout` / `ConnectionAttempts` remain deferred to
376    /// M18.
377    ///
378    /// # Errors
379    ///
380    /// This method is infallible by signature, but a malformed
381    /// algorithm list (denylisted entry referenced by an override)
382    /// is logged at `warn` level and silently dropped — the
383    /// connection then falls back to the curated default.  Callers
384    /// who want strict validation should run the same value
385    /// through [`crate::algorithms::apply_overrides`] explicitly
386    /// before calling here.
387    pub fn apply_ssh_config(mut self, resolved: &ResolvedSshConfig) -> Self {
388        if let Some(hostname) = &resolved.hostname {
389            self.host.clone_from(hostname);
390        }
391        if let Some(port) = resolved.port {
392            self.port = port;
393        }
394        if let Some(user) = &resolved.user {
395            self.username.clone_from(user);
396        }
397        self.identity_files
398            .extend(resolved.identity_files.iter().cloned());
399        if let Some(policy) = resolved.strict_host_key_checking {
400            self.strict_host_key_checking = policy;
401        }
402        if self.custom_known_hosts.is_none() {
403            if let Some(p) = resolved.user_known_hosts_files.first() {
404                self.custom_known_hosts = Some(p.clone());
405            }
406        }
407        // M17 / FR-76: plumb algorithm directives through the
408        // `+`/`-`/`^` parser against Anvil's curated defaults.  CLI
409        // overrides applied AFTER `apply_ssh_config` win over the
410        // ssh_config-derived value (matches OpenSSH precedence).
411        self.apply_alg_directive(
412            crate::algorithms::AlgCategory::Kex,
413            resolved.kex_algorithms.as_ref(),
414            crate::algorithms::anvil_default_kex,
415            |b, v| b.kex_algorithms = Some(v),
416        );
417        self.apply_alg_directive(
418            crate::algorithms::AlgCategory::Cipher,
419            resolved.ciphers.as_ref(),
420            crate::algorithms::anvil_default_ciphers,
421            |b, v| b.ciphers = Some(v),
422        );
423        self.apply_alg_directive(
424            crate::algorithms::AlgCategory::Mac,
425            resolved.macs.as_ref(),
426            crate::algorithms::anvil_default_macs,
427            |b, v| b.macs = Some(v),
428        );
429        self.apply_alg_directive(
430            crate::algorithms::AlgCategory::HostKey,
431            resolved.host_key_algorithms.as_ref(),
432            crate::algorithms::anvil_default_host_keys,
433            |b, v| b.host_key_algorithms = Some(v),
434        );
435        warn_unhonored_directives(resolved);
436        self
437    }
438
439    /// Internal helper for `apply_ssh_config`.  Reads one algorithm
440    /// directive from the resolved config, runs it through
441    /// `apply_overrides` against the curated default, and stores the
442    /// result via `setter`.  Malformed values (denylisted entries)
443    /// log a warning and leave the field on its `None` default so
444    /// the curated list is used at session-build time.
445    fn apply_alg_directive(
446        &mut self,
447        category: crate::algorithms::AlgCategory,
448        directive: Option<&crate::ssh_config::AlgList>,
449        default_fn: fn() -> Vec<String>,
450        setter: fn(&mut Self, Vec<String>),
451    ) {
452        let Some(crate::ssh_config::AlgList(value)) = directive else {
453            return;
454        };
455        match crate::algorithms::apply_overrides(category, default_fn(), value) {
456            Ok(list) => setter(self, list),
457            Err(e) => {
458                log::warn!(
459                    "ssh_config {category} directive '{value}' rejected: {e} \
460                     (falling back to Anvil curated default)",
461                    category = category.label(),
462                );
463            }
464        }
465    }
466
467    // (Unhonored-directives warning helper lives below `impl` block.)
468
469    /// Finalise and return the [`AnvilConfig`].
470    #[must_use]
471    pub fn build(self) -> AnvilConfig {
472        AnvilConfig {
473            host: self.host,
474            port: self.port,
475            username: self.username,
476            identity_files: self.identity_files,
477            cert_file: self.cert_file,
478            strict_host_key_checking: self.strict_host_key_checking,
479            inactivity_timeout: self.inactivity_timeout,
480            custom_known_hosts: self.custom_known_hosts,
481            verbose: self.verbose,
482            fallback: self.fallback,
483            kex_algorithms: self.kex_algorithms,
484            ciphers: self.ciphers,
485            macs: self.macs,
486            host_key_algorithms: self.host_key_algorithms,
487        }
488    }
489}
490
491/// Emits a single `log::warn!` listing any directives in `resolved` that
492/// the resolver successfully parsed but Anvil does not yet honor.  Called
493/// from [`AnvilConfigBuilder::apply_ssh_config`] so the warning fires
494/// when a config is actually being prepared for connection — `gitway
495/// config show` and similar inspection callers do not trigger it.
496///
497/// The remaining unhonored set after M17:
498/// - `ConnectTimeout`, `ConnectionAttempts` — parsed but not yet wired
499///   into `connect()`.  M18 honors them.
500///
501/// `HostKeyAlgorithms` / `KexAlgorithms` / `Ciphers` / `MACs` were
502/// the M17 deferral; they're now consumed by `apply_ssh_config` via
503/// the `apply_alg_directive` helper above.
504fn warn_unhonored_directives(resolved: &ResolvedSshConfig) {
505    let mut m18: Vec<&'static str> = Vec::new();
506    if resolved.connect_timeout.is_some() {
507        m18.push("ConnectTimeout");
508    }
509    if resolved.connection_attempts.is_some() {
510        m18.push("ConnectionAttempts");
511    }
512
513    if !m18.is_empty() {
514        log::warn!(
515            "ssh_config: directive(s) {} parsed but not yet honored \
516             (landing in M18 — Gitway PRD §8)",
517            m18.join(", "),
518        );
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn builder_defaults_yes_strict_host_check() {
528        let cfg = AnvilConfig::builder("h").build();
529        assert_eq!(cfg.strict_host_key_checking, StrictHostKeyChecking::Yes);
530        assert!(cfg.identity_files.is_empty());
531    }
532
533    #[test]
534    fn add_identity_file_accumulates() {
535        let cfg = AnvilConfig::builder("h")
536            .add_identity_file(PathBuf::from("/a"))
537            .add_identity_file(PathBuf::from("/b"))
538            .build();
539        assert_eq!(
540            cfg.identity_files,
541            vec![PathBuf::from("/a"), PathBuf::from("/b")],
542        );
543    }
544
545    #[test]
546    fn identity_files_replaces_list() {
547        let cfg = AnvilConfig::builder("h")
548            .add_identity_file(PathBuf::from("/old"))
549            .identity_files(vec![PathBuf::from("/new1"), PathBuf::from("/new2")])
550            .build();
551        assert_eq!(
552            cfg.identity_files,
553            vec![PathBuf::from("/new1"), PathBuf::from("/new2")],
554        );
555    }
556
557    #[test]
558    #[allow(deprecated, reason = "exercising the deprecated shim")]
559    fn deprecated_identity_file_shim_clears_then_pushes() {
560        let cfg = AnvilConfig::builder("h")
561            .add_identity_file(PathBuf::from("/should_be_cleared"))
562            .identity_file(PathBuf::from("/single"))
563            .build();
564        assert_eq!(cfg.identity_files, vec![PathBuf::from("/single")]);
565        // The deprecated accessor returns the first identity file.
566        assert_eq!(cfg.identity_file(), Some(Path::new("/single")));
567    }
568
569    #[test]
570    #[allow(deprecated, reason = "exercising the deprecated shim")]
571    fn deprecated_skip_host_check_maps_to_enum() {
572        let cfg_skip = AnvilConfig::builder("h").skip_host_check(true).build();
573        assert_eq!(cfg_skip.strict_host_key_checking, StrictHostKeyChecking::No);
574        assert!(cfg_skip.skip_host_check());
575
576        let cfg_check = AnvilConfig::builder("h").skip_host_check(false).build();
577        assert_eq!(
578            cfg_check.strict_host_key_checking,
579            StrictHostKeyChecking::Yes,
580        );
581        assert!(!cfg_check.skip_host_check());
582    }
583
584    #[test]
585    fn strict_host_key_checking_accepts_all_three() {
586        for policy in [
587            StrictHostKeyChecking::Yes,
588            StrictHostKeyChecking::No,
589            StrictHostKeyChecking::AcceptNew,
590        ] {
591            let cfg = AnvilConfig::builder("h")
592                .strict_host_key_checking(policy)
593                .build();
594            assert_eq!(cfg.strict_host_key_checking, policy);
595        }
596    }
597
598    #[test]
599    fn apply_ssh_config_layers_resolved_values() {
600        let resolved = ResolvedSshConfig {
601            hostname: Some("real.example.com".to_owned()),
602            user: Some("alice".to_owned()),
603            port: Some(2222),
604            identity_files: vec![PathBuf::from("/cfg/key")],
605            strict_host_key_checking: Some(StrictHostKeyChecking::AcceptNew),
606            user_known_hosts_files: vec![PathBuf::from("/cfg/known_hosts")],
607            ..ResolvedSshConfig::default()
608        };
609        let cfg = AnvilConfig::builder("alias")
610            .apply_ssh_config(&resolved)
611            .build();
612        assert_eq!(cfg.host, "real.example.com");
613        assert_eq!(cfg.port, 2222);
614        assert_eq!(cfg.username, "alice");
615        assert_eq!(cfg.identity_files, vec![PathBuf::from("/cfg/key")]);
616        assert_eq!(
617            cfg.strict_host_key_checking,
618            StrictHostKeyChecking::AcceptNew,
619        );
620        assert_eq!(
621            cfg.custom_known_hosts,
622            Some(PathBuf::from("/cfg/known_hosts"))
623        );
624    }
625
626    #[test]
627    fn apply_ssh_config_extends_identity_files_does_not_replace() {
628        let resolved = ResolvedSshConfig {
629            identity_files: vec![PathBuf::from("/cfg/a")],
630            ..ResolvedSshConfig::default()
631        };
632        let cfg = AnvilConfig::builder("h")
633            .add_identity_file(PathBuf::from("/cli/first"))
634            .apply_ssh_config(&resolved)
635            .build();
636        // CLI ones come first, ssh_config appends after.
637        assert_eq!(
638            cfg.identity_files,
639            vec![PathBuf::from("/cli/first"), PathBuf::from("/cfg/a")],
640        );
641    }
642
643    #[test]
644    fn apply_ssh_config_does_not_overwrite_explicit_known_hosts() {
645        let resolved = ResolvedSshConfig {
646            user_known_hosts_files: vec![PathBuf::from("/from/cfg")],
647            ..ResolvedSshConfig::default()
648        };
649        let cfg = AnvilConfig::builder("h")
650            .custom_known_hosts(PathBuf::from("/from/cli"))
651            .apply_ssh_config(&resolved)
652            .build();
653        assert_eq!(cfg.custom_known_hosts, Some(PathBuf::from("/from/cli")));
654    }
655}