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}