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}
95
96impl AnvilConfig {
97 /// Begin building a config targeting `host`.
98 ///
99 /// All optional fields default to sensible values. No fallback host is
100 /// set by default; use the provider-specific convenience constructors
101 /// ([`github`](Self::github), [`gitlab`](Self::gitlab)) if you want the
102 /// port-443 fallback pre-configured.
103 pub fn builder(host: impl Into<String>) -> AnvilConfigBuilder {
104 AnvilConfigBuilder::new(host.into())
105 }
106
107 /// Convenience constructor for the default GitHub target (`github.com:22`).
108 ///
109 /// Includes the `ssh.github.com:443` fallback pre-configured.
110 #[must_use]
111 pub fn github() -> Self {
112 Self::builder(DEFAULT_GITHUB_HOST)
113 .fallback(Some((GITHUB_FALLBACK_HOST.to_owned(), FALLBACK_PORT)))
114 .build()
115 }
116
117 /// Convenience constructor for the default GitLab target (`gitlab.com:22`).
118 ///
119 /// Includes the `altssh.gitlab.com:443` fallback pre-configured.
120 #[must_use]
121 pub fn gitlab() -> Self {
122 Self::builder(DEFAULT_GITLAB_HOST)
123 .fallback(Some((GITLAB_FALLBACK_HOST.to_owned(), FALLBACK_PORT)))
124 .build()
125 }
126
127 /// Convenience constructor for Codeberg (`codeberg.org:22`).
128 ///
129 /// Codeberg has no published port-443 SSH fallback; no fallback is set.
130 #[must_use]
131 pub fn codeberg() -> Self {
132 Self::builder(DEFAULT_CODEBERG_HOST).build()
133 }
134
135 /// First identity-file path, or `None` if [`Self::identity_files`] is
136 /// empty. Provided as a 0.2.x compatibility shim — new code should
137 /// read [`Self::identity_files`] directly.
138 #[deprecated(since = "0.3.0", note = "read `identity_files` directly")]
139 #[must_use]
140 pub fn identity_file(&self) -> Option<&Path> {
141 self.identity_files.first().map(PathBuf::as_path)
142 }
143
144 /// `true` when [`Self::strict_host_key_checking`] is
145 /// [`StrictHostKeyChecking::No`]. Provided as a 0.2.x compatibility
146 /// shim — new code should read [`Self::strict_host_key_checking`]
147 /// directly.
148 #[deprecated(since = "0.3.0", note = "read `strict_host_key_checking` directly")]
149 #[must_use]
150 pub fn skip_host_check(&self) -> bool {
151 matches!(self.strict_host_key_checking, StrictHostKeyChecking::No)
152 }
153}
154
155// ── Builder ───────────────────────────────────────────────────────────────────
156
157/// Builder for [`AnvilConfig`].
158///
159/// Obtained via [`AnvilConfig::builder`].
160#[derive(Debug)]
161#[must_use]
162pub struct AnvilConfigBuilder {
163 host: String,
164 port: u16,
165 username: String,
166 identity_files: Vec<PathBuf>,
167 cert_file: Option<PathBuf>,
168 strict_host_key_checking: StrictHostKeyChecking,
169 inactivity_timeout: Duration,
170 custom_known_hosts: Option<PathBuf>,
171 verbose: bool,
172 fallback: Option<(String, u16)>,
173}
174
175impl AnvilConfigBuilder {
176 fn new(host: String) -> Self {
177 Self {
178 host,
179 port: DEFAULT_PORT,
180 username: "git".to_owned(),
181 identity_files: Vec::new(),
182 cert_file: None,
183 strict_host_key_checking: StrictHostKeyChecking::Yes,
184 // 60 seconds — large enough to survive slow host responses.
185 // Changing this below ~10 s risks spurious timeouts on congested
186 // links.
187 inactivity_timeout: Duration::from_secs(60),
188 custom_known_hosts: None,
189 verbose: false,
190 // No fallback by default; provider-specific convenience
191 // constructors set this when a known fallback exists.
192 fallback: None,
193 }
194 }
195
196 /// Override the target SSH port (default: 22, FR-1).
197 pub fn port(mut self, port: u16) -> Self {
198 self.port = port;
199 self
200 }
201
202 /// Override the remote username (default: `"git"`, FR-13).
203 pub fn username(mut self, username: impl Into<String>) -> Self {
204 self.username = username.into();
205 self
206 }
207
208 /// Append `path` to the ordered identity-file list (FR-9).
209 ///
210 /// Use this to add CLI-supplied keys; ssh_config-supplied keys flow
211 /// in through [`Self::apply_ssh_config`]. Both can coexist; auth
212 /// tries them in the order they were added.
213 pub fn add_identity_file(mut self, path: impl Into<PathBuf>) -> Self {
214 self.identity_files.push(path.into());
215 self
216 }
217
218 /// Replace the entire identity-file list with `paths`. Existing
219 /// entries are discarded.
220 pub fn identity_files(mut self, paths: Vec<PathBuf>) -> Self {
221 self.identity_files = paths;
222 self
223 }
224
225 /// Set a single identity-file path, replacing any existing entries.
226 ///
227 /// 0.2.x compatibility shim. New code should use
228 /// [`Self::add_identity_file`] (additive) or [`Self::identity_files`]
229 /// (replace-all) for clarity.
230 #[deprecated(
231 since = "0.3.0",
232 note = "use `add_identity_file` or `identity_files` for the multi-key API"
233 )]
234 pub fn identity_file(mut self, path: impl Into<PathBuf>) -> Self {
235 self.identity_files.clear();
236 self.identity_files.push(path.into());
237 self
238 }
239
240 /// Set an OpenSSH certificate path (FR-12).
241 pub fn cert_file(mut self, path: impl Into<PathBuf>) -> Self {
242 self.cert_file = Some(path.into());
243 self
244 }
245
246 /// Set the host-key verification policy (FR-8).
247 pub fn strict_host_key_checking(mut self, policy: StrictHostKeyChecking) -> Self {
248 self.strict_host_key_checking = policy;
249 self
250 }
251
252 /// Disable host-key verification. **Use only for emergencies** (FR-8).
253 ///
254 /// `true` maps to [`StrictHostKeyChecking::No`]; `false` to
255 /// [`StrictHostKeyChecking::Yes`]. Lossless from the 0.2.x boolean
256 /// shape (which only encoded those two states).
257 #[deprecated(
258 since = "0.3.0",
259 note = "use `strict_host_key_checking(StrictHostKeyChecking::No)` for clarity"
260 )]
261 pub fn skip_host_check(mut self, skip: bool) -> Self {
262 self.strict_host_key_checking = if skip {
263 StrictHostKeyChecking::No
264 } else {
265 StrictHostKeyChecking::Yes
266 };
267 self
268 }
269
270 /// Override the session inactivity timeout (FR-5).
271 pub fn inactivity_timeout(mut self, timeout: Duration) -> Self {
272 self.inactivity_timeout = timeout;
273 self
274 }
275
276 /// Path to a custom `known_hosts`-style file for self-hosted instances
277 /// (FR-7).
278 pub fn custom_known_hosts(mut self, path: impl Into<PathBuf>) -> Self {
279 self.custom_known_hosts = Some(path.into());
280 self
281 }
282
283 /// Enable verbose debug logging.
284 pub fn verbose(mut self, verbose: bool) -> Self {
285 self.verbose = verbose;
286 self
287 }
288
289 /// Override the fallback host/port. Pass `None` to disable fallback.
290 pub fn fallback(mut self, fallback: Option<(String, u16)>) -> Self {
291 self.fallback = fallback;
292 self
293 }
294
295 /// Layer values from a [`ResolvedSshConfig`] into this builder.
296 ///
297 /// Provides ssh_config-derived defaults that subsequent builder calls
298 /// can still override (call this *before* CLI-derived overrides if
299 /// you want CLI to win). The following mappings are applied:
300 ///
301 /// | `ssh_config` directive | Builder field |
302 /// |---|---|
303 /// | `HostName` | `host` (overridden) |
304 /// | `Port` | `port` (overridden) |
305 /// | `User` | `username` (overridden) |
306 /// | `IdentityFile` (multi) | `identity_files` (extended) |
307 /// | `StrictHostKeyChecking` | `strict_host_key_checking` (overridden) |
308 /// | `UserKnownHostsFile` (first) | `custom_known_hosts` (filled if `None`) |
309 ///
310 /// Algorithm directives (`HostKeyAlgorithms`, `KexAlgorithms`,
311 /// `Ciphers`, `MACs`) and `ConnectTimeout` / `ConnectionAttempts` are
312 /// not yet plumbed through to the session builder; they are recorded
313 /// in [`ResolvedSshConfig`] for `gitway config show` but consumption
314 /// is deferred to M17 / M18.
315 pub fn apply_ssh_config(mut self, resolved: &ResolvedSshConfig) -> Self {
316 if let Some(hostname) = &resolved.hostname {
317 self.host.clone_from(hostname);
318 }
319 if let Some(port) = resolved.port {
320 self.port = port;
321 }
322 if let Some(user) = &resolved.user {
323 self.username.clone_from(user);
324 }
325 self.identity_files
326 .extend(resolved.identity_files.iter().cloned());
327 if let Some(policy) = resolved.strict_host_key_checking {
328 self.strict_host_key_checking = policy;
329 }
330 if self.custom_known_hosts.is_none() {
331 if let Some(p) = resolved.user_known_hosts_files.first() {
332 self.custom_known_hosts = Some(p.clone());
333 }
334 }
335 warn_unhonored_directives(resolved);
336 self
337 }
338
339 // (Unhonored-directives warning helper lives below `impl` block.)
340
341 /// Finalise and return the [`AnvilConfig`].
342 #[must_use]
343 pub fn build(self) -> AnvilConfig {
344 AnvilConfig {
345 host: self.host,
346 port: self.port,
347 username: self.username,
348 identity_files: self.identity_files,
349 cert_file: self.cert_file,
350 strict_host_key_checking: self.strict_host_key_checking,
351 inactivity_timeout: self.inactivity_timeout,
352 custom_known_hosts: self.custom_known_hosts,
353 verbose: self.verbose,
354 fallback: self.fallback,
355 }
356 }
357}
358
359/// Emits a single `log::warn!` listing any directives in `resolved` that
360/// the resolver successfully parsed but Anvil does not yet honor. Called
361/// from [`AnvilConfigBuilder::apply_ssh_config`] so the warning fires
362/// when a config is actually being prepared for connection — `gitway
363/// config show` and similar inspection callers do not trigger it.
364///
365/// The split:
366/// - `HostKeyAlgorithms`, `KexAlgorithms`, `Ciphers`, `MACs` — parsed
367/// into [`ResolvedSshConfig`] for `gitway config show`, but
368/// [`crate::session::build_russh_config`] uses hardcoded preferences
369/// today. M17 plumbs the `+`/`-`/`^` modifier semantics through to
370/// russh's preference list.
371/// - `ConnectTimeout`, `ConnectionAttempts` — parsed but not yet wired
372/// into `connect()`. M18 honors them.
373fn warn_unhonored_directives(resolved: &ResolvedSshConfig) {
374 let mut m17: Vec<&'static str> = Vec::new();
375 if resolved.host_key_algorithms.is_some() {
376 m17.push("HostKeyAlgorithms");
377 }
378 if resolved.kex_algorithms.is_some() {
379 m17.push("KexAlgorithms");
380 }
381 if resolved.ciphers.is_some() {
382 m17.push("Ciphers");
383 }
384 if resolved.macs.is_some() {
385 m17.push("MACs");
386 }
387
388 let mut m18: Vec<&'static str> = Vec::new();
389 if resolved.connect_timeout.is_some() {
390 m18.push("ConnectTimeout");
391 }
392 if resolved.connection_attempts.is_some() {
393 m18.push("ConnectionAttempts");
394 }
395
396 if !m17.is_empty() {
397 log::warn!(
398 "ssh_config: directive(s) {} parsed but not yet honored \
399 (landing in M17 — Gitway PRD §8); current connections use \
400 Anvil's hardcoded algorithm preferences",
401 m17.join(", "),
402 );
403 }
404 if !m18.is_empty() {
405 log::warn!(
406 "ssh_config: directive(s) {} parsed but not yet honored \
407 (landing in M18 — Gitway PRD §8)",
408 m18.join(", "),
409 );
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn builder_defaults_yes_strict_host_check() {
419 let cfg = AnvilConfig::builder("h").build();
420 assert_eq!(cfg.strict_host_key_checking, StrictHostKeyChecking::Yes);
421 assert!(cfg.identity_files.is_empty());
422 }
423
424 #[test]
425 fn add_identity_file_accumulates() {
426 let cfg = AnvilConfig::builder("h")
427 .add_identity_file(PathBuf::from("/a"))
428 .add_identity_file(PathBuf::from("/b"))
429 .build();
430 assert_eq!(
431 cfg.identity_files,
432 vec![PathBuf::from("/a"), PathBuf::from("/b")],
433 );
434 }
435
436 #[test]
437 fn identity_files_replaces_list() {
438 let cfg = AnvilConfig::builder("h")
439 .add_identity_file(PathBuf::from("/old"))
440 .identity_files(vec![PathBuf::from("/new1"), PathBuf::from("/new2")])
441 .build();
442 assert_eq!(
443 cfg.identity_files,
444 vec![PathBuf::from("/new1"), PathBuf::from("/new2")],
445 );
446 }
447
448 #[test]
449 #[allow(deprecated, reason = "exercising the deprecated shim")]
450 fn deprecated_identity_file_shim_clears_then_pushes() {
451 let cfg = AnvilConfig::builder("h")
452 .add_identity_file(PathBuf::from("/should_be_cleared"))
453 .identity_file(PathBuf::from("/single"))
454 .build();
455 assert_eq!(cfg.identity_files, vec![PathBuf::from("/single")]);
456 // The deprecated accessor returns the first identity file.
457 assert_eq!(cfg.identity_file(), Some(Path::new("/single")));
458 }
459
460 #[test]
461 #[allow(deprecated, reason = "exercising the deprecated shim")]
462 fn deprecated_skip_host_check_maps_to_enum() {
463 let cfg_skip = AnvilConfig::builder("h").skip_host_check(true).build();
464 assert_eq!(cfg_skip.strict_host_key_checking, StrictHostKeyChecking::No);
465 assert!(cfg_skip.skip_host_check());
466
467 let cfg_check = AnvilConfig::builder("h").skip_host_check(false).build();
468 assert_eq!(
469 cfg_check.strict_host_key_checking,
470 StrictHostKeyChecking::Yes,
471 );
472 assert!(!cfg_check.skip_host_check());
473 }
474
475 #[test]
476 fn strict_host_key_checking_accepts_all_three() {
477 for policy in [
478 StrictHostKeyChecking::Yes,
479 StrictHostKeyChecking::No,
480 StrictHostKeyChecking::AcceptNew,
481 ] {
482 let cfg = AnvilConfig::builder("h")
483 .strict_host_key_checking(policy)
484 .build();
485 assert_eq!(cfg.strict_host_key_checking, policy);
486 }
487 }
488
489 #[test]
490 fn apply_ssh_config_layers_resolved_values() {
491 let resolved = ResolvedSshConfig {
492 hostname: Some("real.example.com".to_owned()),
493 user: Some("alice".to_owned()),
494 port: Some(2222),
495 identity_files: vec![PathBuf::from("/cfg/key")],
496 strict_host_key_checking: Some(StrictHostKeyChecking::AcceptNew),
497 user_known_hosts_files: vec![PathBuf::from("/cfg/known_hosts")],
498 ..ResolvedSshConfig::default()
499 };
500 let cfg = AnvilConfig::builder("alias")
501 .apply_ssh_config(&resolved)
502 .build();
503 assert_eq!(cfg.host, "real.example.com");
504 assert_eq!(cfg.port, 2222);
505 assert_eq!(cfg.username, "alice");
506 assert_eq!(cfg.identity_files, vec![PathBuf::from("/cfg/key")]);
507 assert_eq!(
508 cfg.strict_host_key_checking,
509 StrictHostKeyChecking::AcceptNew,
510 );
511 assert_eq!(
512 cfg.custom_known_hosts,
513 Some(PathBuf::from("/cfg/known_hosts"))
514 );
515 }
516
517 #[test]
518 fn apply_ssh_config_extends_identity_files_does_not_replace() {
519 let resolved = ResolvedSshConfig {
520 identity_files: vec![PathBuf::from("/cfg/a")],
521 ..ResolvedSshConfig::default()
522 };
523 let cfg = AnvilConfig::builder("h")
524 .add_identity_file(PathBuf::from("/cli/first"))
525 .apply_ssh_config(&resolved)
526 .build();
527 // CLI ones come first, ssh_config appends after.
528 assert_eq!(
529 cfg.identity_files,
530 vec![PathBuf::from("/cli/first"), PathBuf::from("/cfg/a")],
531 );
532 }
533
534 #[test]
535 fn apply_ssh_config_does_not_overwrite_explicit_known_hosts() {
536 let resolved = ResolvedSshConfig {
537 user_known_hosts_files: vec![PathBuf::from("/from/cfg")],
538 ..ResolvedSshConfig::default()
539 };
540 let cfg = AnvilConfig::builder("h")
541 .custom_known_hosts(PathBuf::from("/from/cli"))
542 .apply_ssh_config(&resolved)
543 .build();
544 assert_eq!(cfg.custom_known_hosts, Some(PathBuf::from("/from/cli")));
545 }
546}