diff --git a/crates/socket-patch-core/src/crawlers/cargo_crawler.rs b/crates/socket-patch-core/src/crawlers/cargo_crawler.rs index a26b5185a..b03b797ed 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,23 @@ 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::utils::fs::passwd_home() else { + return; + }; + let got = { + let _env = crate::utils::cargo_home::NoHomeEnv::set(); + CargoCrawler::cargo_home() + }; + assert_eq!(got, Some(passwd.join(".cargo"))); + } + // ── Project-mode registry scope (#595) ────────────────────────────── mod lock_scope { 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_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..371cbc8e0 --- /dev/null +++ b/crates/socket-patch-core/src/utils/cargo_home.rs @@ -0,0 +1,191 @@ +//! 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). 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` on Windows) or, +/// 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"), + std::env::var_os(HOME_VAR), + super::fs::passwd_home, + ) +} + +/// [`cargo_home`] over an injected `CARGO_HOME`, home variable and passwd +/// home (consulted only when the home variable is unset or empty). +fn resolve( + cargo_home_var: Option, + home_var: 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)); + } + // 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(), + }; + home.filter(|h| super::fs::is_usable_home(h)) + .map(|home| home.join(".cargo")) +} + +/// 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)] +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) + } +} + +#[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), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn no_passwd() -> Option { + 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()), + 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()), passwd_unused); + assert_eq!(got, Some(PathBuf::from("/home/u/.cargo"))); + } + + #[test] + 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 unset_home_var_falls_back_to_passwd_home() { + let got = resolve(None, None, || Some("/root".into())); + assert_eq!(got, Some(PathBuf::from("/root/.cargo"))); + } + + /// 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 relative_home_var_is_no_home() { + 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); + assert_eq!(resolve(None, None, || Some(PathBuf::new())), None); + 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, or `HOME` empty, 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) = super::super::fs::passwd_home() else { + return; // no passwd entry for this uid: nothing cargo could use either + }; + let (unset, empty) = { + let _env = NoHomeEnv::set(); + let unset = cargo_home(); + std::env::set_var("HOME", ""); + (unset, cargo_home()) + }; + assert_eq!(unset, Some(passwd.join(".cargo"))); + assert_eq!(empty, 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 { 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 } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 62b0b8556..ed01e00e8 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 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