From ad4954ec7788de70756403e287c9afd26c6d9345 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:32:07 +0800 Subject: [PATCH] docs(ssh): record why Match blocks are skipped, where they are skipped `Match` is dropped along with everything under it, and nothing in the code said whether that was a decision or an oversight. It is a decision -- `docs/remote/ssh.mdx` lists "No `Match` or `canonicalize*` directives" under what is not supported -- and it took a run of `ssh -G` against a config to establish that, which is a reason for the next reader not to have to. The comment also says why reading them is more than parsing them: `Match host` tests the *resolved* hostname, so it needs the pass it sits in; `Match exec` runs a command, which importing a config file should not do; `Match canonical` wants canonicalisation, which is on the same unsupported list. The test holds the documented shape: `ssh -G` answers `user alice` and `port 2222` for that config, tty7 answers the defaults, and the profile is still imported from its `Host` block. It is a limitation rather than a behaviour, so it is worth a test only because failing it means the page has to change in the same commit. --- src/core/ssh_config.rs | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs index 1e2e2399..021271b3 100644 --- a/src/core/ssh_config.rs +++ b/src/core/ssh_config.rs @@ -622,6 +622,19 @@ fn parse_config_file( options: Vec::new(), }); } else if key.eq_ignore_ascii_case("match") { + // Everything until the next `Host` is skipped, on purpose: + // "No `Match` or `canonicalize*` directives" is what + // `docs/remote/ssh.mdx` promises, and this is where that is kept. + // + // Reading them would mean more than parsing them. `Match host` + // tests the *resolved* hostname, so it needs the pass this one is + // producing; `Match exec` runs a command, which is not something an + // import of a config file should do; `Match canonical` needs + // canonicalisation, which is on the same list of things tty7 does + // not do. A profile imported from a config with a `Match` block + // therefore carries the `Host` block's answer alone — `ssh -G` + // would say `user alice`, and this says the default — which is the + // documented shape rather than an oversight. if let Some(block) = current.take() { blocks.push(block); } @@ -1023,6 +1036,41 @@ mod tests { /// own directory agrees for `~/.ssh/config` and diverges one level down, /// where `Include extra.conf` inside `~/.ssh/conf.d/x.conf` would reach /// `~/.ssh/conf.d/extra.conf` instead of the file ssh reads. + /// A `Match` block's settings do not reach an imported profile. + /// + /// `docs/remote/ssh.mdx` lists "No `Match` or `canonicalize*` directives" + /// under what is not supported, and `ssh -G` on this config answers + /// `user alice` and `port 2222` where tty7 answers the defaults. That gap + /// is the documented one. + /// + /// If this ever fails because `Match` was implemented, the page saying it + /// is not supported has to change in the same commit — which is the only + /// reason to hold a limitation in a test. + #[test] + fn a_match_block_is_not_read_into_a_profile() { + let root = temp_root("match-block"); + let ssh = root.join(".ssh"); + std::fs::create_dir_all(&ssh).unwrap(); + std::fs::write( + ssh.join("config"), + "Host work\n HostName work.example\n\n\ + Match host work.example\n User alice\n Port 2222\n", + ) + .unwrap(); + + let imported = import_profiles_from(ssh.join("config"), &root); + let work = imported + .iter() + .find(|p| p.profile.name == "work") + .expect("the Host block still imports"); + assert_eq!(work.profile.host, "work.example"); + assert_eq!( + (work.profile.user.as_str(), work.profile.port), + ("", 22), + "a Match block reached the profile; docs/remote/ssh.mdx says it does not" + ); + } + #[test] fn a_nested_include_resolves_against_ssh_not_the_including_file() { let root = temp_root("nested-includes");