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    /// Per-attempt TCP connect timeout (PRD §5.8.7 FR-80).  `None`
112    /// disables the timeout (matches OpenSSH's "no `ConnectTimeout`"
113    /// semantics).
114    pub connect_timeout: Option<Duration>,
115    /// Total number of connection attempts including the initial one
116    /// (PRD §5.8.7 FR-80).  `None` selects the curated default
117    /// ([`crate::retry::RetryPolicy`]'s `attempts = 3`).
118    pub connection_attempts: Option<u32>,
119    /// Hard ceiling on total elapsed wall-clock time across all
120    /// retry attempts (PRD §5.8.7 FR-81).  `None` selects the
121    /// curated default (30 s).  CLI-only — not part of OpenSSH's
122    /// `ssh_config(5)` grammar.
123    pub max_retry_window: Option<Duration>,
124}
125
126impl AnvilConfig {
127    /// Begin building a config targeting `host`.
128    ///
129    /// All optional fields default to sensible values. No fallback host is
130    /// set by default; use the provider-specific convenience constructors
131    /// ([`github`](Self::github), [`gitlab`](Self::gitlab)) if you want the
132    /// port-443 fallback pre-configured.
133    pub fn builder(host: impl Into<String>) -> AnvilConfigBuilder {
134        AnvilConfigBuilder::new(host.into())
135    }
136
137    /// Convenience constructor for the default GitHub target (`github.com:22`).
138    ///
139    /// Includes the `ssh.github.com:443` fallback pre-configured.
140    #[must_use]
141    pub fn github() -> Self {
142        Self::builder(DEFAULT_GITHUB_HOST)
143            .fallback(Some((GITHUB_FALLBACK_HOST.to_owned(), FALLBACK_PORT)))
144            .build()
145    }
146
147    /// Convenience constructor for the default GitLab target (`gitlab.com:22`).
148    ///
149    /// Includes the `altssh.gitlab.com:443` fallback pre-configured.
150    #[must_use]
151    pub fn gitlab() -> Self {
152        Self::builder(DEFAULT_GITLAB_HOST)
153            .fallback(Some((GITLAB_FALLBACK_HOST.to_owned(), FALLBACK_PORT)))
154            .build()
155    }
156
157    /// Convenience constructor for Codeberg (`codeberg.org:22`).
158    ///
159    /// Codeberg has no published port-443 SSH fallback; no fallback is set.
160    #[must_use]
161    pub fn codeberg() -> Self {
162        Self::builder(DEFAULT_CODEBERG_HOST).build()
163    }
164
165    /// First identity-file path, or `None` if [`Self::identity_files`] is
166    /// empty.  Provided as a 0.2.x compatibility shim — new code should
167    /// read [`Self::identity_files`] directly.
168    #[deprecated(since = "0.3.0", note = "read `identity_files` directly")]
169    #[must_use]
170    pub fn identity_file(&self) -> Option<&Path> {
171        self.identity_files.first().map(PathBuf::as_path)
172    }
173
174    /// `true` when [`Self::strict_host_key_checking`] is
175    /// [`StrictHostKeyChecking::No`].  Provided as a 0.2.x compatibility
176    /// shim — new code should read [`Self::strict_host_key_checking`]
177    /// directly.
178    #[deprecated(since = "0.3.0", note = "read `strict_host_key_checking` directly")]
179    #[must_use]
180    pub fn skip_host_check(&self) -> bool {
181        matches!(self.strict_host_key_checking, StrictHostKeyChecking::No)
182    }
183}
184
185// ── Builder ───────────────────────────────────────────────────────────────────
186
187/// Builder for [`AnvilConfig`].
188///
189/// Obtained via [`AnvilConfig::builder`].
190#[derive(Debug)]
191#[must_use]
192pub struct AnvilConfigBuilder {
193    host: String,
194    port: u16,
195    username: String,
196    identity_files: Vec<PathBuf>,
197    cert_file: Option<PathBuf>,
198    strict_host_key_checking: StrictHostKeyChecking,
199    inactivity_timeout: Duration,
200    custom_known_hosts: Option<PathBuf>,
201    verbose: bool,
202    fallback: Option<(String, u16)>,
203    kex_algorithms: Option<Vec<String>>,
204    ciphers: Option<Vec<String>>,
205    macs: Option<Vec<String>>,
206    host_key_algorithms: Option<Vec<String>>,
207    connect_timeout: Option<Duration>,
208    connection_attempts: Option<u32>,
209    max_retry_window: Option<Duration>,
210}
211
212impl AnvilConfigBuilder {
213    fn new(host: String) -> Self {
214        Self {
215            host,
216            port: DEFAULT_PORT,
217            username: "git".to_owned(),
218            identity_files: Vec::new(),
219            cert_file: None,
220            strict_host_key_checking: StrictHostKeyChecking::Yes,
221            // 60 seconds — large enough to survive slow host responses.
222            // Changing this below ~10 s risks spurious timeouts on congested
223            // links.
224            inactivity_timeout: Duration::from_secs(60),
225            custom_known_hosts: None,
226            verbose: false,
227            // No fallback by default; provider-specific convenience
228            // constructors set this when a known fallback exists.
229            fallback: None,
230            // Algorithm preferences default to None (= use the
231            // curated `algorithms::anvil_default_*` lists at session
232            // build time).  M17.4 CLI flags overwrite these via the
233            // four setters below.
234            kex_algorithms: None,
235            ciphers: None,
236            macs: None,
237            host_key_algorithms: None,
238            // Retry / timeout knobs default to None.  At session
239            // build time, None falls through to
240            // `crate::retry::RetryPolicy::default()` (3 attempts,
241            // 250 ms base, 30 s max_window, no connect timeout) —
242            // matches OpenSSH's defaults except for the new
243            // max_window cap which is Gitway-specific.
244            connect_timeout: None,
245            connection_attempts: None,
246            max_retry_window: None,
247        }
248    }
249
250    /// Override the target SSH port (default: 22, FR-1).
251    pub fn port(mut self, port: u16) -> Self {
252        self.port = port;
253        self
254    }
255
256    /// Override the remote username (default: `"git"`, FR-13).
257    pub fn username(mut self, username: impl Into<String>) -> Self {
258        self.username = username.into();
259        self
260    }
261
262    /// Append `path` to the ordered identity-file list (FR-9).
263    ///
264    /// Use this to add CLI-supplied keys; ssh_config-supplied keys flow
265    /// in through [`Self::apply_ssh_config`].  Both can coexist; auth
266    /// tries them in the order they were added.
267    pub fn add_identity_file(mut self, path: impl Into<PathBuf>) -> Self {
268        self.identity_files.push(path.into());
269        self
270    }
271
272    /// Replace the entire identity-file list with `paths`.  Existing
273    /// entries are discarded.
274    pub fn identity_files(mut self, paths: Vec<PathBuf>) -> Self {
275        self.identity_files = paths;
276        self
277    }
278
279    /// Set a single identity-file path, replacing any existing entries.
280    ///
281    /// 0.2.x compatibility shim.  New code should use
282    /// [`Self::add_identity_file`] (additive) or [`Self::identity_files`]
283    /// (replace-all) for clarity.
284    #[deprecated(
285        since = "0.3.0",
286        note = "use `add_identity_file` or `identity_files` for the multi-key API"
287    )]
288    pub fn identity_file(mut self, path: impl Into<PathBuf>) -> Self {
289        self.identity_files.clear();
290        self.identity_files.push(path.into());
291        self
292    }
293
294    /// Set an OpenSSH certificate path (FR-12).
295    pub fn cert_file(mut self, path: impl Into<PathBuf>) -> Self {
296        self.cert_file = Some(path.into());
297        self
298    }
299
300    /// Set the host-key verification policy (FR-8).
301    pub fn strict_host_key_checking(mut self, policy: StrictHostKeyChecking) -> Self {
302        self.strict_host_key_checking = policy;
303        self
304    }
305
306    /// Disable host-key verification.  **Use only for emergencies** (FR-8).
307    ///
308    /// `true` maps to [`StrictHostKeyChecking::No`]; `false` to
309    /// [`StrictHostKeyChecking::Yes`].  Lossless from the 0.2.x boolean
310    /// shape (which only encoded those two states).
311    #[deprecated(
312        since = "0.3.0",
313        note = "use `strict_host_key_checking(StrictHostKeyChecking::No)` for clarity"
314    )]
315    pub fn skip_host_check(mut self, skip: bool) -> Self {
316        self.strict_host_key_checking = if skip {
317            StrictHostKeyChecking::No
318        } else {
319            StrictHostKeyChecking::Yes
320        };
321        self
322    }
323
324    /// Override the session inactivity timeout (FR-5).
325    pub fn inactivity_timeout(mut self, timeout: Duration) -> Self {
326        self.inactivity_timeout = timeout;
327        self
328    }
329
330    /// Path to a custom `known_hosts`-style file for self-hosted instances
331    /// (FR-7).
332    pub fn custom_known_hosts(mut self, path: impl Into<PathBuf>) -> Self {
333        self.custom_known_hosts = Some(path.into());
334        self
335    }
336
337    /// Enable verbose debug logging.
338    pub fn verbose(mut self, verbose: bool) -> Self {
339        self.verbose = verbose;
340        self
341    }
342
343    /// Override the fallback host/port.  Pass `None` to disable fallback.
344    pub fn fallback(mut self, fallback: Option<(String, u16)>) -> Self {
345        self.fallback = fallback;
346        self
347    }
348
349    /// Override the key-exchange algorithm preference (PRD §5.8.6 FR-76).
350    ///
351    /// Pass `None` to keep the curated default
352    /// ([`crate::algorithms::anvil_default_kex`]).  The list is
353    /// expected to have already passed through
354    /// [`crate::algorithms::apply_overrides`] so any
355    /// `+`/`-`/`^` prefix is resolved and the FR-78 denylist applied.
356    pub fn kex_algorithms(mut self, list: Option<Vec<String>>) -> Self {
357        self.kex_algorithms = list;
358        self
359    }
360
361    /// Override the cipher preference (PRD §5.8.6 FR-76).
362    pub fn ciphers(mut self, list: Option<Vec<String>>) -> Self {
363        self.ciphers = list;
364        self
365    }
366
367    /// Override the MAC preference (PRD §5.8.6 FR-76).
368    pub fn macs(mut self, list: Option<Vec<String>>) -> Self {
369        self.macs = list;
370        self
371    }
372
373    /// Override the host-key algorithm preference (PRD §5.8.6 FR-76).
374    pub fn host_key_algorithms(mut self, list: Option<Vec<String>>) -> Self {
375        self.host_key_algorithms = list;
376        self
377    }
378
379    /// Override the per-attempt TCP connect timeout (PRD §5.8.7
380    /// FR-80).  `None` disables the timeout.  CLI overrides this
381    /// AFTER `apply_ssh_config` so flags beat config (matches
382    /// OpenSSH precedence).
383    pub fn connect_timeout(mut self, timeout: Option<Duration>) -> Self {
384        self.connect_timeout = timeout;
385        self
386    }
387
388    /// Override the total connection-attempt count (PRD §5.8.7
389    /// FR-80).  `None` selects the curated default (3).
390    pub fn connection_attempts(mut self, attempts: Option<u32>) -> Self {
391        self.connection_attempts = attempts;
392        self
393    }
394
395    /// Override the wall-clock cap on total retry time (PRD
396    /// §5.8.7 FR-81).  `None` selects the curated default (30 s).
397    /// Not part of OpenSSH's `ssh_config(5)` grammar — CLI-only.
398    pub fn max_retry_window(mut self, window: Option<Duration>) -> Self {
399        self.max_retry_window = window;
400        self
401    }
402
403    /// Layer values from a [`ResolvedSshConfig`] into this builder.
404    ///
405    /// Provides ssh_config-derived defaults that subsequent builder calls
406    /// can still override (call this *before* CLI-derived overrides if
407    /// you want CLI to win).  The following mappings are applied:
408    ///
409    /// | `ssh_config` directive | Builder field |
410    /// |---|---|
411    /// | `HostName` | `host` (overridden) |
412    /// | `Port` | `port` (overridden) |
413    /// | `User` | `username` (overridden) |
414    /// | `IdentityFile` (multi) | `identity_files` (extended) |
415    /// | `StrictHostKeyChecking` | `strict_host_key_checking` (overridden) |
416    /// | `UserKnownHostsFile` (first) | `custom_known_hosts` (filled if `None`) |
417    ///
418    /// Algorithm directives (`HostKeyAlgorithms`, `KexAlgorithms`,
419    /// `Ciphers`, `MACs`) are honored as of M17 (PRD §5.8.6 FR-76):
420    /// each parsed `AlgList` is fed through
421    /// [`crate::algorithms::apply_overrides`] against the matching
422    /// curated default, so an `ssh_config` value of `+algo,algo`
423    /// appends to Anvil's defaults rather than replacing them.
424    /// `ConnectTimeout` / `ConnectionAttempts` remain deferred to
425    /// M18.
426    ///
427    /// # Errors
428    ///
429    /// This method is infallible by signature, but a malformed
430    /// algorithm list (denylisted entry referenced by an override)
431    /// is logged at `warn` level and silently dropped — the
432    /// connection then falls back to the curated default.  Callers
433    /// who want strict validation should run the same value
434    /// through [`crate::algorithms::apply_overrides`] explicitly
435    /// before calling here.
436    pub fn apply_ssh_config(mut self, resolved: &ResolvedSshConfig) -> Self {
437        if let Some(hostname) = &resolved.hostname {
438            self.host.clone_from(hostname);
439        }
440        if let Some(port) = resolved.port {
441            self.port = port;
442        }
443        if let Some(user) = &resolved.user {
444            self.username.clone_from(user);
445        }
446        self.identity_files
447            .extend(resolved.identity_files.iter().cloned());
448        if let Some(policy) = resolved.strict_host_key_checking {
449            self.strict_host_key_checking = policy;
450        }
451        if self.custom_known_hosts.is_none() {
452            if let Some(p) = resolved.user_known_hosts_files.first() {
453                self.custom_known_hosts = Some(p.clone());
454            }
455        }
456        // M17 / FR-76: plumb algorithm directives through the
457        // `+`/`-`/`^` parser against Anvil's curated defaults.  CLI
458        // overrides applied AFTER `apply_ssh_config` win over the
459        // ssh_config-derived value (matches OpenSSH precedence).
460        self.apply_alg_directive(
461            crate::algorithms::AlgCategory::Kex,
462            resolved.kex_algorithms.as_ref(),
463            crate::algorithms::anvil_default_kex,
464            |b, v| b.kex_algorithms = Some(v),
465        );
466        self.apply_alg_directive(
467            crate::algorithms::AlgCategory::Cipher,
468            resolved.ciphers.as_ref(),
469            crate::algorithms::anvil_default_ciphers,
470            |b, v| b.ciphers = Some(v),
471        );
472        self.apply_alg_directive(
473            crate::algorithms::AlgCategory::Mac,
474            resolved.macs.as_ref(),
475            crate::algorithms::anvil_default_macs,
476            |b, v| b.macs = Some(v),
477        );
478        self.apply_alg_directive(
479            crate::algorithms::AlgCategory::HostKey,
480            resolved.host_key_algorithms.as_ref(),
481            crate::algorithms::anvil_default_host_keys,
482            |b, v| b.host_key_algorithms = Some(v),
483        );
484
485        // M18 / FR-80: ConnectTimeout + ConnectionAttempts from
486        // ssh_config flow through to the retry-policy fields.  CLI
487        // overrides applied AFTER apply_ssh_config win over these
488        // (matches OpenSSH precedence).  Don't clobber a value
489        // already set on the builder — that's how the CLI-wins
490        // precedence is achieved.
491        if self.connect_timeout.is_none() {
492            if let Some(d) = resolved.connect_timeout {
493                self.connect_timeout = Some(d);
494            }
495        }
496        if self.connection_attempts.is_none() {
497            if let Some(n) = resolved.connection_attempts {
498                self.connection_attempts = Some(n);
499            }
500        }
501        self
502    }
503
504    /// Internal helper for `apply_ssh_config`.  Reads one algorithm
505    /// directive from the resolved config, runs it through
506    /// `apply_overrides` against the curated default, and stores the
507    /// result via `setter`.  Malformed values (denylisted entries)
508    /// log a warning and leave the field on its `None` default so
509    /// the curated list is used at session-build time.
510    fn apply_alg_directive(
511        &mut self,
512        category: crate::algorithms::AlgCategory,
513        directive: Option<&crate::ssh_config::AlgList>,
514        default_fn: fn() -> Vec<String>,
515        setter: fn(&mut Self, Vec<String>),
516    ) {
517        let Some(crate::ssh_config::AlgList(value)) = directive else {
518            return;
519        };
520        match crate::algorithms::apply_overrides(category, default_fn(), value) {
521            Ok(list) => setter(self, list),
522            Err(e) => {
523                log::warn!(
524                    "ssh_config {category} directive '{value}' rejected: {e} \
525                     (falling back to Anvil curated default)",
526                    category = category.label(),
527                );
528            }
529        }
530    }
531
532    // (Unhonored-directives warning helper lives below `impl` block.)
533
534    /// Finalise and return the [`AnvilConfig`].
535    #[must_use]
536    pub fn build(self) -> AnvilConfig {
537        AnvilConfig {
538            host: self.host,
539            port: self.port,
540            username: self.username,
541            identity_files: self.identity_files,
542            cert_file: self.cert_file,
543            strict_host_key_checking: self.strict_host_key_checking,
544            inactivity_timeout: self.inactivity_timeout,
545            custom_known_hosts: self.custom_known_hosts,
546            verbose: self.verbose,
547            fallback: self.fallback,
548            kex_algorithms: self.kex_algorithms,
549            ciphers: self.ciphers,
550            macs: self.macs,
551            host_key_algorithms: self.host_key_algorithms,
552            connect_timeout: self.connect_timeout,
553            connection_attempts: self.connection_attempts,
554            max_retry_window: self.max_retry_window,
555        }
556    }
557}
558
559// Note: the `warn_unhonored_directives` helper that lived here from
560// M12.6 → M17.2 → M18.2 was removed in M18.2.  Every `ssh_config(5)`
561// directive Anvil's resolver parses today is now consumed by
562// `apply_ssh_config` (HostKeyAlgorithms / KexAlgorithms / Ciphers /
563// MACs landed in M17; ConnectTimeout / ConnectionAttempts in M18).
564// Future deferral warnings should reintroduce a similar helper if a
565// new milestone parses-but-doesn't-yet-consume a directive.
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn builder_defaults_yes_strict_host_check() {
573        let cfg = AnvilConfig::builder("h").build();
574        assert_eq!(cfg.strict_host_key_checking, StrictHostKeyChecking::Yes);
575        assert!(cfg.identity_files.is_empty());
576    }
577
578    #[test]
579    fn add_identity_file_accumulates() {
580        let cfg = AnvilConfig::builder("h")
581            .add_identity_file(PathBuf::from("/a"))
582            .add_identity_file(PathBuf::from("/b"))
583            .build();
584        assert_eq!(
585            cfg.identity_files,
586            vec![PathBuf::from("/a"), PathBuf::from("/b")],
587        );
588    }
589
590    #[test]
591    fn identity_files_replaces_list() {
592        let cfg = AnvilConfig::builder("h")
593            .add_identity_file(PathBuf::from("/old"))
594            .identity_files(vec![PathBuf::from("/new1"), PathBuf::from("/new2")])
595            .build();
596        assert_eq!(
597            cfg.identity_files,
598            vec![PathBuf::from("/new1"), PathBuf::from("/new2")],
599        );
600    }
601
602    #[test]
603    #[allow(deprecated, reason = "exercising the deprecated shim")]
604    fn deprecated_identity_file_shim_clears_then_pushes() {
605        let cfg = AnvilConfig::builder("h")
606            .add_identity_file(PathBuf::from("/should_be_cleared"))
607            .identity_file(PathBuf::from("/single"))
608            .build();
609        assert_eq!(cfg.identity_files, vec![PathBuf::from("/single")]);
610        // The deprecated accessor returns the first identity file.
611        assert_eq!(cfg.identity_file(), Some(Path::new("/single")));
612    }
613
614    #[test]
615    #[allow(deprecated, reason = "exercising the deprecated shim")]
616    fn deprecated_skip_host_check_maps_to_enum() {
617        let cfg_skip = AnvilConfig::builder("h").skip_host_check(true).build();
618        assert_eq!(cfg_skip.strict_host_key_checking, StrictHostKeyChecking::No);
619        assert!(cfg_skip.skip_host_check());
620
621        let cfg_check = AnvilConfig::builder("h").skip_host_check(false).build();
622        assert_eq!(
623            cfg_check.strict_host_key_checking,
624            StrictHostKeyChecking::Yes,
625        );
626        assert!(!cfg_check.skip_host_check());
627    }
628
629    #[test]
630    fn strict_host_key_checking_accepts_all_three() {
631        for policy in [
632            StrictHostKeyChecking::Yes,
633            StrictHostKeyChecking::No,
634            StrictHostKeyChecking::AcceptNew,
635        ] {
636            let cfg = AnvilConfig::builder("h")
637                .strict_host_key_checking(policy)
638                .build();
639            assert_eq!(cfg.strict_host_key_checking, policy);
640        }
641    }
642
643    #[test]
644    fn apply_ssh_config_layers_resolved_values() {
645        let resolved = ResolvedSshConfig {
646            hostname: Some("real.example.com".to_owned()),
647            user: Some("alice".to_owned()),
648            port: Some(2222),
649            identity_files: vec![PathBuf::from("/cfg/key")],
650            strict_host_key_checking: Some(StrictHostKeyChecking::AcceptNew),
651            user_known_hosts_files: vec![PathBuf::from("/cfg/known_hosts")],
652            ..ResolvedSshConfig::default()
653        };
654        let cfg = AnvilConfig::builder("alias")
655            .apply_ssh_config(&resolved)
656            .build();
657        assert_eq!(cfg.host, "real.example.com");
658        assert_eq!(cfg.port, 2222);
659        assert_eq!(cfg.username, "alice");
660        assert_eq!(cfg.identity_files, vec![PathBuf::from("/cfg/key")]);
661        assert_eq!(
662            cfg.strict_host_key_checking,
663            StrictHostKeyChecking::AcceptNew,
664        );
665        assert_eq!(
666            cfg.custom_known_hosts,
667            Some(PathBuf::from("/cfg/known_hosts"))
668        );
669    }
670
671    #[test]
672    fn apply_ssh_config_extends_identity_files_does_not_replace() {
673        let resolved = ResolvedSshConfig {
674            identity_files: vec![PathBuf::from("/cfg/a")],
675            ..ResolvedSshConfig::default()
676        };
677        let cfg = AnvilConfig::builder("h")
678            .add_identity_file(PathBuf::from("/cli/first"))
679            .apply_ssh_config(&resolved)
680            .build();
681        // CLI ones come first, ssh_config appends after.
682        assert_eq!(
683            cfg.identity_files,
684            vec![PathBuf::from("/cli/first"), PathBuf::from("/cfg/a")],
685        );
686    }
687
688    #[test]
689    fn apply_ssh_config_does_not_overwrite_explicit_known_hosts() {
690        let resolved = ResolvedSshConfig {
691            user_known_hosts_files: vec![PathBuf::from("/from/cfg")],
692            ..ResolvedSshConfig::default()
693        };
694        let cfg = AnvilConfig::builder("h")
695            .custom_known_hosts(PathBuf::from("/from/cli"))
696            .apply_ssh_config(&resolved)
697            .build();
698        assert_eq!(cfg.custom_known_hosts, Some(PathBuf::from("/from/cli")));
699    }
700}