From 57db98d77643a83674f8723287b010aabbd94d9d Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Thu, 17 Sep 2026 16:31:04 +0200 Subject: [PATCH] put: expand ~ in ssh_config IdentityFile paths IdentityFile values were stored verbatim, so the near-universal 'IdentityFile ~/.ssh/key' spelling never matched an existing file and the key was silently skipped during authentication. Expand a leading ~ (only that form; ~user and embedded tildes stay verbatim) against the user's home directory when parsing. --- src/put/ssh.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/src/put/ssh.rs b/src/put/ssh.rs index 625c812..7f97b66 100644 --- a/src/put/ssh.rs +++ b/src/put/ssh.rs @@ -120,7 +120,7 @@ fn apply_config_file(config: &mut SshConfig, content: &str, host: &str) { config.port = Some(port); } } else if keyword.eq_ignore_ascii_case("identityfile") { - let path = PathBuf::from(rest); + let path = expand_tilde(rest); if !config.identity_files.contains(&path) { config.identity_files.push(path); } @@ -128,6 +128,37 @@ fn apply_config_file(config: &mut SshConfig, content: &str, host: &str) { } } +/// The current user's home directory: the `directories` crate first (which +/// consults `$HOME` on Unix), with a direct `$HOME` fallback. +fn current_home() -> Option { + directories::UserDirs::new() + .map(|dirs| dirs.home_dir().to_path_buf()) + .or_else(|| std::env::var_os("HOME").map(PathBuf::from)) +} + +/// Expand a leading `~` or `~/` in `path` against `home`: the near-universal +/// `IdentityFile ~/.ssh/key` spelling. Only a leading tilde is handled — +/// `~user/...` (another user's home) and tildes appearing anywhere else are +/// kept verbatim — and without a known home directory the path is returned +/// unchanged. +fn expand_tilde_with(path: &str, home: Option<&Path>) -> PathBuf { + let Some(home) = home else { + return PathBuf::from(path); + }; + if path == "~" { + home.to_path_buf() + } else if let Some(rest) = path.strip_prefix("~/") { + home.join(rest) + } else { + PathBuf::from(path) + } +} + +/// [`expand_tilde_with`] against the current user's home directory +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). @@ -557,17 +588,43 @@ Host * ); assert_eq!(config.user.as_deref(), Some("myuser")); + // A leading tilde is expanded to the home directory, built from the + // same lookup the helper uses so no specific username is assumed + let home = current_home().expect("tests require a home directory"); assert_eq!( config.identity_files, - vec![ - PathBuf::from("~/.ssh/lp_key"), - PathBuf::from("~/.ssh/other_key") - ] + vec![home.join(".ssh/lp_key"), home.join(".ssh/other_key")] ); assert_eq!(config.host_name, None); assert_eq!(config.port, None); } + /// Only a leading `~`/`~/` expands to the home directory; `~user/...` + /// and non-tilde paths are kept verbatim, and nothing is expanded + /// without a known home directory + #[test] + fn expand_tilde_handles_leading_tilde_only() { + let home = Some(Path::new("/home/testuser")); + assert_eq!( + expand_tilde_with("~/x", home), + PathBuf::from("/home/testuser/x") + ); + assert_eq!( + expand_tilde_with("~", home), + PathBuf::from("/home/testuser") + ); + assert_eq!( + expand_tilde_with("~other/x", home), + PathBuf::from("~other/x") + ); + assert_eq!( + expand_tilde_with("relative", home), + PathBuf::from("relative") + ); + assert_eq!(expand_tilde_with("/abs", home), PathBuf::from("/abs")); + assert_eq!(expand_tilde_with("~/x", None), PathBuf::from("~/x")); + } + #[test] fn config_wildcard_and_question_marks_match() { let config = apply_config_file_all(