From 116f1e58bc4088b9dec916a46703c293be24885d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:32:03 +0000 Subject: [PATCH 1/5] Start fix for #1135 Assisted-by: Claude Code:claude-opus-5-5 From 90cc2e0067e078fb85578ee97c7891639b09bda5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:38:34 +0000 Subject: [PATCH 2/5] Find the cargo home without HOME set When HOME and CARGO_HOME are both unset (env -i, systemd units, stripped CI or container entrypoints), cargo still builds from the account's passwd home, e.g. /root/.cargo. socket-patch looked only at HOME/USERPROFILE, so an agent scan probed no registry, reported the crate as lockfile-only and exited 0 while cargo compiled the unpatched copy. Global cargo scans found nothing, and the [patch] config chain skipped $CARGO_HOME/config.toml. Resolve the cargo home in one place the way cargo does: CARGO_HOME, then HOME/USERPROFILE, then the passwd entry on Unix. The crawler, the cargo config chain and the build-cache cleaner all use it. Fixes #1135 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/cargo_crawler.rs | 36 ++++-- .../src/utils/cargo_build_cache.rs | 13 +- .../socket-patch-core/src/utils/cargo_home.rs | 113 ++++++++++++++++++ crates/socket-patch-core/src/utils/mod.rs | 1 + .../src/vendor/cargo_config.rs | 9 +- 5 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 crates/socket-patch-core/src/utils/cargo_home.rs diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index a26b5185a..0b2ec9d93 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -261,15 +261,10 @@ impl CargoCrawler { Some((name.to_string(), version.to_string())) } - /// Get `CARGO_HOME`, defaulting to `$HOME/.cargo` (`None` with no - /// home). An empty value means unset (the env_non_empty convention) — - /// `PathBuf::from("")` would otherwise resolve `registry/src` against - /// the CWD and silently crawl nothing. + /// The cargo home cargo itself would use + /// ([`crate::utils::cargo_home::cargo_home`]). fn cargo_home() -> Option { - match std::env::var("CARGO_HOME") { - Ok(v) if !v.trim().is_empty() => Some(PathBuf::from(v)), - _ => crate::utils::fs::home_dir().map(|home| home.join(".cargo")), - } + crate::utils::cargo_home::cargo_home() } /// Where cargo takes crates.io packages from for a build in `cwd`: the @@ -1114,6 +1109,31 @@ version = "fake" assert_eq!(packages[0].purl, "pkg:cargo/serde@1.0.200"); } + /// #1135: with `HOME`, `USERPROFILE` and `CARGO_HOME` unset cargo still + /// builds from the passwd home's `~/.cargo`, so the crawler must probe + /// that registry instead of reporting every crate lockfile-only. + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn cargo_home_without_home_env_uses_passwd_home() { + let Some(passwd) = crate::crawlers::gradle_cache::passwd_home() else { + return; + }; + let keys = ["HOME", "USERPROFILE", "CARGO_HOME"]; + let saved: Vec<_> = keys.iter().map(|k| std::env::var_os(k)).collect(); + for k in keys { + std::env::remove_var(k); + } + let got = CargoCrawler::cargo_home(); + for (k, v) in keys.iter().zip(saved) { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + assert_eq!(got, Some(passwd.join(".cargo"))); + } + // ── Project-mode registry scope (#595) ────────────────────────────── mod lock_scope { diff --git a/crates/socket-patch-core/src/utils/cargo_build_cache.rs b/crates/socket-patch-core/src/utils/cargo_build_cache.rs index b12ce8f4e..8c12b1c08 100644 --- a/crates/socket-patch-core/src/utils/cargo_build_cache.rs +++ b/crates/socket-patch-core/src/utils/cargo_build_cache.rs @@ -43,14 +43,11 @@ pub struct Invalidation { /// The project's build directories, reading the process environment. pub fn build_dirs(cwd: &Path) -> BuildDirs { - build_dirs_with(cwd, &|k| std::env::var(k).ok(), cargo_home().as_deref()) -} - -fn cargo_home() -> Option { - match std::env::var("CARGO_HOME") { - Ok(v) if !v.trim().is_empty() => Some(PathBuf::from(v)), - _ => crate::utils::fs::home_dir().map(|h| h.join(".cargo")), - } + build_dirs_with( + cwd, + &|k| std::env::var(k).ok(), + super::cargo_home::cargo_home().as_deref(), + ) } /// [`build_dirs`] with an injected environment and `CARGO_HOME`. diff --git a/crates/socket-patch-core/src/utils/cargo_home.rs b/crates/socket-patch-core/src/utils/cargo_home.rs new file mode 100644 index 000000000..a7a032f44 --- /dev/null +++ b/crates/socket-patch-core/src/utils/cargo_home.rs @@ -0,0 +1,113 @@ +//! The cargo home cargo itself would use: `$CARGO_HOME`, else +//! `/.cargo` with `` resolved the way cargo's `home` crate +//! resolves it. +//! +//! [`super::fs::home_dir`] reads only `HOME` / `USERPROFILE`, but cargo +//! falls back to the account's passwd entry on Unix when `HOME` is unset +//! (`env -i`, systemd units, stripped CI and container entrypoints). A +//! crawler that stopped at `home_dir` probed no registry cache there and +//! reported the crate lockfile-only while cargo built the unpatched copy +//! from the passwd home's `~/.cargo` (#1135). Every cargo-home reader (the +//! crawler, the `[patch]` config chain, the build-cache cleaner) goes +//! through [`cargo_home`] so they agree with cargo and with each other. + +use std::ffi::OsString; +use std::path::PathBuf; + +/// The process's cargo home: `$CARGO_HOME` when set and non-blank, else +/// `/.cargo`, where `` is `HOME` / `USERPROFILE` and then, on +/// Unix, the passwd entry. `None` when none of them names a directory. +pub fn cargo_home() -> Option { + resolve( + std::env::var_os("CARGO_HOME"), + super::fs::home_dir(), + crate::crawlers::gradle_cache::passwd_home, + ) +} + +/// [`cargo_home`] over an injected `CARGO_HOME`, env home and passwd home +/// (only consulted when the first two give nothing). +fn resolve( + cargo_home_var: Option, + env_home: Option, + passwd_home: impl FnOnce() -> Option, +) -> Option { + // An empty value means unset (the env_non_empty convention): + // `PathBuf::from("")` would resolve `registry/src` against the CWD. + if let Some(v) = cargo_home_var.filter(|v| !v.to_string_lossy().trim().is_empty()) { + return Some(PathBuf::from(v)); + } + env_home + .or_else(|| passwd_home().filter(|h| super::fs::is_usable_home(h))) + .map(|home| home.join(".cargo")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_passwd() -> Option { + None + } + + #[test] + fn cargo_home_var_wins() { + let got = resolve(Some("/opt/cargo".into()), Some("/home/u".into()), || { + panic!("passwd consulted with CARGO_HOME set") + }); + assert_eq!(got, Some(PathBuf::from("/opt/cargo"))); + } + + #[test] + fn blank_cargo_home_var_is_unset() { + let got = resolve(Some(" ".into()), Some("/home/u".into()), no_passwd); + assert_eq!(got, Some(PathBuf::from("/home/u/.cargo"))); + } + + #[test] + fn env_home_wins_over_passwd() { + let got = resolve(None, Some("/home/u".into()), || { + Some("/var/lib/other".into()) + }); + assert_eq!(got, Some(PathBuf::from("/home/u/.cargo"))); + } + + /// #1135: with `HOME` and `CARGO_HOME` unset cargo builds from the + /// passwd home's `~/.cargo`, so the crawler must probe it too. + #[test] + fn no_env_home_falls_back_to_passwd_home() { + let got = resolve(None, None, || Some("/root".into())); + assert_eq!(got, Some(PathBuf::from("/root/.cargo"))); + } + + #[test] + fn relative_or_missing_passwd_home_is_no_home() { + assert_eq!(resolve(None, None, || Some("rel".into())), None); + assert_eq!(resolve(None, None, || Some(PathBuf::new())), None); + assert_eq!(resolve(None, None, no_passwd), None); + } + + /// #1135 through the process environment: `HOME`, `USERPROFILE` and + /// `CARGO_HOME` all unset still finds the passwd home's `.cargo`. + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn process_cargo_home_without_home_uses_passwd_entry() { + let Some(passwd) = crate::crawlers::gradle_cache::passwd_home() else { + return; // no passwd entry for this uid: nothing cargo could use either + }; + let keys = ["HOME", "USERPROFILE", "CARGO_HOME"]; + let saved: Vec<_> = keys.iter().map(|k| std::env::var_os(k)).collect(); + for k in keys { + std::env::remove_var(k); + } + let got = cargo_home(); + for (k, v) in keys.iter().zip(saved) { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + assert_eq!(got, Some(passwd.join(".cargo"))); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 520c9c430..6d13f9fbb 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -1,4 +1,5 @@ pub mod cargo_build_cache; +pub mod cargo_home; pub mod cargo_workspace; pub mod composer_version; pub mod concurrent; diff --git a/crates/socket-patch-core/src/vendor/cargo_config.rs b/crates/socket-patch-core/src/vendor/cargo_config.rs index b75a4095f..3a3966708 100644 --- a/crates/socket-patch-core/src/vendor/cargo_config.rs +++ b/crates/socket-patch-core/src/vendor/cargo_config.rs @@ -150,13 +150,10 @@ pub struct ChainConfig { /// every ancestor directory's, and `$CARGO_HOME`'s (default `~/.cargo`), in /// that order (cargo lets a config item replace the manifest item with the /// same key, whatever its version). Read-only and fail-soft: missing / -/// unreadable / malformed files contribute nothing, and with no -/// `$CARGO_HOME` and no home directory there is no home config. +/// unreadable / malformed files contribute nothing, and with no cargo home +/// ([`crate::utils::cargo_home::cargo_home`]) there is no home config. pub async fn read_config_chain(project_root: &Path) -> Vec { - let cargo_home = match std::env::var("CARGO_HOME") { - Ok(v) if !v.trim().is_empty() => Some(PathBuf::from(v)), - _ => crate::utils::fs::home_dir().map(|home| home.join(".cargo")), - }; + let cargo_home = crate::utils::cargo_home::cargo_home(); read_config_chain_with(project_root, cargo_home.as_deref()).await } From 07494a0b3671bc5ab08c63bab0bd7c128d5b2f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:12:19 +0000 Subject: [PATCH 3/5] Share the HOME-less test helper; document rule The two #1135 regression tests now use one helper to clear HOME, USERPROFILE and CARGO_HOME. docs/ecosystems.md says how the cargo cache is found when CARGO_HOME is unset. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/cargo_crawler.rs | 13 +------- .../socket-patch-core/src/utils/cargo_home.rs | 33 ++++++++++++------- docs/ecosystems.md | 5 ++- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index 0b2ec9d93..0e3135afc 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -1119,18 +1119,7 @@ version = "fake" let Some(passwd) = crate::crawlers::gradle_cache::passwd_home() else { return; }; - let keys = ["HOME", "USERPROFILE", "CARGO_HOME"]; - let saved: Vec<_> = keys.iter().map(|k| std::env::var_os(k)).collect(); - for k in keys { - std::env::remove_var(k); - } - let got = CargoCrawler::cargo_home(); - for (k, v) in keys.iter().zip(saved) { - match v { - Some(v) => std::env::set_var(k, v), - None => std::env::remove_var(k), - } - } + let got = crate::utils::cargo_home::without_home_env(CargoCrawler::cargo_home); assert_eq!(got, Some(passwd.join(".cargo"))); } diff --git a/crates/socket-patch-core/src/utils/cargo_home.rs b/crates/socket-patch-core/src/utils/cargo_home.rs index a7a032f44..567e64be7 100644 --- a/crates/socket-patch-core/src/utils/cargo_home.rs +++ b/crates/socket-patch-core/src/utils/cargo_home.rs @@ -42,6 +42,26 @@ fn resolve( .map(|home| home.join(".cargo")) } +/// Runs `f` with `HOME`, `USERPROFILE` and `CARGO_HOME` removed from the +/// process environment, restoring them after. Callers are +/// `#[serial_test::serial]`. +#[cfg(test)] +pub(crate) fn without_home_env(f: impl FnOnce() -> T) -> T { + let keys = ["HOME", "USERPROFILE", "CARGO_HOME"]; + let saved: Vec<_> = keys.iter().map(std::env::var_os).collect(); + for k in keys { + std::env::remove_var(k); + } + let out = f(); + for (k, v) in keys.iter().zip(saved) { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -96,18 +116,7 @@ mod tests { let Some(passwd) = crate::crawlers::gradle_cache::passwd_home() else { return; // no passwd entry for this uid: nothing cargo could use either }; - let keys = ["HOME", "USERPROFILE", "CARGO_HOME"]; - let saved: Vec<_> = keys.iter().map(|k| std::env::var_os(k)).collect(); - for k in keys { - std::env::remove_var(k); - } - let got = cargo_home(); - for (k, v) in keys.iter().zip(saved) { - match v { - Some(v) => std::env::set_var(k, v), - None => std::env::remove_var(k), - } - } + let got = without_home_env(cargo_home); assert_eq!(got, Some(passwd.join(".cargo"))); } } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 62b0b8556..bf2a40be4 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1247,7 +1247,10 @@ the repository list, so it is left as a Agent mode patches the crate in place wherever the crawler finds it. For a non-vendored crate that means the **shared** `$CARGO_HOME/registry` cache: the patch affects every project on the machine, and is silently reset by `cargo clean` or a cache prune. Use -`--mode vendored` for a project-local, committable patch. +`--mode vendored` for a project-local, committable patch. With `CARGO_HOME` unset the +cache is `/.cargo/registry`, with `` found the way cargo finds it: `HOME` +(`USERPROFILE` on Windows), then, on Unix, the account's passwd entry, so a run with +`HOME` unset still patches the copy cargo builds. The cache holds one extracted copy of a crate per registry index directory under `registry/src` (cargo 1.70–1.84 and cargo 1.85+ use different ones), so two toolchains From be7e923246b49858a472d37804b46cf891fb7ad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:21:25 +0000 Subject: [PATCH 4/5] Match cargo's home rule exactly on each OS Review follow-ups for the cargo home fix: - Read only HOME on Unix and only USERPROFILE on Windows, as cargo does, so a stray USERPROFILE on Linux can't redirect the cache. - Use the passwd entry only when HOME is unset. A set but empty or relative HOME still means no home, so we never patch the shared passwd-home cache that cargo isn't using. - Look the passwd entry up once per process, from utils::fs, so cargo no longer depends on the Gradle crawler. - Self-update's cargo-install check uses the same rule. - The test env guard now restores HOME even if a test panics. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/cargo_crawler.rs | 7 +- .../src/crawlers/gradle_cache.rs | 39 +----- .../socket-patch-core/src/update/channel.rs | 2 +- .../socket-patch-core/src/utils/cargo_home.rs | 130 +++++++++++++----- crates/socket-patch-core/src/utils/fs.rs | 47 +++++++ 5 files changed, 147 insertions(+), 78 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index 0e3135afc..b03b797ed 100644 --- a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs @@ -1116,10 +1116,13 @@ version = "fake" #[test] #[serial_test::serial] fn cargo_home_without_home_env_uses_passwd_home() { - let Some(passwd) = crate::crawlers::gradle_cache::passwd_home() else { + let Some(passwd) = crate::utils::fs::passwd_home() else { return; }; - let got = crate::utils::cargo_home::without_home_env(CargoCrawler::cargo_home); + let got = { + let _env = crate::utils::cargo_home::NoHomeEnv::set(); + CargoCrawler::cargo_home() + }; assert_eq!(got, Some(passwd.join(".cargo"))); } diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index 8774a0f6e..fdab8df73 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -522,44 +522,7 @@ impl Env for ProcessEnv { } } -/// The account's home directory from the passwd database -/// (`getpwuid_r(getuid())->pw_dir`): what the JVM reports as `user.home` -/// on Linux and macOS, whatever `$HOME` says. `None` on Windows, and when -/// there is no entry or it names no directory. -#[cfg(unix)] -pub fn passwd_home() -> Option { - use std::ffi::CStr; - use std::os::unix::ffi::OsStrExt; - - let uid = unsafe { libc::getuid() }; - let mut buf: Vec = vec![0; 4096]; - // SAFETY: an all-zero `passwd` is a valid out-parameter; it is only - // read when `getpwuid_r` reports a result, and its strings point into - // `buf`, which outlives every read below. - let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; - let mut result: *mut libc::passwd = std::ptr::null_mut(); - loop { - // SAFETY: `buf` is writable for `buf.len()` bytes. - let rc = - unsafe { libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result) }; - if rc == libc::ERANGE && buf.len() < (1 << 20) { - buf.resize(buf.len() * 2, 0); - continue; - } - break; - } - if result.is_null() || pwd.pw_dir.is_null() { - return None; - } - // SAFETY: a non-null `pw_dir` is a NUL-terminated string inside `buf`. - let dir = unsafe { CStr::from_ptr(pwd.pw_dir) }.to_bytes(); - (!dir.is_empty()).then(|| PathBuf::from(std::ffi::OsStr::from_bytes(dir))) -} - -#[cfg(not(unix))] -pub fn passwd_home() -> Option { - None -} +pub use crate::utils::fs::passwd_home; /// The Gradle user home this process's Gradle would use /// ([`GradleHome::resolve`] over the process environment and, on Unix, diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs index 143a9af97..7794e6526 100644 --- a/crates/socket-patch-core/src/update/channel.rs +++ b/crates/socket-patch-core/src/update/channel.rs @@ -67,7 +67,7 @@ impl ChannelEnv { .map(PathBuf::from) } ChannelEnv { - cargo_home: path_var("CARGO_HOME"), + cargo_home: crate::utils::cargo_home::cargo_home(), xdg_cache_home: path_var("XDG_CACHE_HOME"), home: crate::utils::fs::home_dir(), local_app_data: path_var("LOCALAPPDATA"), diff --git a/crates/socket-patch-core/src/utils/cargo_home.rs b/crates/socket-patch-core/src/utils/cargo_home.rs index 567e64be7..021e23691 100644 --- a/crates/socket-patch-core/src/utils/cargo_home.rs +++ b/crates/socket-patch-core/src/utils/cargo_home.rs @@ -7,29 +7,35 @@ //! (`env -i`, systemd units, stripped CI and container entrypoints). A //! crawler that stopped at `home_dir` probed no registry cache there and //! reported the crate lockfile-only while cargo built the unpatched copy -//! from the passwd home's `~/.cargo` (#1135). Every cargo-home reader (the -//! crawler, the `[patch]` config chain, the build-cache cleaner) goes -//! through [`cargo_home`] so they agree with cargo and with each other. +//! from the passwd home's `~/.cargo` (#1135). The cargo-home readers (the +//! crawler, the `[patch]` config chain, the build-cache cleaner and the +//! self-update channel check) go through [`cargo_home`] so they agree with +//! cargo and with each other. use std::ffi::OsString; use std::path::PathBuf; +/// The variable cargo's `home` crate reads the user home from: `HOME` on +/// Unix, `USERPROFILE` on Windows (cargo ignores the other one). +const HOME_VAR: &str = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + /// The process's cargo home: `$CARGO_HOME` when set and non-blank, else -/// `/.cargo`, where `` is `HOME` / `USERPROFILE` and then, on -/// Unix, the passwd entry. `None` when none of them names a directory. +/// `/.cargo`, where `` is `HOME` (`USERPROFILE` on Windows) or, +/// with that variable unset on Unix, the passwd entry. `None` when none of +/// them names an absolute directory. pub fn cargo_home() -> Option { resolve( std::env::var_os("CARGO_HOME"), - super::fs::home_dir(), - crate::crawlers::gradle_cache::passwd_home, + std::env::var_os(HOME_VAR), + super::fs::passwd_home, ) } -/// [`cargo_home`] over an injected `CARGO_HOME`, env home and passwd home -/// (only consulted when the first two give nothing). +/// [`cargo_home`] over an injected `CARGO_HOME`, home variable and passwd +/// home (consulted only when the home variable is unset). fn resolve( cargo_home_var: Option, - env_home: Option, + home_var: Option, passwd_home: impl FnOnce() -> Option, ) -> Option { // An empty value means unset (the env_non_empty convention): @@ -37,29 +43,49 @@ fn resolve( if let Some(v) = cargo_home_var.filter(|v| !v.to_string_lossy().trim().is_empty()) { return Some(PathBuf::from(v)); } - env_home - .or_else(|| passwd_home().filter(|h| super::fs::is_usable_home(h))) + // A set-but-empty or relative home stays no home (see + // `fs::home_dir`): probing it would read directories inside the + // project, and the passwd home is not where cargo looks then either. + let home = match home_var { + Some(v) => Some(PathBuf::from(v)), + None => passwd_home(), + }; + home.filter(|h| super::fs::is_usable_home(h)) .map(|home| home.join(".cargo")) } -/// Runs `f` with `HOME`, `USERPROFILE` and `CARGO_HOME` removed from the -/// process environment, restoring them after. Callers are -/// `#[serial_test::serial]`. +/// Removes `HOME`, `USERPROFILE` and `CARGO_HOME` from the process +/// environment until dropped, restoring them even if the test panics. +/// Users are `#[serial_test::serial]`. +#[cfg(test)] +pub(crate) struct NoHomeEnv(Vec<(&'static str, Option)>); + #[cfg(test)] -pub(crate) fn without_home_env(f: impl FnOnce() -> T) -> T { - let keys = ["HOME", "USERPROFILE", "CARGO_HOME"]; - let saved: Vec<_> = keys.iter().map(std::env::var_os).collect(); - for k in keys { - std::env::remove_var(k); +impl NoHomeEnv { + const KEYS: [&'static str; 3] = ["HOME", "USERPROFILE", "CARGO_HOME"]; + + pub(crate) fn set() -> Self { + let saved = Self::KEYS + .into_iter() + .map(|k| (k, std::env::var_os(k))) + .collect(); + for k in Self::KEYS { + std::env::remove_var(k); + } + NoHomeEnv(saved) } - let out = f(); - for (k, v) in keys.iter().zip(saved) { - match v { - Some(v) => std::env::set_var(k, v), - None => std::env::remove_var(k), +} + +#[cfg(test)] +impl Drop for NoHomeEnv { + fn drop(&mut self) { + for (k, v) in self.0.drain(..) { + match v { + Some(v) => std::env::set_var(k, v), + None => std::env::remove_var(k), + } } } - out } #[cfg(test)] @@ -70,36 +96,48 @@ mod tests { None } + fn passwd_unused() -> Option { + panic!("passwd home consulted with the home variable set") + } + #[test] fn cargo_home_var_wins() { - let got = resolve(Some("/opt/cargo".into()), Some("/home/u".into()), || { - panic!("passwd consulted with CARGO_HOME set") - }); + let got = resolve( + Some("/opt/cargo".into()), + Some("/home/u".into()), + passwd_unused, + ); assert_eq!(got, Some(PathBuf::from("/opt/cargo"))); } #[test] fn blank_cargo_home_var_is_unset() { - let got = resolve(Some(" ".into()), Some("/home/u".into()), no_passwd); + let got = resolve(Some(" ".into()), Some("/home/u".into()), passwd_unused); assert_eq!(got, Some(PathBuf::from("/home/u/.cargo"))); } #[test] - fn env_home_wins_over_passwd() { - let got = resolve(None, Some("/home/u".into()), || { - Some("/var/lib/other".into()) - }); + fn home_var_wins_over_passwd() { + let got = resolve(None, Some("/home/u".into()), passwd_unused); assert_eq!(got, Some(PathBuf::from("/home/u/.cargo"))); } /// #1135: with `HOME` and `CARGO_HOME` unset cargo builds from the /// passwd home's `~/.cargo`, so the crawler must probe it too. #[test] - fn no_env_home_falls_back_to_passwd_home() { + fn unset_home_var_falls_back_to_passwd_home() { let got = resolve(None, None, || Some("/root".into())); assert_eq!(got, Some(PathBuf::from("/root/.cargo"))); } + /// A set-but-unusable home is no home, not a cue to patch the shared + /// passwd-home cache cargo isn't using. + #[test] + fn empty_or_relative_home_var_is_no_home() { + assert_eq!(resolve(None, Some("".into()), passwd_unused), None); + assert_eq!(resolve(None, Some("rel/home".into()), passwd_unused), None); + } + #[test] fn relative_or_missing_passwd_home_is_no_home() { assert_eq!(resolve(None, None, || Some("rel".into())), None); @@ -107,16 +145,34 @@ mod tests { assert_eq!(resolve(None, None, no_passwd), None); } + /// The process wrapper reads `HOME` only on Unix: a `USERPROFILE` + /// left over from a Windows-forwarding shell is not cargo's home. + #[cfg(unix)] + #[test] + #[serial_test::serial] + fn process_cargo_home_ignores_userprofile_on_unix() { + let want = super::super::fs::passwd_home().map(|h| h.join(".cargo")); + let got = { + let _env = NoHomeEnv::set(); + std::env::set_var("USERPROFILE", "/mnt/c/Users/x"); + cargo_home() + }; + assert_eq!(got, want); + } + /// #1135 through the process environment: `HOME`, `USERPROFILE` and /// `CARGO_HOME` all unset still finds the passwd home's `.cargo`. #[cfg(unix)] #[test] #[serial_test::serial] fn process_cargo_home_without_home_uses_passwd_entry() { - let Some(passwd) = crate::crawlers::gradle_cache::passwd_home() else { + let Some(passwd) = super::super::fs::passwd_home() else { return; // no passwd entry for this uid: nothing cargo could use either }; - let got = without_home_env(cargo_home); + let got = { + let _env = NoHomeEnv::set(); + cargo_home() + }; assert_eq!(got, Some(passwd.join(".cargo"))); } } diff --git a/crates/socket-patch-core/src/utils/fs.rs b/crates/socket-patch-core/src/utils/fs.rs index beebe9bc1..af07667f2 100644 --- a/crates/socket-patch-core/src/utils/fs.rs +++ b/crates/socket-patch-core/src/utils/fs.rs @@ -397,6 +397,53 @@ pub(crate) fn home_from_env(var: impl Fn(&str) -> Option) -> .find(|home| is_usable_home(home)) } +/// The account's home directory from the passwd database +/// (`getpwuid_r(getuid())->pw_dir`): what the JVM reports as `user.home` +/// on Linux and macOS, and the home cargo falls back to with `HOME` unset, +/// whatever `$HOME` says. `None` on Windows, and when there is no entry or +/// it names no directory. Looked up once per process (an NSS lookup may +/// reach a directory server). +#[cfg(unix)] +pub fn passwd_home() -> Option { + static HOME: std::sync::OnceLock> = std::sync::OnceLock::new(); + HOME.get_or_init(lookup_passwd_home).clone() +} + +#[cfg(unix)] +fn lookup_passwd_home() -> Option { + use std::ffi::CStr; + use std::os::unix::ffi::OsStrExt; + + let uid = unsafe { libc::getuid() }; + let mut buf: Vec = vec![0; 4096]; + // SAFETY: an all-zero `passwd` is a valid out-parameter; it is only + // read when `getpwuid_r` reports a result, and its strings point into + // `buf`, which outlives every read below. + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + loop { + // SAFETY: `buf` is writable for `buf.len()` bytes. + let rc = + unsafe { libc::getpwuid_r(uid, &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result) }; + if rc == libc::ERANGE && buf.len() < (1 << 20) { + buf.resize(buf.len() * 2, 0); + continue; + } + break; + } + if result.is_null() || pwd.pw_dir.is_null() { + return None; + } + // SAFETY: a non-null `pw_dir` is a NUL-terminated string inside `buf`. + let dir = unsafe { CStr::from_ptr(pwd.pw_dir) }.to_bytes(); + (!dir.is_empty()).then(|| PathBuf::from(std::ffi::OsStr::from_bytes(dir))) +} + +#[cfg(not(unix))] +pub fn passwd_home() -> Option { + None +} + /// Whether `home` may anchor per-user probes: not empty and not /// CWD-relative. pub(crate) fn is_usable_home(home: &Path) -> bool { From 6566a4269529e7b243cf0ae6e5515a0ec29f4014 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 14:16:41 +0000 Subject: [PATCH 5/5] Use the passwd home when HOME is empty too Cargo's home lookup (std::env::home_dir) skips an empty HOME and falls back to the passwd entry, the same as an unset HOME. We treated HOME= as "no home", so a run with an empty HOME still found no cargo cache and reported every crate lockfile-only. A relative HOME is still refused, since cargo uses it as is. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/utils/cargo_home.rs | 41 ++++++++++++------- docs/ecosystems.md | 2 +- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-core/src/utils/cargo_home.rs b/crates/socket-patch-core/src/utils/cargo_home.rs index 021e23691..371cbc8e0 100644 --- a/crates/socket-patch-core/src/utils/cargo_home.rs +++ b/crates/socket-patch-core/src/utils/cargo_home.rs @@ -21,8 +21,8 @@ const HOME_VAR: &str = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; /// The process's cargo home: `$CARGO_HOME` when set and non-blank, else /// `/.cargo`, where `` is `HOME` (`USERPROFILE` on Windows) or, -/// with that variable unset on Unix, the passwd entry. `None` when none of -/// them names an absolute directory. +/// with that variable unset or empty on Unix, the passwd entry. `None` when +/// none of them names an absolute directory. pub fn cargo_home() -> Option { resolve( std::env::var_os("CARGO_HOME"), @@ -32,7 +32,7 @@ pub fn cargo_home() -> Option { } /// [`cargo_home`] over an injected `CARGO_HOME`, home variable and passwd -/// home (consulted only when the home variable is unset). +/// home (consulted only when the home variable is unset or empty). fn resolve( cargo_home_var: Option, home_var: Option, @@ -43,10 +43,12 @@ fn resolve( if let Some(v) = cargo_home_var.filter(|v| !v.to_string_lossy().trim().is_empty()) { return Some(PathBuf::from(v)); } - // A set-but-empty or relative home stays no home (see - // `fs::home_dir`): probing it would read directories inside the - // project, and the passwd home is not where cargo looks then either. - let home = match home_var { + // std's `home_dir` (which cargo's `home` crate calls on Unix) skips an + // empty `HOME` for the passwd entry. A relative one it uses as is, so + // that stays no home here (see `fs::home_dir`): probing it would read + // directories inside the project, and the passwd home is not where + // cargo looks then either. + let home = match home_var.filter(|v| !v.is_empty()) { Some(v) => Some(PathBuf::from(v)), None => passwd_home(), }; @@ -130,11 +132,18 @@ mod tests { assert_eq!(got, Some(PathBuf::from("/root/.cargo"))); } - /// A set-but-unusable home is no home, not a cue to patch the shared + /// std's `home_dir` treats `HOME=` like an unset `HOME`, so cargo + /// builds from the passwd home then too. + #[test] + fn empty_home_var_falls_back_to_passwd_home() { + let got = resolve(None, Some("".into()), || Some("/root".into())); + assert_eq!(got, Some(PathBuf::from("/root/.cargo"))); + } + + /// A relative home is no home, not a cue to patch the shared /// passwd-home cache cargo isn't using. #[test] - fn empty_or_relative_home_var_is_no_home() { - assert_eq!(resolve(None, Some("".into()), passwd_unused), None); + fn relative_home_var_is_no_home() { assert_eq!(resolve(None, Some("rel/home".into()), passwd_unused), None); } @@ -161,7 +170,8 @@ mod tests { } /// #1135 through the process environment: `HOME`, `USERPROFILE` and - /// `CARGO_HOME` all unset still finds the passwd home's `.cargo`. + /// `CARGO_HOME` all unset, or `HOME` empty, still finds the passwd + /// home's `.cargo`. #[cfg(unix)] #[test] #[serial_test::serial] @@ -169,10 +179,13 @@ mod tests { let Some(passwd) = super::super::fs::passwd_home() else { return; // no passwd entry for this uid: nothing cargo could use either }; - let got = { + let (unset, empty) = { let _env = NoHomeEnv::set(); - cargo_home() + let unset = cargo_home(); + std::env::set_var("HOME", ""); + (unset, cargo_home()) }; - assert_eq!(got, Some(passwd.join(".cargo"))); + assert_eq!(unset, Some(passwd.join(".cargo"))); + assert_eq!(empty, Some(passwd.join(".cargo"))); } } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index bf2a40be4..ed01e00e8 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -1250,7 +1250,7 @@ project on the machine, and is silently reset by `cargo clean` or a cache prune. `--mode vendored` for a project-local, committable patch. With `CARGO_HOME` unset the cache is `/.cargo/registry`, with `` found the way cargo finds it: `HOME` (`USERPROFILE` on Windows), then, on Unix, the account's passwd entry, so a run with -`HOME` unset still patches the copy cargo builds. +`HOME` unset or empty still patches the copy cargo builds. The cache holds one extracted copy of a crate per registry index directory under `registry/src` (cargo 1.70–1.84 and cargo 1.85+ use different ones), so two toolchains