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}