From 3be35231497f33f38aeffd84df02642479995bc6 Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Sun, 13 Sep 2026 18:51:04 +0400 Subject: [PATCH] fix(remote): resolve ssh host aliases from git bash on windows (#4054) Windows OpenSSH resolves drive-letter Include paths, but Git for Windows' MSYS OpenSSH does not, so the managed remote SSH config silently ignored the user's ~/.ssh/config when herdr ran under Git Bash. Include the user config through ~/.ssh/config, which both implementations expand to the same file. refs #3947 --- src/remote/attach.rs | 53 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/src/remote/attach.rs b/src/remote/attach.rs index e6abc8f4..a597f564 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -2298,6 +2298,27 @@ fn ssh_config_include_path(path: &Path) -> String { } } +/// Returns the `Include` value for the user's SSH config, or `None` when there +/// is nothing useful to include. +/// +/// Git for Windows' OpenSSH (MSYS) does not resolve Windows drive-letter paths +/// inside `Include`, so an absolute `C:/.../.ssh/config` path is silently +/// ignored when herdr runs under Git Bash and host aliases stop resolving. +/// `~/.ssh/config` is expanded by both Windows OpenSSH (to the user profile) +/// and MSYS OpenSSH (through `HOME`), so each shell's `ssh` reads the same user +/// config it would read by default. A missing config is harmless because +/// OpenSSH ignores an `Include` that matches nothing. +#[cfg(windows)] +fn ssh_user_config_include(_path: Option<&Path>) -> Option { + Some(ssh_config_quote("~/.ssh/config")) +} + +#[cfg(not(windows))] +fn ssh_user_config_include(path: Option<&Path>) -> Option { + path.filter(|path| path.is_file()) + .map(ssh_config_include_path) +} + /// Builds a temporary ssh config that includes the user's settings first, so /// OpenSSH's first-value-wins behavior preserves explicit user keepalives. fn write_managed_ssh_config() -> io::Result { @@ -2309,11 +2330,8 @@ fn write_managed_ssh_config() -> io::Result { .then(|| dir.join(SSH_CONTROL_SOCKET_NAME)); let mut contents = String::new(); - if let Some(user_config) = paths.user_config.filter(|path| path.is_file()) { - contents.push_str(&format!( - "Include {}\n", - ssh_config_include_path(&user_config) - )); + if let Some(include) = ssh_user_config_include(paths.user_config.as_deref()) { + contents.push_str(&format!("Include {include}\n")); } if let Some(system_config) = paths.system_config.filter(|path| path.is_file()) { contents.push_str(&format!( @@ -3214,6 +3232,16 @@ mod tests { let contents = std::fs::read_to_string(&config_path).expect("read managed config"); assert!(contents.contains("ServerAliveInterval 15")); assert!(contents.contains("ServerAliveCountMax 4")); + // Git Bash's MSYS OpenSSH ignores drive-letter `Include` paths, so the + // user config must be referenced through `~` for aliases to resolve. + let include_at = contents + .find("Include \"~/.ssh/config\"") + .expect("user config Included through home"); + let fallback_at = contents.find("Host *").expect("fallback present"); + assert!( + include_at < fallback_at, + "user config must be Included before herdr's fallback: {contents}" + ); let ssh = RemoteSsh { target: "example".to_string(), @@ -3246,6 +3274,21 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn windows_ssh_user_config_include_uses_home_shorthand() { + // MSYS/Git Bash OpenSSH does not resolve `C:/...` in `Include`, so the + // user config is referenced through `~` regardless of the profile path. + assert_eq!( + ssh_user_config_include(Some(Path::new(r"C:\Users\A B\.ssh\config"))), + Some(r#""~/.ssh/config""#.to_string()) + ); + assert_eq!( + ssh_user_config_include(None), + Some(r#""~/.ssh/config""#.to_string()) + ); + } + #[test] fn noninteractive_ssh_stderr_capture_is_bounded() { let stderr = vec![b'x'; NONINTERACTIVE_SSH_STDERR_LIMIT + 4096];