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        resolved.provenance.push(DirectiveSource {
423            directive: d.keyword.clone(),
424            file: d.file.clone(),
425            line: d.line_no,
426        });
427    }
428
429    Ok(())
430}
431
432fn first_arg_required(d: &Directive) -> Result<String, AnvilError> {
433    d.args.first().cloned().ok_or_else(|| missing_value_err(d))
434}
435
436fn require_at_least_one(d: &Directive) -> Result<(), AnvilError> {
437    if d.args.is_empty() {
438        Err(missing_value_err(d))
439    } else {
440        Ok(())
441    }
442}
443
444fn missing_value_err(d: &Directive) -> AnvilError {
445    AnvilError::invalid_config(format!(
446        "ssh_config: directive '{}' at {}:{} has no value",
447        d.keyword,
448        d.file.display(),
449        d.line_no,
450    ))
451}
452
453fn parse_yes_no(d: &Directive) -> Result<bool, AnvilError> {
454    let s = first_arg_required(d)?;
455    match s.to_ascii_lowercase().as_str() {
456        "yes" | "true" => Ok(true),
457        "no" | "false" => Ok(false),
458        other => Err(AnvilError::invalid_config(format!(
459            "ssh_config: expected yes/no for '{}' at {}:{}, got '{other}'",
460            d.keyword,
461            d.file.display(),
462            d.line_no,
463        ))),
464    }
465}
466
467/// Tilde + env expansion for path-shaped directive values.
468fn expand_path_value(value: &str) -> PathBuf {
469    PathBuf::from(expand_tilde(&expand_env(value)))
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use std::fs;
476    use tempfile::tempdir;
477
478    /// Writes `content` to a fresh temp config file and returns the path
479    /// + the [`tempfile::TempDir`] guard (drop the guard last).
480    fn write_config(content: &str) -> (tempfile::TempDir, PathBuf) {
481        let dir = tempdir().expect("tempdir");
482        let path = dir.path().join("config");
483        fs::write(&path, content).expect("write config");
484        (dir, path)
485    }
486
487    fn paths_user_only(p: PathBuf) -> SshConfigPaths {
488        SshConfigPaths {
489            user: Some(p),
490            system: None,
491        }
492    }
493
494    #[test]
495    fn empty_paths_returns_default() {
496        let resolved = resolve("anyhost", &SshConfigPaths::none()).expect("resolve with no files");
497        assert_eq!(resolved.hostname, None);
498        assert!(resolved.identity_files.is_empty());
499        assert!(resolved.provenance.is_empty());
500    }
501
502    #[test]
503    fn missing_file_is_silently_ignored() {
504        let paths = SshConfigPaths {
505            user: Some(PathBuf::from("/this/path/definitely/does/not/exist")),
506            system: None,
507        };
508        let resolved = resolve("anyhost", &paths).expect("resolve");
509        assert_eq!(resolved.hostname, None);
510    }
511
512    #[test]
513    fn resolves_basic_block() {
514        let (_g, conf) = write_config("Host gh\n  HostName github.com\n  User git\n  Port 2222\n");
515        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
516        assert_eq!(resolved.hostname.as_deref(), Some("github.com"));
517        assert_eq!(resolved.user.as_deref(), Some("git"));
518        assert_eq!(resolved.port, Some(2222));
519        assert_eq!(resolved.provenance.len(), 3);
520    }
521
522    #[test]
523    fn first_occurrence_wins_for_single_valued_fields() {
524        // Two Host blocks both match `gh` (`gh` and `*`).  The earlier
525        // block's value should win.
526        let (_g, conf) = write_config(
527            "Host gh\n  HostName specific.example.com\nHost *\n  HostName fallback.example.com\n",
528        );
529        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
530        assert_eq!(resolved.hostname.as_deref(), Some("specific.example.com"));
531    }
532
533    #[test]
534    fn multiple_identity_files_accumulate() {
535        let (_g, conf) =
536            write_config("Host gh\n  IdentityFile ~/.ssh/id_a\n  IdentityFile ~/.ssh/id_b\n");
537        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
538        assert_eq!(resolved.identity_files.len(), 2);
539        // Tilde was expanded.
540        assert!(!resolved.identity_files[0]
541            .to_string_lossy()
542            .starts_with('~'));
543    }
544
545    #[test]
546    fn identityfile_one_line_multiple_args_accumulates() {
547        // `IdentityFile a b c` expands to three entries (per OpenSSH).
548        let (_g, conf) = write_config("Host gh\n  IdentityFile a b c\n");
549        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
550        assert_eq!(resolved.identity_files.len(), 3);
551    }
552
553    #[test]
554    fn invalid_port_errors() {
555        let (_g, conf) = write_config("Host gh\n  Port not_a_number\n");
556        let err = resolve("gh", &paths_user_only(conf)).expect_err("invalid Port");
557        let msg = format!("{err}");
558        assert!(msg.contains("invalid Port"), "got: {msg}");
559    }
560
561    #[test]
562    fn strict_host_key_checking_variants() {
563        let cases = &[
564            ("yes", StrictHostKeyChecking::Yes),
565            ("ask", StrictHostKeyChecking::Yes), // folded
566            ("no", StrictHostKeyChecking::No),
567            ("off", StrictHostKeyChecking::No),
568            ("accept-new", StrictHostKeyChecking::AcceptNew),
569        ];
570        for (raw, expected) in cases {
571            let (_g, conf) = write_config(&format!("Host gh\n  StrictHostKeyChecking {raw}\n"));
572            let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
573            assert_eq!(
574                resolved.strict_host_key_checking,
575                Some(*expected),
576                "case `{raw}`",
577            );
578        }
579    }
580
581    #[test]
582    fn algorithm_directives_captured_raw() {
583        let (_g, conf) = write_config(
584            "Host gh\n  HostKeyAlgorithms ssh-ed25519,rsa-sha2-512\n  KexAlgorithms curve25519-sha256\n",
585        );
586        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
587        assert_eq!(
588            resolved.host_key_algorithms,
589            Some(AlgList("ssh-ed25519,rsa-sha2-512".to_owned())),
590        );
591        assert_eq!(
592            resolved.kex_algorithms,
593            Some(AlgList("curve25519-sha256".to_owned())),
594        );
595    }
596
597    #[test]
598    fn connect_timeout_parses_to_duration() {
599        let (_g, conf) = write_config("Host gh\n  ConnectTimeout 30\n");
600        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
601        assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(30)));
602    }
603
604    #[test]
605    fn connection_attempts_parses() {
606        let (_g, conf) = write_config("Host gh\n  ConnectionAttempts 5\n");
607        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
608        assert_eq!(resolved.connection_attempts, Some(5));
609    }
610
611    #[test]
612    fn proxy_command_joined_with_spaces() {
613        let (_g, conf) = write_config("Host gh\n  ProxyCommand ssh -W %h:%p bastion\n");
614        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
615        // The lexer split on whitespace; the resolver re-joined with one space.
616        assert_eq!(
617            resolved.proxy_command.as_deref(),
618            Some("ssh -W %h:%p bastion"),
619        );
620    }
621
622    #[test]
623    fn proxy_jump_captured() {
624        let (_g, conf) = write_config("Host gh\n  ProxyJump bastion.example.com\n");
625        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
626        assert_eq!(resolved.proxy_jump.as_deref(), Some("bastion.example.com"),);
627    }
628
629    #[test]
630    fn proxy_command_none_preserved_as_literal() {
631        // FR-59: `ProxyCommand none` is the OpenSSH idiom for cancelling a
632        // parent block's ProxyCommand.  We preserve the literal `"none"`
633        // (lower-cased) so first-occurrence-wins protects it from a later
634        // wildcard match, and so `gitway config show` prints it faithfully.
635        let (_g, conf) = write_config("Host gh\n  ProxyCommand none\n");
636        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
637        assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
638    }
639
640    #[test]
641    fn proxy_command_none_case_insensitive() {
642        // OpenSSH treats the literal case-insensitively.
643        for raw in ["NONE", "None", "nOnE"] {
644            let (_g, conf) = write_config(&format!("Host gh\n  ProxyCommand {raw}\n"));
645            let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
646            assert_eq!(
647                resolved.proxy_command.as_deref(),
648                Some("none"),
649                "case `{raw}`: should normalize to lowercase `none`",
650            );
651        }
652    }
653
654    #[test]
655    fn proxy_command_none_overrides_later_wildcard() {
656        // FR-59 precedence: a more specific block's `ProxyCommand none`
657        // appearing earlier in the file beats a later `Host *` block's
658        // ProxyCommand foo, because the resolver applies first-occurrence-
659        // wins.  The result is `Some("none")` — the spawn path treats this
660        // as "no proxy".
661        let (_g, conf) =
662            write_config("Host gh\n  ProxyCommand none\nHost *\n  ProxyCommand /usr/bin/false\n");
663        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
664        assert_eq!(resolved.proxy_command.as_deref(), Some("none"));
665    }
666
667    #[test]
668    fn proxy_command_with_word_none_in_middle_not_treated_as_disable() {
669        // The `none` literal is only recognized when it is the SOLE
670        // argument.  A multi-word command containing `none` is preserved
671        // verbatim (re-joined with single spaces).
672        let (_g, conf) = write_config("Host gh\n  ProxyCommand none-yet-a-real-cmd %h\n");
673        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
674        assert_eq!(
675            resolved.proxy_command.as_deref(),
676            Some("none-yet-a-real-cmd %h"),
677        );
678    }
679
680    #[test]
681    fn user_known_hosts_files_accumulate() {
682        let (_g, conf) = write_config(
683            "Host gh\n  UserKnownHostsFile /etc/known\n  UserKnownHostsFile /home/u/known\n",
684        );
685        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
686        assert_eq!(resolved.user_known_hosts_files.len(), 2);
687    }
688
689    #[test]
690    fn user_known_hosts_files_one_line_multi_args() {
691        let (_g, conf) = write_config("Host gh\n  UserKnownHostsFile /a /b /c\n");
692        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
693        assert_eq!(resolved.user_known_hosts_files.len(), 3);
694    }
695
696    #[test]
697    fn unknown_directives_ignored() {
698        let (_g, conf) = write_config("Host gh\n  ServerAliveInterval 60\n  User git\n");
699        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
700        // Unknown directive ignored; recognized directive still applied.
701        assert_eq!(resolved.user.as_deref(), Some("git"));
702        assert_eq!(resolved.provenance.len(), 1);
703    }
704
705    #[test]
706    fn provenance_records_file_and_line() {
707        let (_g, conf) = write_config("# header\nHost gh\n  User git\n");
708        let resolved = resolve("gh", &paths_user_only(conf.clone())).expect("resolve");
709        assert_eq!(resolved.provenance.len(), 1);
710        let prov = &resolved.provenance[0];
711        assert_eq!(prov.directive, "user");
712        assert_eq!(prov.line, 3);
713        // The provenance file matches the read path (post-canonicalize via include).
714        // It may be canonicalized; compare canonical-or-as-is.
715        let prov_canon = prov.file.canonicalize().unwrap_or(prov.file.clone());
716        let conf_canon = conf.canonicalize().unwrap_or(conf);
717        assert_eq!(prov_canon, conf_canon);
718    }
719
720    #[test]
721    fn user_then_system_first_wins() {
722        let dir = tempdir().expect("tempdir");
723        let user_path = dir.path().join("user_config");
724        let sys_path = dir.path().join("sys_config");
725        fs::write(&user_path, "Host gh\n  User from_user\n").expect("write user");
726        fs::write(&sys_path, "Host gh\n  User from_system\n").expect("write sys");
727
728        let paths = SshConfigPaths {
729            user: Some(user_path),
730            system: Some(sys_path),
731        };
732        let resolved = resolve("gh", &paths).expect("resolve");
733        assert_eq!(resolved.user.as_deref(), Some("from_user"));
734    }
735
736    #[test]
737    fn no_match_yields_empty_resolved() {
738        let (_g, conf) = write_config("Host other\n  User unrelated\n");
739        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
740        assert_eq!(resolved.user, None);
741        assert!(resolved.provenance.is_empty());
742    }
743}