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);
145    /// M13 parses and spawns it.
146    pub proxy_command: Option<String>,
147    /// `ProxyJump` — captured raw; M13 parses the chain.
148    pub proxy_jump: Option<String>,
149    /// `UserKnownHostsFile` — every entry contributes one path.
150    pub user_known_hosts_files: Vec<PathBuf>,
151    /// `StrictHostKeyChecking`.
152    pub strict_host_key_checking: Option<StrictHostKeyChecking>,
153    /// `HostKeyAlgorithms` — raw spec; M17 plumbs through to russh.
154    pub host_key_algorithms: Option<AlgList>,
155    /// `KexAlgorithms` — raw spec; M17 plumbs through.
156    pub kex_algorithms: Option<AlgList>,
157    /// `Ciphers` — raw spec; M17 plumbs through.
158    pub ciphers: Option<AlgList>,
159    /// `MACs` — raw spec; M17 plumbs through.
160    pub macs: Option<AlgList>,
161    /// `ConnectTimeout` — measured in seconds in the source file,
162    /// stored here as a [`Duration`].
163    pub connect_timeout: Option<Duration>,
164    /// `ConnectionAttempts`.
165    pub connection_attempts: Option<u32>,
166    /// One [`DirectiveSource`] entry per directive that contributed to a
167    /// known field, in the order applied.  Preserves provenance for
168    /// `gitway config show` and the `config_source=` diag-line field.
169    pub provenance: Vec<DirectiveSource>,
170}
171
172/// Resolves the effective `ssh_config` for `host` against the files
173/// listed in `paths`.
174///
175/// Reads the user file first, then the system file (per `ssh_config(5)`:
176/// "first obtained value for each parameter is used").  Within each file,
177/// `Include` directives are recursively expanded (see
178/// [`super::include::expand_includes`]) before host matching.
179///
180/// Missing files are silently skipped — only failures to *read* an
181/// existing file (permission denied, malformed UTF-8, etc.) bubble up
182/// as errors.
183///
184/// # Errors
185/// Returns [`AnvilError::invalid_config`] when:
186/// - A read of an existing file fails for reasons other than "not found".
187/// - The file is not valid UTF-8.
188/// - The file is malformed (unterminated quote, `Host` with no patterns,
189///   Include cycle, depth overflow).
190/// - A directive's argument fails to parse (e.g. `Port abc`).
191pub fn resolve(host: &str, paths: &SshConfigPaths) -> Result<ResolvedSshConfig, AnvilError> {
192    let mut all_blocks: Vec<HostBlock> = Vec::new();
193
194    if let Some(user) = &paths.user {
195        let path = expand_path_for_read(user);
196        all_blocks.extend(read_and_parse(&path)?);
197    }
198    if let Some(system) = &paths.system {
199        let path = expand_path_for_read(system);
200        all_blocks.extend(read_and_parse(&path)?);
201    }
202
203    let mut resolved = ResolvedSshConfig::default();
204    if all_blocks.is_empty() {
205        return Ok(resolved);
206    }
207
208    for d in directives_for_host(&all_blocks, host) {
209        apply_directive(d, &mut resolved)?;
210    }
211
212    Ok(resolved)
213}
214
215/// Tilde-expands the path so callers may pass `~/.ssh/config` literally.
216fn expand_path_for_read(path: &Path) -> PathBuf {
217    let s = path.to_string_lossy();
218    PathBuf::from(expand_tilde(&s))
219}
220
221/// Reads, tokenizes, expands Includes, and parses one config file.
222/// Missing files yield an empty block list (no error).
223fn read_and_parse(path: &Path) -> Result<Vec<HostBlock>, AnvilError> {
224    let content = match std::fs::read_to_string(path) {
225        Ok(c) => c,
226        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
227        Err(e) => {
228            return Err(AnvilError::invalid_config(format!(
229                "ssh_config: failed to read {}: {e}",
230                path.display(),
231            )));
232        }
233    };
234    let tokens = tokenize(&content, path)?;
235    let expanded = expand_includes(path, tokens)?;
236    parse(expanded)
237}
238
239/// Applies one directive to `resolved` with first-occurrence-wins
240/// semantics, recording provenance for every recognized directive.
241#[allow(
242    clippy::too_many_lines,
243    reason = "directive dispatch is intentionally one big match for clarity \
244              and easy review; each arm is a few lines and there is no \
245              meaningful sub-grouping"
246)]
247fn apply_directive(d: &Directive, resolved: &mut ResolvedSshConfig) -> Result<(), AnvilError> {
248    let mut recorded = true;
249
250    match d.keyword.as_str() {
251        "hostname" => {
252            if resolved.hostname.is_none() {
253                resolved.hostname = Some(first_arg_required(d)?);
254            }
255        }
256        "user" => {
257            if resolved.user.is_none() {
258                resolved.user = Some(first_arg_required(d)?);
259            }
260        }
261        "port" => {
262            if resolved.port.is_none() {
263                let s = first_arg_required(d)?;
264                resolved.port = Some(s.parse::<u16>().map_err(|e| {
265                    AnvilError::invalid_config(format!(
266                        "ssh_config: invalid Port '{s}' at {}:{}: {e}",
267                        d.file.display(),
268                        d.line_no,
269                    ))
270                })?);
271            }
272        }
273        "identityfile" => {
274            require_at_least_one(d)?;
275            for arg in &d.args {
276                resolved.identity_files.push(expand_path_value(arg));
277            }
278        }
279        "identitiesonly" => {
280            if resolved.identities_only.is_none() {
281                resolved.identities_only = Some(parse_yes_no(d)?);
282            }
283        }
284        "identityagent" => {
285            if resolved.identity_agent.is_none() {
286                let s = first_arg_required(d)?;
287                resolved.identity_agent = Some(expand_path_value(&s));
288            }
289        }
290        "certificatefile" => {
291            require_at_least_one(d)?;
292            for arg in &d.args {
293                resolved.certificate_files.push(expand_path_value(arg));
294            }
295        }
296        "proxycommand" => {
297            if resolved.proxy_command.is_none() {
298                if d.args.is_empty() {
299                    return Err(missing_value_err(d));
300                }
301                // ProxyCommand takes the rest of the line as a shell
302                // command; the lexer split it on whitespace so we re-join.
303                resolved.proxy_command = Some(d.args.join(" "));
304            }
305        }
306        "proxyjump" => {
307            if resolved.proxy_jump.is_none() {
308                resolved.proxy_jump = Some(first_arg_required(d)?);
309            }
310        }
311        "userknownhostsfile" => {
312            require_at_least_one(d)?;
313            for arg in &d.args {
314                resolved.user_known_hosts_files.push(expand_path_value(arg));
315            }
316        }
317        "stricthostkeychecking" => {
318            if resolved.strict_host_key_checking.is_none() {
319                let s = first_arg_required(d)?;
320                let v = match s.to_ascii_lowercase().as_str() {
321                    // OpenSSH `ask` defaults to interactive prompt; we
322                    // fold to Yes since this crate never prompts.
323                    "yes" | "ask" => StrictHostKeyChecking::Yes,
324                    "no" | "off" => StrictHostKeyChecking::No,
325                    "accept-new" => StrictHostKeyChecking::AcceptNew,
326                    other => {
327                        return Err(AnvilError::invalid_config(format!(
328                            "ssh_config: invalid StrictHostKeyChecking '{other}' at {}:{}",
329                            d.file.display(),
330                            d.line_no,
331                        )));
332                    }
333                };
334                resolved.strict_host_key_checking = Some(v);
335            }
336        }
337        "hostkeyalgorithms" => {
338            if resolved.host_key_algorithms.is_none() {
339                resolved.host_key_algorithms = Some(AlgList(first_arg_required(d)?));
340            }
341        }
342        "kexalgorithms" => {
343            if resolved.kex_algorithms.is_none() {
344                resolved.kex_algorithms = Some(AlgList(first_arg_required(d)?));
345            }
346        }
347        "ciphers" => {
348            if resolved.ciphers.is_none() {
349                resolved.ciphers = Some(AlgList(first_arg_required(d)?));
350            }
351        }
352        "macs" => {
353            if resolved.macs.is_none() {
354                resolved.macs = Some(AlgList(first_arg_required(d)?));
355            }
356        }
357        "connecttimeout" => {
358            if resolved.connect_timeout.is_none() {
359                let s = first_arg_required(d)?;
360                let secs: u64 = s.parse().map_err(|e| {
361                    AnvilError::invalid_config(format!(
362                        "ssh_config: invalid ConnectTimeout '{s}' at {}:{}: {e}",
363                        d.file.display(),
364                        d.line_no,
365                    ))
366                })?;
367                resolved.connect_timeout = Some(Duration::from_secs(secs));
368            }
369        }
370        "connectionattempts" => {
371            if resolved.connection_attempts.is_none() {
372                let s = first_arg_required(d)?;
373                resolved.connection_attempts = Some(s.parse::<u32>().map_err(|e| {
374                    AnvilError::invalid_config(format!(
375                        "ssh_config: invalid ConnectionAttempts '{s}' at {}:{}: {e}",
376                        d.file.display(),
377                        d.line_no,
378                    ))
379                })?);
380            }
381        }
382        _ => {
383            // Unknown / unhandled directive — silently skip.  Many
384            // ssh_config(5) directives are out of scope for Anvil; logging
385            // every one would be noisy.  Trace level only.
386            log::trace!(
387                "ssh_config: ignoring unhandled directive '{}' at {}:{}",
388                d.keyword,
389                d.file.display(),
390                d.line_no,
391            );
392            recorded = false;
393        }
394    }
395
396    if recorded {
397        resolved.provenance.push(DirectiveSource {
398            directive: d.keyword.clone(),
399            file: d.file.clone(),
400            line: d.line_no,
401        });
402    }
403
404    Ok(())
405}
406
407fn first_arg_required(d: &Directive) -> Result<String, AnvilError> {
408    d.args.first().cloned().ok_or_else(|| missing_value_err(d))
409}
410
411fn require_at_least_one(d: &Directive) -> Result<(), AnvilError> {
412    if d.args.is_empty() {
413        Err(missing_value_err(d))
414    } else {
415        Ok(())
416    }
417}
418
419fn missing_value_err(d: &Directive) -> AnvilError {
420    AnvilError::invalid_config(format!(
421        "ssh_config: directive '{}' at {}:{} has no value",
422        d.keyword,
423        d.file.display(),
424        d.line_no,
425    ))
426}
427
428fn parse_yes_no(d: &Directive) -> Result<bool, AnvilError> {
429    let s = first_arg_required(d)?;
430    match s.to_ascii_lowercase().as_str() {
431        "yes" | "true" => Ok(true),
432        "no" | "false" => Ok(false),
433        other => Err(AnvilError::invalid_config(format!(
434            "ssh_config: expected yes/no for '{}' at {}:{}, got '{other}'",
435            d.keyword,
436            d.file.display(),
437            d.line_no,
438        ))),
439    }
440}
441
442/// Tilde + env expansion for path-shaped directive values.
443fn expand_path_value(value: &str) -> PathBuf {
444    PathBuf::from(expand_tilde(&expand_env(value)))
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use std::fs;
451    use tempfile::tempdir;
452
453    /// Writes `content` to a fresh temp config file and returns the path
454    /// + the [`tempfile::TempDir`] guard (drop the guard last).
455    fn write_config(content: &str) -> (tempfile::TempDir, PathBuf) {
456        let dir = tempdir().expect("tempdir");
457        let path = dir.path().join("config");
458        fs::write(&path, content).expect("write config");
459        (dir, path)
460    }
461
462    fn paths_user_only(p: PathBuf) -> SshConfigPaths {
463        SshConfigPaths {
464            user: Some(p),
465            system: None,
466        }
467    }
468
469    #[test]
470    fn empty_paths_returns_default() {
471        let resolved = resolve("anyhost", &SshConfigPaths::none()).expect("resolve with no files");
472        assert_eq!(resolved.hostname, None);
473        assert!(resolved.identity_files.is_empty());
474        assert!(resolved.provenance.is_empty());
475    }
476
477    #[test]
478    fn missing_file_is_silently_ignored() {
479        let paths = SshConfigPaths {
480            user: Some(PathBuf::from("/this/path/definitely/does/not/exist")),
481            system: None,
482        };
483        let resolved = resolve("anyhost", &paths).expect("resolve");
484        assert_eq!(resolved.hostname, None);
485    }
486
487    #[test]
488    fn resolves_basic_block() {
489        let (_g, conf) = write_config("Host gh\n  HostName github.com\n  User git\n  Port 2222\n");
490        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
491        assert_eq!(resolved.hostname.as_deref(), Some("github.com"));
492        assert_eq!(resolved.user.as_deref(), Some("git"));
493        assert_eq!(resolved.port, Some(2222));
494        assert_eq!(resolved.provenance.len(), 3);
495    }
496
497    #[test]
498    fn first_occurrence_wins_for_single_valued_fields() {
499        // Two Host blocks both match `gh` (`gh` and `*`).  The earlier
500        // block's value should win.
501        let (_g, conf) = write_config(
502            "Host gh\n  HostName specific.example.com\nHost *\n  HostName fallback.example.com\n",
503        );
504        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
505        assert_eq!(resolved.hostname.as_deref(), Some("specific.example.com"));
506    }
507
508    #[test]
509    fn multiple_identity_files_accumulate() {
510        let (_g, conf) =
511            write_config("Host gh\n  IdentityFile ~/.ssh/id_a\n  IdentityFile ~/.ssh/id_b\n");
512        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
513        assert_eq!(resolved.identity_files.len(), 2);
514        // Tilde was expanded.
515        assert!(!resolved.identity_files[0]
516            .to_string_lossy()
517            .starts_with('~'));
518    }
519
520    #[test]
521    fn identityfile_one_line_multiple_args_accumulates() {
522        // `IdentityFile a b c` expands to three entries (per OpenSSH).
523        let (_g, conf) = write_config("Host gh\n  IdentityFile a b c\n");
524        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
525        assert_eq!(resolved.identity_files.len(), 3);
526    }
527
528    #[test]
529    fn invalid_port_errors() {
530        let (_g, conf) = write_config("Host gh\n  Port not_a_number\n");
531        let err = resolve("gh", &paths_user_only(conf)).expect_err("invalid Port");
532        let msg = format!("{err}");
533        assert!(msg.contains("invalid Port"), "got: {msg}");
534    }
535
536    #[test]
537    fn strict_host_key_checking_variants() {
538        let cases = &[
539            ("yes", StrictHostKeyChecking::Yes),
540            ("ask", StrictHostKeyChecking::Yes), // folded
541            ("no", StrictHostKeyChecking::No),
542            ("off", StrictHostKeyChecking::No),
543            ("accept-new", StrictHostKeyChecking::AcceptNew),
544        ];
545        for (raw, expected) in cases {
546            let (_g, conf) = write_config(&format!("Host gh\n  StrictHostKeyChecking {raw}\n"));
547            let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
548            assert_eq!(
549                resolved.strict_host_key_checking,
550                Some(*expected),
551                "case `{raw}`",
552            );
553        }
554    }
555
556    #[test]
557    fn algorithm_directives_captured_raw() {
558        let (_g, conf) = write_config(
559            "Host gh\n  HostKeyAlgorithms ssh-ed25519,rsa-sha2-512\n  KexAlgorithms curve25519-sha256\n",
560        );
561        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
562        assert_eq!(
563            resolved.host_key_algorithms,
564            Some(AlgList("ssh-ed25519,rsa-sha2-512".to_owned())),
565        );
566        assert_eq!(
567            resolved.kex_algorithms,
568            Some(AlgList("curve25519-sha256".to_owned())),
569        );
570    }
571
572    #[test]
573    fn connect_timeout_parses_to_duration() {
574        let (_g, conf) = write_config("Host gh\n  ConnectTimeout 30\n");
575        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
576        assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(30)));
577    }
578
579    #[test]
580    fn connection_attempts_parses() {
581        let (_g, conf) = write_config("Host gh\n  ConnectionAttempts 5\n");
582        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
583        assert_eq!(resolved.connection_attempts, Some(5));
584    }
585
586    #[test]
587    fn proxy_command_joined_with_spaces() {
588        let (_g, conf) = write_config("Host gh\n  ProxyCommand ssh -W %h:%p bastion\n");
589        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
590        // The lexer split on whitespace; the resolver re-joined with one space.
591        assert_eq!(
592            resolved.proxy_command.as_deref(),
593            Some("ssh -W %h:%p bastion"),
594        );
595    }
596
597    #[test]
598    fn proxy_jump_captured() {
599        let (_g, conf) = write_config("Host gh\n  ProxyJump bastion.example.com\n");
600        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
601        assert_eq!(resolved.proxy_jump.as_deref(), Some("bastion.example.com"),);
602    }
603
604    #[test]
605    fn user_known_hosts_files_accumulate() {
606        let (_g, conf) = write_config(
607            "Host gh\n  UserKnownHostsFile /etc/known\n  UserKnownHostsFile /home/u/known\n",
608        );
609        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
610        assert_eq!(resolved.user_known_hosts_files.len(), 2);
611    }
612
613    #[test]
614    fn user_known_hosts_files_one_line_multi_args() {
615        let (_g, conf) = write_config("Host gh\n  UserKnownHostsFile /a /b /c\n");
616        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
617        assert_eq!(resolved.user_known_hosts_files.len(), 3);
618    }
619
620    #[test]
621    fn unknown_directives_ignored() {
622        let (_g, conf) = write_config("Host gh\n  ServerAliveInterval 60\n  User git\n");
623        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
624        // Unknown directive ignored; recognized directive still applied.
625        assert_eq!(resolved.user.as_deref(), Some("git"));
626        assert_eq!(resolved.provenance.len(), 1);
627    }
628
629    #[test]
630    fn provenance_records_file_and_line() {
631        let (_g, conf) = write_config("# header\nHost gh\n  User git\n");
632        let resolved = resolve("gh", &paths_user_only(conf.clone())).expect("resolve");
633        assert_eq!(resolved.provenance.len(), 1);
634        let prov = &resolved.provenance[0];
635        assert_eq!(prov.directive, "user");
636        assert_eq!(prov.line, 3);
637        // The provenance file matches the read path (post-canonicalize via include).
638        // It may be canonicalized; compare canonical-or-as-is.
639        let prov_canon = prov.file.canonicalize().unwrap_or(prov.file.clone());
640        let conf_canon = conf.canonicalize().unwrap_or(conf);
641        assert_eq!(prov_canon, conf_canon);
642    }
643
644    #[test]
645    fn user_then_system_first_wins() {
646        let dir = tempdir().expect("tempdir");
647        let user_path = dir.path().join("user_config");
648        let sys_path = dir.path().join("sys_config");
649        fs::write(&user_path, "Host gh\n  User from_user\n").expect("write user");
650        fs::write(&sys_path, "Host gh\n  User from_system\n").expect("write sys");
651
652        let paths = SshConfigPaths {
653            user: Some(user_path),
654            system: Some(sys_path),
655        };
656        let resolved = resolve("gh", &paths).expect("resolve");
657        assert_eq!(resolved.user.as_deref(), Some("from_user"));
658    }
659
660    #[test]
661    fn no_match_yields_empty_resolved() {
662        let (_g, conf) = write_config("Host other\n  User unrelated\n");
663        let resolved = resolve("gh", &paths_user_only(conf)).expect("resolve");
664        assert_eq!(resolved.user, None);
665        assert!(resolved.provenance.is_empty());
666    }
667}