diff --git a/src/put/ssh.rs b/src/put/ssh.rs index 3bac629..7307f73 100644 --- a/src/put/ssh.rs +++ b/src/put/ssh.rs @@ -53,9 +53,10 @@ fn host_key_is_pinned(host: &str, fingerprint: &str) -> bool { }) } -/// Per-host settings extracted from the SSH configuration files -/// (`/etc/ssh/ssh_config`, `~/.ssh/config`). Every option keeps the first -/// value obtained from the matching `Host` blocks, like OpenSSH. +/// Per-host settings extracted from the SSH configuration files, read in +/// the OpenSSH order (`~/.ssh/config`, then `/etc/ssh/ssh_config`). Every +/// option keeps the first value obtained from the matching `Host` blocks, +/// like OpenSSH: user settings override system ones. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct SshConfig { /// `HostName`: real host behind a configured alias. @@ -68,14 +69,19 @@ pub struct SshConfig { pub identity_files: Vec, } -/// Look up `host` in the system and user SSH configuration files. Missing -/// files yield an empty configuration. +/// Look up `host` in the user and system SSH configuration files, in +/// OpenSSH's read order: `~/.ssh/config` first, then +/// `/etc/ssh/ssh_config` (command line > user > system, first obtained +/// value wins). Missing files yield an empty configuration. pub fn lookup_ssh_config(host: &str) -> SshConfig { let mut config = SshConfig::default(); - let mut files = vec![PathBuf::from("/etc/ssh/ssh_config")]; + // User file first: with first-obtained-value-wins semantics below, this + // makes user settings override the system ones, like OpenSSH + let mut files = Vec::new(); if let Some(home) = std::env::var_os("HOME") { files.push(Path::new(&home).join(".ssh/config")); } + files.push(PathBuf::from("/etc/ssh/ssh_config")); for file in files { if let Ok(content) = fs::read_to_string(&file) { apply_config_file(&mut config, &content, host); @@ -84,8 +90,13 @@ pub fn lookup_ssh_config(host: &str) -> SshConfig { config } -/// Fold one configuration file's matching `Host` blocks into `config`, -/// first obtained value wins per option. +/// Fold one configuration file's `Host` blocks matching `host` into +/// `config`, first obtained value wins per option. A block applies iff at +/// least one positive pattern matches and no negated (`!`-prefixed) +/// pattern matches — the OpenSSH `Host` rule. `Include` and `Match` are +/// not supported: a `Match` block's criteria are not evaluated, and it +/// conservatively stops attributing the following lines to the previous +/// `Host` block so its options are ignored rather than mis-applied. fn apply_config_file(config: &mut SshConfig, content: &str, host: &str) { let mut matching = false; @@ -101,9 +112,15 @@ fn apply_config_file(config: &mut SshConfig, content: &str, host: &str) { }; if keyword.eq_ignore_ascii_case("host") { - matching = rest - .split_whitespace() - .any(|pattern| match_pattern(host, pattern)); + matching = match_pattern_list(host, rest); + continue; + } + + if keyword.eq_ignore_ascii_case("match") { + // A conditional block whose criteria we do not evaluate: stop + // attributing the following options to the previous `Host` + // block, so they are ignored instead of mis-applied + matching = false; continue; } @@ -159,15 +176,20 @@ fn expand_tilde(path: &str) -> PathBuf { expand_tilde_with(path, current_home().as_deref()) } -/// One `Host` block pattern against a host name: exact match, `*` (any -/// run), `?` (one character) and `!pattern` negation (a matching negated -/// pattern excludes the host from the block). -fn match_pattern(host: &str, pattern: &str) -> bool { - if let Some(negated) = pattern.strip_prefix('!') { - !match_pattern(host, negated) - } else { - wildmatch(host, pattern) +/// One `Host` block pattern list against a host name, with the OpenSSH +/// rule: the block applies iff at least one positive pattern matches AND +/// no negated pattern matches — a matching `!pattern` excludes the host +/// from the whole block, regardless of the positive patterns. +fn match_pattern_list(host: &str, patterns: &str) -> bool { + let mut any_positive = false; + let mut any_negated = false; + for pattern in patterns.split_whitespace() { + match pattern.strip_prefix('!') { + Some(negated) => any_negated |= wildmatch(host, negated), + None => any_positive |= wildmatch(host, pattern), + } } + any_positive && !any_negated } /// `*`/`?` glob matching without allocation, enough for `Host` patterns @@ -567,16 +589,80 @@ mod tests { )); } + /// Negated `Host` patterns exclude the host from the whole block (the + /// OpenSSH rule): a block applies iff at least one positive pattern + /// matches AND no negated pattern matches. #[test] fn pattern_negation_excludes_host() { - assert!(!match_pattern("ppa.launchpad.net", "!*.launchpad.net")); - // A negated pattern only excludes; the other patterns of the block - // decide separately - assert!(match_pattern("example.com", "!*.launchpad.net example.com")); - assert!(!match_pattern( + // The classic catch-all-with-exception: `*` alone would match, but + // the negated pattern must exclude the host from the whole block + let config = apply_config_file_all( + "\ +Host * !*.launchpad.net + User fallback +", + "ppa.launchpad.net", + ); + assert_eq!(config, SshConfig::default()); + + // A negation with no positive pattern never matches either + assert!(!match_pattern_list("ppa.launchpad.net", "!*.launchpad.net")); + // A matching negation excludes despite a positive match... + assert!(!match_pattern_list( "ppa.launchpad.net", "example.com !*.launchpad.net" )); + // ...while an unrelated negation leaves the positive match intact + assert!(match_pattern_list("ppa.launchpad.net", "* !*.example.com")); + } + + /// OpenSSH's precedence: the user file is read before the system file + /// and the first value obtained wins, so the second (system) pass must + /// not override values the first (user) pass already obtained — while + /// options the user file leaves unset are still taken from the system + /// file. + #[test] + fn user_config_overrides_system() { + let user = "\ +Host ppa.launchpad.net + User myuser + Port 2222 +"; + let system = "\ +Host * + User login + Port 22 + HostName system.example.com +"; + + let mut config = SshConfig::default(); + apply_config_file(&mut config, user, "ppa.launchpad.net"); // ~/.ssh/config + apply_config_file(&mut config, system, "ppa.launchpad.net"); // /etc/ssh/ssh_config + + assert_eq!(config.user.as_deref(), Some("myuser")); + assert_eq!(config.port, Some(2222)); + assert_eq!(config.host_name.as_deref(), Some("system.example.com")); + } + + /// `Match` blocks are not evaluated: their options must be ignored, not + /// mis-attributed to the preceding `Host` block through a stale + /// matching flag. + #[test] + fn match_block_is_ignored_conservatively() { + let config = apply_config_file_all( + "\ +Host ppa.launchpad.net + User myuser +Match final all + Port 2223 + IdentityFile ~/.ssh/matched_key +", + "ppa.launchpad.net", + ); + + assert_eq!(config.user.as_deref(), Some("myuser")); + assert_eq!(config.port, None); + assert!(config.identity_files.is_empty()); } #[test]