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}
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}