Skip to main content

anvil_ssh/ssh_config/
resolver.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-03-30
3//! Public `resolve()` entry point and the [`ResolvedSshConfig`] result.
4//!
5//! Wires the lexer, include resolver, parser, and host matcher together
6//! into one call and applies "first occurrence wins" semantics across the
7//! matched directives, mirroring `ssh_config(5)` and OpenSSH's
8//! `read_config_file` flow.
9//!
10//! This is the only module in `ssh_config` whose surface is publicly
11//! re-exported from the crate root; the rest are crate-private building
12//! blocks.
13
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use super::include::expand_includes;
18use super::lexer::{expand_env, expand_tilde, tokenize};
19use super::matcher::directives_for_host;
20use super::parser::{parse, Directive, HostBlock};
21use crate::error::AnvilError;
22
23/// Locations of the `ssh_config` files to read during a [`resolve`] call.
24///
25/// Both fields are optional so callers can disable either tier (e.g.
26/// `gitway --no-config`) or supply an isolated config file for testing.
27///
28/// Paths are expected to be absolute or already tilde-expanded; relative
29/// paths are read relative to the current working directory.  The leading
30/// `~` is expanded automatically as a courtesy.
31#[derive(Debug, Clone, Default, PartialEq, Eq)]
32pub struct SshConfigPaths {
33    /// User-level config, typically `~/.ssh/config` on Unix and
34    /// `%USERPROFILE%\.ssh\config` on Windows.  `None` skips reading it.
35    pub user: Option<PathBuf>,
36
37    /// System-level config, typically `/etc/ssh/ssh_config` on Unix and
38    /// `%PROGRAMDATA%\ssh\ssh_config` on Windows.  `None` skips reading.
39    pub system: Option<PathBuf>,
40}
41
42impl SshConfigPaths {
43    /// Returns the platform-default paths.
44    ///
45    /// On Unix: `~/.ssh/config` (user) and `/etc/ssh/ssh_config` (system).
46    /// On Windows: `%USERPROFILE%\.ssh\config` (user) and
47    /// `%PROGRAMDATA%\ssh\ssh_config` (system, if `%PROGRAMDATA%` is set).
48    /// On other platforms: user only, system `None`.
49    #[must_use]
50    pub fn default_paths() -> Self {
51        let user = dirs::home_dir().map(|h| h.join(".ssh").join("config"));
52        let system = if cfg!(unix) {
53            Some(PathBuf::from("/etc/ssh/ssh_config"))
54        } else if cfg!(windows) {
55            std::env::var_os("ProgramData").map(|pd| {
56                let mut p = PathBuf::from(pd);
57                p.push("ssh");
58                p.push("ssh_config");
59                p
60            })
61        } else {
62            None
63        };
64        Self { user, system }
65    }
66
67    /// Disables both tiers — reads no config files.  Equivalent to the
68    /// `--no-config` CLI flag wired up by Gitway in M12.7.
69    #[must_use]
70    pub fn none() -> Self {
71        Self::default()
72    }
73}
74
75/// `StrictHostKeyChecking` directive value.
76///
77/// `ask` — OpenSSH's default that prompts the user — is folded into
78/// [`StrictHostKeyChecking::Yes`] because Anvil never prompts; the
79/// strict-no-unknown semantics are equivalent for our purposes.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum StrictHostKeyChecking {
82    /// `yes` (or `ask`): refuse unknown hosts; refuse mismatches.
83    Yes,
84    /// `no` (or `off`): accept any host key.  Insecure; primarily useful
85    /// for ephemeral test infrastructure.
86    No,
87    /// `accept-new`: accept new host keys (writing to `known_hosts`)
88    /// but refuse mismatches against already-known keys.  M12.5 wires
89    /// the minimal write path; full TOFU UX is post-M12 polish.
90    AcceptNew,
91}
92
93/// A list of algorithm names from a `ssh_config` directive
94/// (`HostKeyAlgorithms`, `KexAlgorithms`, `Ciphers`, `MACs`).
95///
96/// The raw, comma-separated source value is preserved verbatim.  M17
97/// adds the OpenSSH `+`/`-`/`^` modifier semantics on top, plumbed
98/// through to russh's preference list.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct AlgList(pub String);
101
102/// Provenance for one resolved directive — which file and line it came
103/// from.  Used by the `gitway diag` line (NFR-24) and `gitway config
104/// show` to attribute each value back to its source.
105#[derive(Debug, Clone)]
106pub struct DirectiveSource {
107    /// Lower-cased directive keyword (`identityfile`, `port`, ...).
108    pub directive: String,
109    /// The source file the directive was read from (post-Include).
110    pub file: PathBuf,
111    /// 1-indexed line number within `file`.
112    pub line: u32,
113}
114
115/// Fully-resolved `ssh_config` for one host.
116///
117/// Every field is optional or a vector — the resolver applies `Some(_)`
118/// or appends only when it sees a directive whose keyword maps onto the
119/// field; otherwise the field stays at its [`Default`] value.
120///
121/// "First occurrence wins" applies to all single-valued fields per
122/// `ssh_config(5)`.  Multi-valued fields (`identity_files`,
123/// `certificate_files`, `user_known_hosts_files`) accumulate every
124/// occurrence in source order, again matching OpenSSH.
125#[derive(Debug, Clone, Default)]
126pub struct ResolvedSshConfig {
127    /// `HostName` — the real hostname to connect to (may differ from the
128    /// alias the user typed).
129    pub hostname: Option<String>,
130    /// `User` — login name on the remote.
131    pub user: Option<String>,
132    /// `Port` — TCP port.
133    pub port: Option<u16>,
134    /// `IdentityFile` — every `IdentityFile` directive contributes one
135    /// entry here, in source order.
136    pub identity_files: Vec<PathBuf>,
137    /// `IdentitiesOnly` — when `true`, restrict authentication to keys
138    /// listed in `identity_files` (no agent-supplied keys).
139    pub identities_only: Option<bool>,
140    /// `IdentityAgent` — path to a non-default agent socket.
141    pub identity_agent: Option<PathBuf>,
142    /// `CertificateFile` — every entry contributes one path, in source order.
143    pub certificate_files: Vec<PathBuf>,
144    /// `ProxyCommand` — captured raw (joined with single spaces); M13
145    /// parses and spawns it.  The literal value `"none"` (lower-cased)
146    /// is the FR-59 disable sentinel: it is preserved here so that the
147    /// first-occurrence-wins rule shields it from a later wildcard
148    /// override, and so `gitway config show` mirrors `ssh -G`'s output;
149    /// the spawn path treats `Some("none")` as "no proxy".
150    pub proxy_command: Option<String>,
151    /// `ProxyJump` — captured raw; M13 parses the chain.
152    pub proxy_jump: Option<String>,
153    /// `UserKnownHostsFile` — every entry contributes one path.
154    pub user_known_hosts_files: Vec<PathBuf>,
155    /// `StrictHostKeyChecking`.
156    pub strict_host_key_checking: Option<StrictHostKeyChecking>,
157    /// `HostKeyAlgorithms` — raw spec; M17 plumbs through to russh.
158    pub host_key_algorithms: Option<AlgList>,
159    /// `KexAlgorithms` — raw spec; M17 plumbs through.
160    pub kex_algorithms: Option<AlgList>,
161    /// `Ciphers` — raw spec; M17 plumbs through.
162    pub ciphers: Option<AlgList>,
163    /// `MACs` — raw spec; M17 plumbs through.
164    pub macs: Option<AlgList>,
165    /// `ConnectTimeout` — measured in seconds in the source file,
166    /// stored here as a [`Duration`].
167    pub connect_timeout: Option<Duration>,
168    /// `ConnectionAttempts`.
169    pub connection_attempts: Option<u32>,
170    /// One [`DirectiveSource`] entry per directive that contributed to a
171    /// known field, in the order applied.  Preserves provenance for
172    /// `gitway config show` and the `config_source=` diag-line field.
173    pub provenance: Vec<DirectiveSource>,
174}
175
176/// Resolves the effective `ssh_config` for `host` against the files
177/// listed in `paths`.
178///
179/// Reads the user file first, then the system file (per `ssh_config(5)`:
180/// "first obtained value for each parameter is used").  Within each file,
181/// `Include` directives are recursively expanded (see
182/// [`super::include::expand_includes`]) before host matching.
183///
184/// Missing files are silently skipped — only failures to *read* an
185/// existing file (permission denied, malformed UTF-8, etc.) bubble up
186/// as errors.
187///
188/// # Errors
189/// Returns [`AnvilError::invalid_config`] when:
190/// - A read of an existing file fails for reasons other than "not found".
191/// - The file is not valid UTF-8.
192/// - The file is malformed (unterminated quote, `Host` with no patterns,
193///   Include cycle, depth overflow).
194/// - A directive's argument fails to parse (e.g. `Port abc`).
195pub fn resolve(host: &str, paths: &SshConfigPaths) -> Result<ResolvedSshConfig, AnvilError> {
196    let mut all_blocks: Vec<HostBlock> = Vec::new();
197
198    if let Some(user) = &paths.user {
199        let path = expand_path_for_read(user);
200        all_blocks.extend(read_and_parse(&path)?);
201    }
202    if let Some(system) = &paths.system {
203        let path = expand_path_for_read(system);
204        all_blocks.extend(read_and_parse(&path)?);
205    }
206
207    let mut resolved = ResolvedSshConfig::default();
208    if all_blocks.is_empty() {
209        return Ok(resolved);
210    }
211
212    for d in directives_for_host(&all_blocks, host) {
213        apply_directive(d, &mut resolved)?;
214    }
215
216    Ok(resolved)
217}
218
219/// Tilde-expands the path so callers may pass `~/.ssh/config` literally.
220fn expand_path_for_read(path: &Path) -> PathBuf {
221    let s = path.to_string_lossy();
222    PathBuf::from(expand_tilde(&s))
223}
224
225/// Reads, tokenizes, expands Includes, and parses one config file.
226/// Missing files yield an empty block list (no error).
227fn read_and_parse(path: &Path) -> Result<Vec<HostBlock>, AnvilError> {
228    let content = match std::fs::read_to_string(path) {
229        Ok(c) => c,
230        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
231        Err(e) => {
232            return Err(AnvilError::invalid_config(format!(
233                "ssh_config: failed to read {}: {e}",
234                path.display(),
235            )));
236        }
237    };
238    let tokens = tokenize(&content, path)?;
239    let expanded = expand_includes(path, tokens)?;
240    parse(expanded)
241}
242
243/// Applies one directive to `resolved` with first-occurrence-wins
244/// semantics, recording provenance for every recognized directive.
245#[allow(
246    clippy::too_many_lines,
247    reason = "directive dispatch is intentionally one big match for clarity \
248              and easy review; each arm is a few lines and there is no \
249              meaningful sub-grouping"
250)]
251fn apply_directive(d: &Directive, resolved: &mut ResolvedSshConfig) -> Result<(), AnvilError> {
252    let mut recorded = true;
253
254    match d.keyword.as_str() {
255        "hostname" => {
256            if resolved.hostname.is_none() {
257                resolved.hostname = Some(first_arg_required(d)?);
258            }
259        }
260        "user" => {
261            if resolved.user.is_none() {
262                resolved.user = Some(first_arg_required(d)?);
263            }
264        }
265        "port" => {
266            if resolved.port.is_none() {
267                let s = first_arg_required(d)?;
268                resolved.port = Some(s.parse::<u16>().map_err(|e| {
269                    AnvilError::invalid_config(format!(
270                        "ssh_config: invalid Port '{s}' at {}:{}: {e}",
271                        d.file.display(),
272                        d.line_no,
273                    ))
274                })?);
275            }
276        }
277        "identityfile" => {
278            require_at_least_one(d)?;
279            for arg in &d.args {
280                resolved.identity_files.push(expand_path_value(arg));
281            }
282        }
283        "identitiesonly" => {
284            if resolved.identities_only.is_none() {
285                resolved.identities_only = Some(parse_yes_no(d)?);
286            }
287        }
288        "identityagent" => {
289            if resolved.identity_agent.is_none() {
290                let s = first_arg_required(d)?;
291                resolved.identity_agent = Some(expand_path_value(&s));
292            }
293        }
294        "certificatefile" => {
295            require_at_least_one(d)?;
296            for arg in &d.args {
297                resolved.certificate_files.push(expand_path_value(arg));
298            }
299        }
300        "proxycommand" => {
301            if resolved.proxy_command.is_none() {
302                if d.args.is_empty() {
303                    return Err(missing_value_err(d));
304                }
305                // FR-59: `ProxyCommand none` is the OpenSSH idiom for
306                // cancelling a parent block's ProxyCommand without
307                // resorting to ssh_config surgery.  We preserve the
308                // literal token `none` so that:
309                //   1. First-occurrence wins still applies — a later
310                //      `Host *` block's `ProxyCommand foo` cannot
311                //      re-enable the proxy after a `none`, since the
312                //      `is_none()` guard above is false once we set the
313                //      field below.
314                //   2. `gitway config show` prints `proxycommand none`
315                //      faithfully (matches `ssh -G`).
316                //   3. The spawn path (M13.2) recognizes the literal
317                //      and skips spawning, treating the host as direct.
318                // Lower-case the user's input so the spawn path's
319                // recognition is one comparison instead of two.
320                let value = if d.args.len() == 1 && d.args[0].eq_ignore_ascii_case("none") {
321                    "none".to_owned()
322                } else {
323                    // ProxyCommand takes the rest of the line as a shell
324                    // command; the lexer split it on whitespace so we
325                    // re-join.
326                    d.args.join(" ")
327                };
328                resolved.proxy_command = Some(value);
329            }
330        }
331        "proxyjump" => {
332            if resolved.proxy_jump.is_none() {
333                resolved.proxy_jump = Some(first_arg_required(d)?);
334            }
335        }
336        "userknownhostsfile" => {
337            require_at_least_one(d)?;
338            for arg in &d.args {
339                resolved.user_known_hosts_files.push(expand_path_value(arg));
340            }
341        }
342        "stricthostkeychecking" => {
343            if resolved.strict_host_key_checking.is_none() {
344                let s = first_arg_required(d)?;
345                let v = match s.to_ascii_lowercase().as_str() {
346                    // OpenSSH `ask` defaults to interactive prompt; we
347                    // fold to Yes since this crate never prompts.
348                    "yes" | "ask" => StrictHostKeyChecking::Yes,
349                    "no" | "off" => StrictHostKeyChecking::No,
350                    "accept-new" => StrictHostKeyChecking::AcceptNew,
351                    other => {
352                        return Err(AnvilError::invalid_config(format!(
353                            "ssh_config: invalid StrictHostKeyChecking '{other}' at {}:{}",
354                            d.file.display(),
355                            d.line_no,
356                        )));
357                    }
358                };
359                resolved.strict_host_key_checking = Some(v);
360            }
361        }
362        "hostkeyalgorithms" => {
363            if resolved.host_key_algorithms.is_none() {
364                resolved.host_key_algorithms = Some(AlgList(first_arg_required(d)?));
365            }
366        }
367        "kexalgorithms" => {
368            if resolved.kex_algorithms.is_none() {
369                resolved.kex_algorithms = Some(AlgList(first_arg_required(d)?));
370            }
371        }
372        "ciphers" => {
373            if resolved.ciphers.is_none() {
374                resolved.ciphers = Some(AlgList(first_arg_required(d)?));
375            }
376        }
377        "macs" => {
378            if resolved.macs.is_none() {
379                resolved.macs = Some(AlgList(first_arg_required(d)?));
380            }
381        }
382        "connecttimeout" => {
383            if resolved.connect_timeout.is_none() {
384                let s = first_arg_required(d)?;
385                let secs: u64 = s.parse().map_err(|e| {
386                    AnvilError::invalid_config(format!(
387                        "ssh_config: invalid ConnectTimeout '{s}' at {}:{}: {e}",
388                        d.file.display(),
389                        d.line_no,
390                    ))
391                })?;
392                resolved.connect_timeout = Some(Duration::from_secs(secs));
393            }
394        }
395        "connectionattempts" => {
396            if resolved.connection_attempts.is_none() {
397                let s = first_arg_required(d)?;
398                resolved.connection_attempts = Some(s.parse::<u32>().map_err(|e| {
399                    AnvilError::invalid_config(format!(
400                        "ssh_config: invalid ConnectionAttempts '{s}' at {}:{}: {e}",
401                        d.file.display(),
402                        d.line_no,
403                    ))
404                })?);
405            }
406        }
407        _ => {
408            // Unknown / unhandled directive — silently skip.  Many
409            // ssh_config(5) directives are out of scope for Anvil; logging
410            // every one would be noisy.  Trace level only.
411            log::trace!(
412                "ssh_config: ignoring unhandled directive '{}' at {}:{}",
413                d.keyword,
414                d.file.display(),
415                d.line_no,
416            );
417            recorded = false;
418        }
419    }
420
421    if recorded {
422        // FR-66: surface every applied directive at trace level with
423        // its source file + line so an operator running `gitway -vvv`
424        // can trace exactly which `Host` block won which directive.
425        // The `value` field rejoins the args with spaces — same shape
426        // an operator would write in `~/.ssh/config`.
427        tracing::trace!(
428            target: crate::log::CAT_CONFIG,
429            file = %d.file.display(),
430            line = d.line_no,
431            directive = %d.keyword,
432            value = %d.args.join(" "),
433            "ssh_config directive applied",
434        );
435        resolved.provenance.push(DirectiveSource {
436            directive: d.keyword.clone(),
437            file: d.file.clone(),
438            line: d.line_no,
439        });
440    }
441
442    Ok(())
443}
444
445fn first_arg_required(d: &Directive) -> Result<String, AnvilError> {
446    d.args.first().cloned().ok_or_else(|| missing_value_err(d))
447}
448
449fn require_at_least_one(d: &Directive) -> Result<(), AnvilError> {
450    if d.args.is_empty() {
451        Err(missing_value_err(d))
452    } else {
453        Ok(())
454    }
455}
456
457fn missing_value_err(d: &Directive) -> AnvilError {
458    AnvilError::invalid_config(format!(
459        "ssh_config: directive '{}' at {}:{} has no value",
460        d.keyword,
461        d.file.display(),
462        d.line_no,
463    ))
464}
465
466fn parse_yes_no(d: &Directive) -> Result<bool, AnvilError> {
467    let s = first_arg_required(d)?;
468    match s.to_ascii_lowercase().as_str() {
469        "yes" | "true" => Ok(true),
470        "no" | "false" => Ok(false),
471        other => Err(AnvilError::invalid_config(format!(
472            "ssh_config: expected yes/no for '{}' at {}:{}, got '{other}'",
473            d.keyword,
474            d.file.display(),
475            d.line_no,
476        ))),
477    }
478}
479
480/// Tilde + env expansion for path-shaped directive values.
481fn expand_path_value(value: &str) -> PathBuf {
482    PathBuf::from(expand_tilde(&expand_env(value)))
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use std::fs;
489    use tempfile::tempdir;
490
491    /// Writes `content` to a fresh temp config file and returns the path
492    /// + the [`tempfile::TempDir`] guard (drop the guard last).
493    fn write_config(content: &str) -> (tempfile::TempDir, PathBuf) {
494        let dir = tempdir().expect("tempdir");
495        let path = dir.path().join("config");
496        fs::write(&path, content).expect("write config");
497        (dir, path)
498    }
499
500    fn paths_user_only(p: PathBuf) -> SshConfigPaths {
501        SshConfigPaths {
502            user: Some(p),
503            system: None,
504        }
505    }
506
507    #[test]
508    fn empty_paths_returns_default() {
509        let resolved = resolve("anyhost", &SshConfigPaths::none()).expect("resolve with no files");
510        assert_eq!(resolved.hostname, None);
511        assert!(resolved.identity_files.is_empty());
512        assert!(resolved.provenance.is_empty());
513    }
514
515    #[test]
516    fn missing_file_is_silently_ignored() {
517        let paths = SshConfigPaths {
518            user: Some(PathBuf::from("/this/path/definitely/does/not/exist")),
519            system: None,
520        };
521        let resolved = resolve("anyhost", &paths).expect("resolve");
522        assert_eq!(resolved.hostname, None);
523    }
524
525    #[test]
526    fn resolves_basic_block() {
527        let (_g, conf) = write_config("Host gh\n  HostName github.com\n  User git\n  Port 2222\n");
528        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
529        assert_eq!(resolved.hostname.as_deref(), Some("github.com"));
530        assert_eq!(resolved.user.as_deref(), Some("git"));
531        assert_eq!(resolved.port, Some(2222));
532        assert_eq!(resolved.provenance.len(), 3);
533    }
534
535    #[test]
536    fn first_occurrence_wins_for_single_valued_fields() {
537        // Two Host blocks both match `gh` (`gh` and `*`).  The earlier
538        // block's value should win.
539        let (_g, conf) = write_config(
540            "Host gh\n  HostName specific.example.com\nHost *\n  HostName fallback.example.com\n",
541        );
542        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
543        assert_eq!(resolved.hostname.as_deref(), Some("specific.example.com"));
544    }
545
546    #[test]
547    fn multiple_identity_files_accumulate() {
548        let (_g, conf) =
549            write_config("Host gh\n  IdentityFile ~/.ssh/id_a\n  IdentityFile ~/.ssh/id_b\n");
550        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
551        assert_eq!(resolved.identity_files.len(), 2);
552        // Tilde was expanded.
553        assert!(!resolved.identity_files[0]
554            .to_string_lossy()
555            .starts_with('~'));
556    }
557
558    #[test]
559    fn identityfile_one_line_multiple_args_accumulates() {
560        // `IdentityFile a b c` expands to three entries (per OpenSSH).
561        let (_g, conf) = write_config("Host gh\n  IdentityFile a b c\n");
562        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
563        assert_eq!(resolved.identity_files.len(), 3);
564    }
565
566    #[test]
567    fn invalid_port_errors() {
568        let (_g, conf) = write_config("Host gh\n  Port not_a_number\n");
569        let err = resolve("gh", &paths_user_only(conf)).expect_err("invalid Port");
570        let msg = format!("{err}");
571        assert!(msg.contains("invalid Port"), "got: {msg}");
572    }
573
574    #[test]
575    fn strict_host_key_checking_variants() {
576        let cases = &[
577            ("yes", StrictHostKeyChecking::Yes),
578            ("ask", StrictHostKeyChecking::Yes), // folded
579            ("no", StrictHostKeyChecking::No),
580            ("off", StrictHostKeyChecking::No),
581            ("accept-new", StrictHostKeyChecking::AcceptNew),
582        ];
583        for (raw, expected) in cases {
584            let (_g, conf) = write_config(&format!("Host gh\n  StrictHostKeyChecking {raw}\n"));
585            let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
586            assert_eq!(
587                resolved.strict_host_key_checking,
588                Some(*expected),
589                "case `{raw}`",
590            );
591        }
592    }
593
594    #[test]
595    fn algorithm_directives_captured_raw() {
596        let (_g, conf) = write_config(
597            "Host gh\n  HostKeyAlgorithms ssh-ed25519,rsa-sha2-512\n  KexAlgorithms curve25519-sha256\n",
598        );
599        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
600        assert_eq!(
601            resolved.host_key_algorithms,
602            Some(AlgList("ssh-ed25519,rsa-sha2-512".to_owned())),
603        );
604        assert_eq!(
605            resolved.kex_algorithms,
606            Some(AlgList("curve25519-sha256".to_owned())),
607        );
608    }
609
610    #[test]
611    fn connect_timeout_parses_to_duration() {
612        let (_g, conf) = write_config("Host gh\n  ConnectTimeout 30\n");
613        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
614        assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(30)));
615    }
616
617    #[test]
618    fn connection_attempts_parses() {
619        let (_g, conf) = write_config("Host gh\n  ConnectionAttempts 5\n");
620        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
621        assert_eq!(resolved.connection_attempts, Some(5));
622    }
623
624    #[test]
625    fn proxy_command_joined_with_spaces() {
626        let (_g, conf) = write_config("Host gh\n  ProxyCommand ssh -W %h:%p bastion\n");
627        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
628        // The lexer split on whitespace; the resolver re-joined with one space.
629        assert_eq!(
630            resolved.proxy_command.as_deref(),
631            Some("ssh -W %h:%p bastion"),
632        );
633    }
634
635    #[test]
636    fn proxy_jump_captured() {
637        let (_g, conf) = write_config("Host gh\n  ProxyJump bastion.example.com\n");
638        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
639        assert_eq!(resolved.proxy_jump.as_deref(), Some("bastion.example.com"),);
640    }
641
642    #[test]
643    fn proxy_command_none_preserved_as_literal() {
644        // FR-59: `ProxyCommand none` is the OpenSSH idiom for cancelling a
645        // parent block's ProxyCommand.  We preserve the literal `"none"`
646        // (lower-cased) so first-occurrence-wins protects it from a later
647        // wildcard match, and so `gitway config show` prints it faithfully.
648        let (_g, conf) = write_config("Host gh\n  ProxyCommand none\n");
649        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
650        assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
651    }
652
653    #[test]
654    fn proxy_command_none_case_insensitive() {
655        // OpenSSH treats the literal case-insensitively.
656        for raw in ["NONE", "None", "nOnE"] {
657            let (_g, conf) = write_config(&format!("Host gh\n  ProxyCommand {raw}\n"));
658            let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
659            assert_eq!(
660                resolved.proxy_command.as_deref(),
661                Some("none"),
662                "case `{raw}`: should normalize to lowercase `none`",
663            );
664        }
665    }
666
667    #[test]
668    fn proxy_command_none_overrides_later_wildcard() {
669        // FR-59 precedence: a more specific block's `ProxyCommand none`
670        // appearing earlier in the file beats a later `Host *` block's
671        // ProxyCommand foo, because the resolver applies first-occurrence-
672        // wins.  The result is `Some("none")` — the spawn path treats this
673        // as "no proxy".
674        let (_g, conf) =
675            write_config("Host gh\n  ProxyCommand none\nHost *\n  ProxyCommand /usr/bin/false\n");
676        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
677        assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
678    }
679
680    #[test]
681    fn proxy_command_with_word_none_in_middle_not_treated_as_disable() {
682        // The `none` literal is only recognized when it is the SOLE
683        // argument.  A multi-word command containing `none` is preserved
684        // verbatim (re-joined with single spaces).
685        let (_g, conf) = write_config("Host gh\n  ProxyCommand none-yet-a-real-cmd %h\n");
686        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
687        assert_eq!(
688            resolved.proxy_command.as_deref(),
689            Some("none-yet-a-real-cmd %h"),
690        );
691    }
692
693    #[test]
694    fn user_known_hosts_files_accumulate() {
695        let (_g, conf) = write_config(
696            "Host gh\n  UserKnownHostsFile /etc/known\n  UserKnownHostsFile /home/u/known\n",
697        );
698        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
699        assert_eq!(resolved.user_known_hosts_files.len(), 2);
700    }
701
702    #[test]
703    fn user_known_hosts_files_one_line_multi_args() {
704        let (_g, conf) = write_config("Host gh\n  UserKnownHostsFile /a /b /c\n");
705        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
706        assert_eq!(resolved.user_known_hosts_files.len(), 3);
707    }
708
709    #[test]
710    fn unknown_directives_ignored() {
711        let (_g, conf) = write_config("Host gh\n  ServerAliveInterval 60\n  User git\n");
712        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
713        // Unknown directive ignored; recognized directive still applied.
714        assert_eq!(resolved.user.as_deref(), Some("git"));
715        assert_eq!(resolved.provenance.len(), 1);
716    }
717
718    #[test]
719    fn provenance_records_file_and_line() {
720        let (_g, conf) = write_config("# header\nHost gh\n  User git\n");
721        let resolved = resolve("gh", &paths_user_only(conf.clone())).expect("resolve");
722        assert_eq!(resolved.provenance.len(), 1);
723        let prov = &resolved.provenance[0];
724        assert_eq!(prov.directive, "user");
725        assert_eq!(prov.line, 3);
726        // The provenance file matches the read path (post-canonicalize via include).
727        // It may be canonicalized; compare canonical-or-as-is.
728        let prov_canon = prov.file.canonicalize().unwrap_or(prov.file.clone());
729        let conf_canon = conf.canonicalize().unwrap_or(conf);
730        assert_eq!(prov_canon, conf_canon);
731    }
732
733    #[test]
734    fn user_then_system_first_wins() {
735        let dir = tempdir().expect("tempdir");
736        let user_path = dir.path().join("user_config");
737        let sys_path = dir.path().join("sys_config");
738        fs::write(&user_path, "Host gh\n  User from_user\n").expect("write user");
739        fs::write(&sys_path, "Host gh\n  User from_system\n").expect("write sys");
740
741        let paths = SshConfigPaths {
742            user: Some(user_path),
743            system: Some(sys_path),
744        };
745        let resolved = resolve("gh", &paths).expect("resolve");
746        assert_eq!(resolved.user.as_deref(), Some("from_user"));
747    }
748
749    #[test]
750    fn no_match_yields_empty_resolved() {
751        let (_g, conf) = write_config("Host other\n  User unrelated\n");
752        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
753        assert_eq!(resolved.user, None);
754        assert!(resolved.provenance.is_empty());
755    }
756}