From f8cc6e51fb2fdbbabc48d92638f9abe4fdc7582c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 5 Aug 2026 11:58:50 -0400 Subject: [PATCH 1/2] test: hermetic package-manager caches across the test suites (squash of #142 + #143 + #144, plus review fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashes jdalton's cache-isolation stack — #142 (setup-matrix per-case HOME), #143 (cache_env::isolate() across the Rust integration suites), #144 (hosted production suite) — and folds in the fixes from a 13-agent adversarial review of the stack: - e2e_hosted_production.rs: widen the gem leg's known-defect matcher to the post-depscan#23630 registry signature (compact index 404s as not_built; the /api/v1/dependencies fallback serves an empty 200 body, which dies as "marshal data too short" under bundler 2.x classic Marshal and as "undefined method 'bytes' for nil" under SafeMarshal rubies). This unblocks the hosted-e2e required check, red on every branch since the 2026-08-02 deploy. Verified live: the leg passes against the current production registry. Also isolate the has_command probe (the corepack-download leak the stack closes everywhere else). - e2e_npm.rs: partial COREPACK_HOME/npm_config_cache pin on the binary runner — the macOS global auto-discovery test spawns the CLI, whose yarn/pnpm prefix probes downloaded ~900 corepack files into the real home (full isolate() would defeat the test; same trade-off as global_packages_e2e.rs). - e2e_vendor_gem_build.rs, e2e_vendor_pypi_build.rs: isolate the bundler/ruby/python/uv availability probes so probe and fixture install answer for the same environment. - run-case.sh: export MAVEN_OPTS=-Duser.home so maven actually follows the case home (the JVM derives user.home from passwd, not $HOME — mvn kept writing the real ~/.m2 while the verify helper scanned the empty fake one); preserve rbenv/pyenv/nvm/volta/asdf/sdkman roots and copy ~/.tool-versions so --host runs still launch their toolchains. - cache_env.rs: document why GEM_HOME/GEM_PATH are deliberately not pinned and the --install-dir/BUNDLE_PATH invariant that keeps that safe. Co-authored-by: jdalton Co-Authored-By: Claude Fable 5 --- .../tests/common/cache_env.rs | 394 ++++++++++++++++++ crates/socket-patch-cli/tests/common/mod.rs | 18 +- crates/socket-patch-cli/tests/e2e_gem.rs | 18 +- .../tests/e2e_golang_build.rs | 8 +- .../tests/e2e_hosted_production.rs | 52 ++- crates/socket-patch-cli/tests/e2e_npm.rs | 52 ++- crates/socket-patch-cli/tests/e2e_pypi.rs | 64 +-- .../tests/e2e_redirect_bun_build.rs | 10 +- .../tests/e2e_redirect_npm_build.rs | 18 +- .../tests/e2e_redirect_rush_sim.rs | 24 +- .../tests/e2e_redirect_yarn_berry_build.rs | 21 +- .../tests/e2e_redirect_yarn_classic_build.rs | 15 +- crates/socket-patch-cli/tests/e2e_scan.rs | 18 +- .../tests/e2e_vendor_bun_build.rs | 15 +- .../tests/e2e_vendor_composer_build.rs | 18 +- .../tests/e2e_vendor_gem_build.rs | 28 +- .../tests/e2e_vendor_golang_build.rs | 17 +- .../tests/e2e_vendor_npm_build.rs | 13 +- .../tests/e2e_vendor_pnpm_build.rs | 17 +- .../tests/e2e_vendor_pypi_build.rs | 33 +- .../tests/e2e_vendor_yarn_berry_build.rs | 15 +- .../tests/e2e_vendor_yarn_classic_build.rs | 15 +- .../tests/e2e_vendor_yarn_classic_dev_flow.rs | 29 +- .../tests/global_packages_e2e.rs | 15 + .../tests/in_process_alternate_installers.rs | 26 +- .../tests/in_process_gem_apply.rs | 28 +- tests/setup_matrix/run-case.sh | 46 ++ 27 files changed, 860 insertions(+), 167 deletions(-) create mode 100644 crates/socket-patch-cli/tests/common/cache_env.rs diff --git a/crates/socket-patch-cli/tests/common/cache_env.rs b/crates/socket-patch-cli/tests/common/cache_env.rs new file mode 100644 index 00000000..7571f061 --- /dev/null +++ b/crates/socket-patch-cli/tests/common/cache_env.rs @@ -0,0 +1,394 @@ +//! Package-manager cache isolation for the integration tests. +//! +//! Several suites do REAL installs as part of their fixture setup — `npm +//! install`, `corepack yarn install`, `pnpm install`, `bun install`, `go +//! build`, `pip install`, `gem install`, `bundle install`. None of that is +//! `#[ignore]`d, so a plain `cargo test` runs it, and with no environment of +//! its own every one of those commands writes into the home directory of +//! whoever ran the suite: the npm cache, the pnpm store, the Go build cache, +//! the corepack download cache, the RubyGems spec cache. +//! +//! That is bad twice over. It pollutes the machine, and it makes results +//! depend on what happened to be lying around — a fixture install can succeed +//! against a package that a previous, unrelated run already cached, and the +//! same test then fails on a clean CI runner. +//! +//! [`isolate`] fixes one child process. Call it on the `Command` for any +//! package manager the tests spawn, and everything that tool caches lands +//! under [`cache_root`] instead. +//! +//! ## Setting `HOME` is not enough +//! +//! Every tool below reads its own variable *in preference to* `HOME`, so a +//! redirected home alone leaves the real cache in play whenever a developer +//! (or a CI action — `pnpm/action-setup` exports `PNPM_HOME`) has one of them +//! exported. Each is therefore pinned explicitly. The two that catch people +//! out: +//! +//! * `GOCACHE` is a **separate** cache from `GOPATH`/`GOMODCACHE`. Setting +//! the module cache and stopping there still leaves `go build` writing its +//! compiled objects to the real home. +//! * `COREPACK_HOME` holds the package managers corepack downloads. A single +//! `corepack pnpm --version` against an empty home writes ~890 files. +//! +//! ## Why a stable directory rather than a fresh one per run +//! +//! These fixtures install the same handful of packages (`ms@2.1.3`, +//! `left-pad@1.3.0`, `six==1.16.0`, `colorize@1.1.0`) on every run. A +//! throwaway directory per run would re-download all of it every time and buy +//! no extra safety, because the sandbox is outside the home directory either +//! way. Tests that specifically assert *cold-install* behavior already pass +//! their own empty directory as explicit env, which wins — see the ordering +//! rule below. +//! +//! Nothing here deletes the sandbox. Go writes its module cache read-only, so +//! a plain `rm -rf` fails partway through with permission errors; use `go +//! clean -modcache` first, or `chmod -R u+w` the tree, if you want it gone. +//! +//! ## Ordering +//! +//! `Command`'s env operations are keyed by variable name and the last call +//! for a given name wins. So: +//! +//! 1. scrub ambient config first (the existing `SOCKET_*` / `npm_config_*` / +//! `YARN_*` prefix scrubs — they iterate the *parent* environment and +//! would otherwise remove the values seeded here), +//! 2. then `isolate`, +//! 3. then any env the individual test needs, which is free to point a +//! specific cache somewhere else. + +#![allow(dead_code)] + +use std::path::PathBuf; +use std::process::Command; + +/// Variables that decide where a *toolchain* lives, as opposed to where it +/// caches. Each defaults to a path under the real home, so redirecting `HOME` +/// without carrying them over can make the tool itself unresolvable — an +/// rbenv shim that cannot find `~/.rbenv` fails to launch ruby at all, and a +/// `cargo` that cannot find `~/.rustup` cannot pick a toolchain. That failure +/// mode is worse than the leak being fixed, because most of these suites +/// respond to a failed fixture install by printing SKIP and returning, so the +/// coverage would disappear silently. +/// +/// Each entry is seeded only when the variable is not already set and the +/// default directory actually exists, which makes it a no-op on machines +/// (and CI runners) that do not use the version manager in question. +const TOOLCHAIN_ROOTS: &[(&str, &str)] = &[ + ("RUSTUP_HOME", ".rustup"), + ("RBENV_ROOT", ".rbenv"), + ("PYENV_ROOT", ".pyenv"), + ("NVM_DIR", ".nvm"), + ("FNM_DIR", ".fnm"), + ("VOLTA_HOME", ".volta"), + ("ASDF_DIR", ".asdf"), + ("ASDF_DATA_DIR", ".asdf"), + ("SDKMAN_DIR", ".sdkman"), + ("MISE_DATA_DIR", ".local/share/mise"), + ("MISE_CONFIG_DIR", ".config/mise"), +]; + +/// The home directory of the account running the tests, read from the parent +/// process before anything is redirected. +pub fn real_home() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +/// Root of the shared cache sandbox, under the OS temp dir. +/// +/// The account name is part of the directory name because `/tmp` is shared on +/// Linux: without it the first user to run the suite on a multi-user box owns +/// the root, and everyone else hits `EACCES` partway through an install. +pub fn cache_root() -> PathBuf { + let account = std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_default(); + let account: String = account + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_') + .collect(); + let name = if account.is_empty() { + "socket-patch-test-caches".to_string() + } else { + format!("socket-patch-test-caches-{account}") + }; + std::env::temp_dir().join(name) +} + +/// The stand-in home directory handed to every isolated child. +pub fn sandbox_home() -> PathBuf { + cache_root().join("home") +} + +/// Every variable [`isolate`] pins, with the sandbox path it points at. +/// +/// Exposed so the self-tests below can assert the list stays complete, and so +/// a test that wants to inspect a cache after the fact can find it. +pub fn overrides() -> Vec<(&'static str, PathBuf)> { + let root = cache_root(); + let home = sandbox_home(); + vec![ + // The catch-all. Everything with no variable of its own — Go's + // telemetry counters, `~/.npmrc`, `~/.gemrc` — follows this. + ("HOME", home.clone()), + ("USERPROFILE", home.clone()), + // XDG cache/data/state, which several Linux tools prefer over $HOME. + // XDG_CONFIG_HOME is deliberately left alone: it is not a cache, and + // when a developer has set it explicitly it usually points at real + // configuration (a registry mirror, a corporate CA bundle) that the + // installs still need. + ("XDG_CACHE_HOME", home.join(".cache")), + ("XDG_DATA_HOME", home.join(".local/share")), + ("XDG_STATE_HOME", home.join(".local/state")), + // npm. + ("npm_config_cache", root.join("npm")), + // pnpm: store and global bin both hang off PNPM_HOME. + ("PNPM_HOME", root.join("pnpm")), + // yarn, both flavors (classic reads YARN_CACHE_FOLDER, berry's global + // cache lives under YARN_GLOBAL_FOLDER). + ("YARN_CACHE_FOLDER", root.join("yarn/cache")), + ("YARN_GLOBAL_FOLDER", root.join("yarn/global")), + // corepack's downloaded package managers. + ("COREPACK_HOME", root.join("corepack")), + // bun. + ("BUN_INSTALL", root.join("bun")), + ("BUN_INSTALL_CACHE_DIR", root.join("bun/cache")), + // Go. GOCACHE (compiled objects) is a different cache from GOMODCACHE + // (downloaded modules) and neither follows GOPATH. + ("GOPATH", root.join("go/path")), + ("GOMODCACHE", root.join("go/mod")), + ("GOCACHE", root.join("go/build")), + // Rust. + ("CARGO_HOME", root.join("cargo")), + // Python. + ("PIP_CACHE_DIR", root.join("pip")), + ("UV_CACHE_DIR", root.join("uv")), + // Ruby: the spec cache and bundler's per-user state. GEM_HOME and + // GEM_PATH are deliberately NOT pinned: rvm/chruby export them, and + // `bundle` itself resolves through them — an empty override would + // break the toolchain, which is worse than the leak (see the module + // docs on version managers). The invariant that keeps this safe: + // every `gem install` in these suites passes --install-dir, and + // every `bundle install` sets BUNDLE_PATH or --path, so gem trees + // land in the fixture regardless of GEM_HOME. A future bare + // `gem install`/`bundle install` without those would leak again. + ("GEM_SPEC_CACHE", root.join("gem/specs")), + ("BUNDLE_USER_HOME", root.join("bundle")), + // PHP. + ("COMPOSER_HOME", root.join("composer/home")), + ("COMPOSER_CACHE_DIR", root.join("composer/cache")), + // .NET. + ("NUGET_PACKAGES", root.join("nuget/packages")), + ("NUGET_HTTP_CACHE_PATH", root.join("nuget/http")), + ] +} + +/// The sandbox path [`isolate`] would pin for `var`. +/// +/// For the rare caller that can only take a subset — `global_packages_e2e` +/// asserts on the *real* npm/yarn/pnpm global prefixes, so it must keep the +/// real `HOME`, but it can still redirect the download caches. Panics on an +/// unknown name so a typo cannot quietly leave the value pointing at the +/// caller's home. +pub fn override_path(var: &str) -> PathBuf { + overrides() + .into_iter() + .find(|(name, _)| *name == var) + .map(|(_, path)| path) + .unwrap_or_else(|| panic!("cache_env does not pin {var}")) +} + +/// Carry the toolchain-selection state that has no variable of its own into +/// the sandbox home. +/// +/// `asdf` and `mise` read the global tool version from `$HOME/.tool-versions` +/// and neither takes an absolute path to it from the environment, so a +/// redirected home would leave a `mise`-managed node/ruby/python resolving to +/// nothing. The fixture install then fails and the test prints SKIP, quietly +/// dropping the coverage. The file is a plain list of ` ` +/// lines. +fn seed_sandbox_home(home: &std::path::Path, real: &std::path::Path) { + let src = real.join(".tool-versions"); + if !src.is_file() { + return; + } + let dst = home.join(".tool-versions"); + if std::fs::read(&src).ok() != std::fs::read(&dst).ok() { + let _ = std::fs::copy(&src, &dst); + } +} + +/// Point `cmd` at the shared cache sandbox. +/// +/// Call this on any package-manager child process. See the module docs for +/// where it belongs relative to an ambient-env scrub and the test's own env. +pub fn isolate(cmd: &mut Command) -> &mut Command { + let home = sandbox_home(); + // Some tools refuse to start when $HOME does not exist; the rest of the + // tree is created by whichever tool needs it. + let _ = std::fs::create_dir_all(&home); + + if let Some(real) = real_home() { + seed_sandbox_home(&home, &real); + for (var, relative) in TOOLCHAIN_ROOTS { + if std::env::var_os(var).is_some() { + continue; + } + let path = real.join(relative); + if path.is_dir() { + cmd.env(var, path); + } + } + } + + for (var, path) in overrides() { + cmd.env(var, path); + } + cmd +} + +// ── Self-tests ──────────────────────────────────────────────────────── +// +// Integration-test crates do not get `cfg(test)`, so — exactly as in +// `common/mod.rs` — these must stay ungated to run at all. They are pure +// env/path arithmetic, so they cost nothing in the binaries that pick this +// module up. +mod cache_env_selftests { + use super::*; + + /// The variables whose whole point is that they outrank `HOME`. A future + /// edit that drops one would silently restore the leak this module + /// exists to close, and nothing else in the suite would notice. + const MUST_PIN: &[&str] = &[ + "HOME", + "GOCACHE", + "GOMODCACHE", + "GOPATH", + "COREPACK_HOME", + "PNPM_HOME", + "CARGO_HOME", + "npm_config_cache", + "YARN_CACHE_FOLDER", + "BUN_INSTALL_CACHE_DIR", + "PIP_CACHE_DIR", + "UV_CACHE_DIR", + "GEM_SPEC_CACHE", + "NUGET_PACKAGES", + ]; + + #[test] + fn every_leak_prone_var_is_pinned() { + let pinned = overrides(); + for want in MUST_PIN { + assert!( + pinned.iter().any(|(var, _)| var == want), + "{want} is no longer pinned by cache_env::overrides(); package-manager \ + caches will leak into the home directory of whoever runs the suite" + ); + } + } + + #[test] + fn every_override_lands_inside_the_sandbox() { + let root = cache_root(); + for (var, path) in overrides() { + assert!( + path.starts_with(&root), + "{var} points outside the cache sandbox: {} is not under {}", + path.display(), + root.display() + ); + } + } + + #[test] + fn no_override_points_into_the_real_home() { + let Some(real) = real_home() else { + return; + }; + // A machine whose TMPDIR is itself inside the home directory has no + // way to satisfy this; the sandbox is still a dedicated directory, so + // skip rather than fail. + if cache_root().starts_with(&real) { + return; + } + for (var, path) in overrides() { + assert!( + !path.starts_with(&real), + "{var} still resolves inside the real home: {}", + path.display() + ); + } + } + + #[test] + fn isolate_applies_the_overrides_to_a_command() { + let mut cmd = Command::new("true"); + isolate(&mut cmd); + let applied: Vec<(String, Option)> = cmd + .get_envs() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.map(|v| v.to_string_lossy().into_owned()), + ) + }) + .collect(); + for (var, path) in overrides() { + let seen = applied + .iter() + .find(|(name, _)| name == var) + .unwrap_or_else(|| panic!("isolate() did not set {var}")); + assert_eq!( + seen.1.as_deref(), + Some(path.to_string_lossy().as_ref()), + "isolate() set {var} to the wrong path" + ); + } + assert!( + sandbox_home().is_dir(), + "isolate() must create the sandbox home so tools that require \ + an existing $HOME can start" + ); + } + + #[test] + fn toolchain_roots_are_only_seeded_when_they_exist() { + // The preservation pass must never invent a path. Whatever it seeds + // has to be a directory that is really there under the real home, + // and it must leave a variable the caller already exported alone. + let Some(real) = real_home() else { + return; + }; + let mut cmd = Command::new("true"); + isolate(&mut cmd); + let applied: Vec<(String, Option)> = cmd + .get_envs() + .map(|(k, v)| (k.to_string_lossy().into_owned(), v.map(PathBuf::from))) + .collect(); + for (var, relative) in TOOLCHAIN_ROOTS { + let Some((_, value)) = applied.iter().find(|(name, _)| name == var) else { + continue; + }; + if std::env::var_os(var).is_some() { + // Already exported by the caller: inherited untouched, so it + // must not appear in the command's explicit env at all. + panic!("{var} was already set in the parent env; isolate() must not override it"); + } + let value = value.as_ref().expect("seeded roots always have a value"); + assert_eq!( + value, + &real.join(relative), + "{var} was seeded to something other than its default under the real home" + ); + assert!( + value.is_dir(), + "{var} was seeded to a path that does not exist: {}", + value.display() + ); + } + } +} diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index 564aab5d..351f6f03 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -22,6 +22,11 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +/// Cache isolation for the package managers these helpers spawn. Files +/// that don't need the rest of this module pull it in on its own with +/// `#[path = "common/cache_env.rs"] mod cache_env;`. +pub mod cache_env; + // ── Binary discovery + invocation ───────────────────────────────────── /// Absolute path to the built `socket-patch` binary that cargo @@ -36,8 +41,10 @@ pub fn binary() -> PathBuf { /// (CI gates the toolchain at the workflow level; this is a /// belt-and-braces guard for local runs). pub fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -198,9 +205,13 @@ pub fn pnpm_run(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) { /// Run `cargo` in `cwd`. Returns the raw Output so callers can /// inspect stdout/stderr/exit on either pass or fail — the cargo /// e2e test wants both passing and failing cases (negative control). +/// +/// Caches are sandboxed by [`cache_env::isolate`] before `extra_env` +/// is applied, so a caller that pins its own `CARGO_HOME` still wins. pub fn cargo_run(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Output { let mut cmd = Command::new("cargo"); cmd.args(args).current_dir(cwd); + cache_env::isolate(&mut cmd); for (k, v) in extra_env { cmd.env(k, v); } @@ -210,6 +221,9 @@ pub fn cargo_run(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Outpu fn run_toolchain(cwd: &Path, exe: &str, args: &[&str], extra_env: &[(&str, &str)]) { let mut cmd = Command::new(exe); cmd.args(args).current_dir(cwd); + // Sandbox the caches first so `extra_env` can still override any + // individual one (`Command` env ops are last-write-wins per name). + cache_env::isolate(&mut cmd); for (k, v) in extra_env { cmd.env(k, v); } diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 85a1e55c..f9dc8120 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -24,6 +24,9 @@ use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/cache_env.rs"] +mod cache_env; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -40,8 +43,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -87,11 +92,10 @@ fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) { } fn bundle_run(cwd: &Path, args: &[&str]) { - let out = Command::new("bundle") - .args(args) - .current_dir(cwd) - .output() - .expect("failed to run bundle"); + let mut cmd = Command::new("bundle"); + cmd.args(args).current_dir(cwd); + cache_env::isolate(&mut cmd); + let out = cmd.output().expect("failed to run bundle"); assert!( out.status.success(), "bundle {args:?} failed (exit {:?}).\nstdout:\n{}\nstderr:\n{}", diff --git a/crates/socket-patch-cli/tests/e2e_golang_build.rs b/crates/socket-patch-cli/tests/e2e_golang_build.rs index cc7f8ee9..a1040d47 100644 --- a/crates/socket-patch-cli/tests/e2e_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_golang_build.rs @@ -19,7 +19,7 @@ use std::process::Command; #[path = "common/mod.rs"] mod common; -use common::{binary, git_sha256, has_command}; +use common::{binary, cache_env, git_sha256, has_command}; const UMOD: &str = "example.com/upstream"; const UVER: &str = "v1.0.0"; @@ -63,9 +63,15 @@ fn run_socket(cwd: &Path, args: &[&str], modcache: &Path) -> (i32, String, Strin ) } +/// Run `go` with its caches sandboxed, then the fixture's own env on top. +/// +/// `GOMODCACHE` alone is not isolation: `go build` keeps its compiled objects +/// in `GOCACHE`, a different directory that does not follow `GOPATH` either, +/// so without [`cache_env::isolate`] this test still filled the real home. fn go(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { let mut cmd = Command::new("go"); cmd.args(args).current_dir(dir); + cache_env::isolate(&mut cmd); for (k, v) in env { cmd.env(k, v); } diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 47ce23c6..044ce19c 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -82,6 +82,9 @@ use std::process::{Command, Output}; use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS}; +#[path = "common/cache_env.rs"] +mod cache_env; + // --------------------------------------------------------------------------- // Production endpoints + required-patch catalog // --------------------------------------------------------------------------- @@ -215,13 +218,17 @@ fn has_command(cmd: &str) -> bool { } else { &["--version"] }; - Command::new(cmd) + let mut probe_cmd = Command::new(cmd); + probe_cmd .args(probe) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) + .stderr(std::process::Stdio::null()); + // Where pnpm/yarn are corepack shims, this probe is what actually + // downloads the package manager — keep that out of the real + // COREPACK_HOME, and let the probe answer for the same environment + // the leg's `tool()` invocations run in. + cache_env::isolate(&mut probe_cmd); + probe_cmd.status().map(|s| s.success()).unwrap_or(false) } /// The three legacy `SOCKET_PATCH_*` names still honored at runtime via @@ -358,6 +365,10 @@ fn redirected_count(env: &serde_json::Value) -> u64 { fn tool(cwd: &Path, program: &str, args: &[&str], env: &[(&str, &str)]) -> Output { let mut cmd = Command::new(program); cmd.args(args).current_dir(cwd); + // Sandbox everything the per-leg `env` below does not name — corepack's + // downloaded package managers most of all — so a run leaves the caller's + // home alone. + cache_env::isolate(&mut cmd); // Keep every toolchain's cache inside the fixture so the reinstall leg // starts genuinely cold and cannot be satisfied from a warm host cache // holding the *pristine* artifact. @@ -1360,7 +1371,24 @@ fn gem_bundler_hosted_redirect_and_known_install_defect() { } let detail = dump(&reinstall); let is_known_defect = detail.contains("APIResponseMismatchError") - || detail.contains("revealed dependencies not in the API"); + || detail.contains("revealed dependencies not in the API") + // depscan#23630 (deployed 2026-08-02) made the compact-index routes + // fail closed: they 404 with `{"error":"not_built"}` until the + // requeued gem-package rebuild populates `package_gem_index_deps`, + // and bundler's /api/v1/dependencies fallback then gets HTTP 200 + // with a ZERO-byte body, so unmarshalling dies. Same server defect + // saga, new signature — and the exact error text depends on the + // bundler generation: classic Marshal (bundler 2.x) raises + // `ArgumentError: marshal data too short`, while SafeMarshal + // (ruby 3.4+/bundler 4) raises `NoMethodError: undefined method + // 'bytes' for nil` reading the empty header ("bytes' for nil" + // matches both the old backtick and new ASCII-quote rubies). The + // conjunction with the dependency-api retry line is required so a + // generic marshal/corruption error from any other source cannot + // hide behind this branch. + || (detail.contains("Retrying dependency api due to error") + && (detail.contains("marshal data too short") + || detail.contains("bytes' for nil"))); assert!( !gem_strict, "{LEG}: SOCKET_PATCH_HOSTED_E2E_GEM_STRICT=1 and `bundle install` from \ @@ -1373,11 +1401,13 @@ fn gem_bundler_hosted_redirect_and_known_install_defect() { This is a new regression:\n{detail}" ); println!( - "KNOWN PRODUCTION DEFECT {LEG}: the Socket gem patch-registry compact \ - index omits runtime dependencies, so bundler refuses the download \ - (APIResponseMismatchError). Hosted gem mode is unusable for gems with \ - dependencies until the server emits them. Redirect assertions above \ - all passed." + "KNOWN PRODUCTION DEFECT {LEG}: the Socket gem patch-registry either \ + omits runtime dependencies from the compact index \ + (APIResponseMismatchError) or, since depscan#23630, 404s the \ + compact-index routes as not_built and serves an empty body from the \ + dependency-API fallback (marshal data too short). Hosted gem mode is \ + unusable for gems with dependencies until the registry rebuild \ + completes. Redirect assertions above all passed." ); } diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index b18ca124..61689c4d 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -18,6 +18,9 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -40,8 +43,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -83,6 +88,18 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { cmd.env_remove(&key); } } + // Partial cache isolation only — the global auto-discovery test below + // needs the binary's `npm root -g` / `yarn global dir` / `pnpm root -g` + // probes to resolve the REAL prefixes, which come out of $HOME, so a full + // cache_env::isolate() would defeat the test's purpose. These two pins + // cannot change a prefix answer; they only keep corepack's shim-triggered + // package-manager downloads and npm's cache/debug-logs out of the real + // home (same trade-off as global_packages_e2e.rs). + cmd.env("COREPACK_HOME", cache_env::override_path("COREPACK_HOME")); + cmd.env( + "npm_config_cache", + cache_env::override_path("npm_config_cache"), + ); let out: Output = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); @@ -101,11 +118,10 @@ fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) { } fn npm_run(cwd: &Path, args: &[&str]) { - let out = Command::new("npm") - .args(args) - .current_dir(cwd) - .output() - .expect("failed to run npm"); + let mut cmd = Command::new("npm"); + cmd.args(args).current_dir(cwd); + cache_env::isolate(&mut cmd); + let out = cmd.output().expect("failed to run npm"); assert!( out.status.success(), "npm {args:?} failed (exit {:?}).\nstdout:\n{}\nstderr:\n{}", @@ -337,16 +353,18 @@ fn test_npm_global_lifecycle() { let cwd = cwd_dir.path(); // -- Setup: install minimist@1.2.2 globally into a temp prefix ---------- - let out = Command::new("npm") - .args([ - "install", - "-g", - "--prefix", - global_dir.path().to_str().unwrap(), - "minimist@1.2.2", - ]) - .output() - .expect("failed to run npm install -g"); + let mut cmd = Command::new("npm"); + cmd.args([ + "install", + "-g", + "--prefix", + global_dir.path().to_str().unwrap(), + "minimist@1.2.2", + ]); + // `--prefix` is a flag, so it still decides where the package lands; the + // sandbox only moves the download cache off the caller's home. + cache_env::isolate(&mut cmd); + let out = cmd.output().expect("failed to run npm install -g"); assert!( out.status.success(), "npm install -g failed.\nstdout:\n{}\nstderr:\n{}", diff --git a/crates/socket-patch-cli/tests/e2e_pypi.rs b/crates/socket-patch-cli/tests/e2e_pypi.rs index b5820913..2c9a5eda 100644 --- a/crates/socket-patch-cli/tests/e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/e2e_pypi.rs @@ -19,6 +19,9 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS}; +#[path = "common/cache_env.rs"] +mod cache_env; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -144,11 +147,10 @@ fn find_site_packages(cwd: &Path) -> PathBuf { /// Create a venv and install pydantic-ai (without transitive deps for speed). fn setup_venv(cwd: &Path) { - let status = Command::new("python3") - .args(["-m", "venv", ".venv"]) - .current_dir(cwd) - .status() - .expect("failed to create venv"); + let mut cmd = Command::new("python3"); + cmd.args(["-m", "venv", ".venv"]).current_dir(cwd); + cache_env::isolate(&mut cmd); + let status = cmd.status().expect("failed to create venv"); assert!(status.success(), "python3 -m venv failed"); let pip = if cfg!(windows) { @@ -160,17 +162,17 @@ fn setup_venv(cwd: &Path) { // Install both the meta-package (for dist-info that matches the PURL) // and the slim package (for the actual Python source files). // --no-deps keeps the install fast by skipping transitive dependencies. - let out = Command::new(&pip) - .args([ - "install", - "--no-deps", - "--disable-pip-version-check", - "pydantic-ai==0.0.36", - "pydantic-ai-slim==0.0.36", - ]) - .current_dir(cwd) - .output() - .expect("failed to run pip install"); + let mut cmd = Command::new(&pip); + cmd.args([ + "install", + "--no-deps", + "--disable-pip-version-check", + "pydantic-ai==0.0.36", + "pydantic-ai-slim==0.0.36", + ]) + .current_dir(cwd); + cache_env::isolate(&mut cmd); + let out = cmd.output().expect("failed to run pip install"); assert!( out.status.success(), "pip install failed.\nstdout:\n{}\nstderr:\n{}", @@ -526,20 +528,22 @@ fn test_pypi_global_lifecycle() { let cwd = cwd_dir.path(); // -- Setup: pip install --target into global_dir ------------------------- - let out = Command::new("python3") - .args([ - "-m", - "pip", - "install", - "--target", - global_dir.path().to_str().unwrap(), - "--no-deps", - "--disable-pip-version-check", - "pydantic-ai==0.0.36", - "pydantic-ai-slim==0.0.36", - ]) - .output() - .expect("failed to run pip install --target"); + let mut cmd = Command::new("python3"); + cmd.args([ + "-m", + "pip", + "install", + "--target", + global_dir.path().to_str().unwrap(), + "--no-deps", + "--disable-pip-version-check", + "pydantic-ai==0.0.36", + "pydantic-ai-slim==0.0.36", + ]); + // `--target` is a flag, so the packages still land in the temp dir the + // test asserts against; the sandbox only moves pip's cache. + cache_env::isolate(&mut cmd); + let out = cmd.output().expect("failed to run pip install --target"); assert!( out.status.success(), "pip install --target failed.\nstdout:\n{}\nstderr:\n{}", diff --git a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs index fa953921..e4ebda51 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs @@ -41,6 +41,9 @@ use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/cache_env.rs"] +mod cache_env; + const ORG: &str = "test-org"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; @@ -58,8 +61,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -81,6 +86,7 @@ fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { let mut cmd = Command::new("bun"); cmd.args(args).current_dir(cwd); scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); cmd.env("BUN_INSTALL_CACHE_DIR", cache_dir); cmd.output().expect("failed to run bun") } diff --git a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs index c5189cf0..180a3389 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_npm_build.rs @@ -38,6 +38,9 @@ use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/cache_env.rs"] +mod cache_env; + const ORG: &str = "test-org"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; @@ -59,8 +62,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -88,11 +93,10 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { } fn npm(cwd: &Path, args: &[&str]) -> Output { - Command::new("npm") - .args(args) - .current_dir(cwd) - .output() - .expect("failed to run npm") + let mut cmd = Command::new("npm"); + cmd.args(args).current_dir(cwd); + cache_env::isolate(&mut cmd); + cmd.output().expect("failed to run npm") } /// Standard-base64-encoded sha512 of `bytes` — the body of the npm-family diff --git a/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs b/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs index e028f49e..c39cb457 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_rush_sim.rs @@ -30,6 +30,9 @@ use sha2::{Digest, Sha512}; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/cache_env.rs"] +mod cache_env; + const ORG: &str = "test-org"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; @@ -52,11 +55,14 @@ fn has_corepack_pm(pm: &str) -> bool { let Ok(probe) = tempfile::tempdir() else { return false; }; - Command::new("corepack") - .args([pm, "--version"]) + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) .current_dir(probe.path()) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") - .stdout(Stdio::null()) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) @@ -64,8 +70,10 @@ fn has_corepack_pm(pm: &str) -> bool { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -88,6 +96,9 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> let mut cmd = Command::new("corepack"); cmd.arg(pm).args(args).current_dir(cwd); scrub_socket_env(&mut cmd); + // After the scrub: it strips ambient `PNPM_HOME` / `npm_config_store_dir`, + // which would otherwise take the sandbox values back out again. + cache_env::isolate(&mut cmd); cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); for (k, v) in extra_env { cmd.env(k, v); @@ -501,6 +512,7 @@ async fn rush_hosted_real_rush_update_install() { let mut cmd = Command::new("npm"); cmd.args(&full).current_dir(root); scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); for (k, _) in std::env::vars_os() { if k.to_string_lossy().starts_with("RUSH_") { cmd.env_remove(&k); diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs index 1209da52..dc4fa9f6 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs @@ -38,6 +38,9 @@ use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/cache_env.rs"] +mod cache_env; + const ORG: &str = "test-org"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; @@ -64,11 +67,14 @@ fn has_corepack_pm(pm: &str) -> bool { let Ok(probe) = tempfile::tempdir() else { return false; }; - Command::new("corepack") - .args([pm, "--version"]) + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) .current_dir(probe.path()) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") - .stdout(Stdio::null()) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) @@ -76,8 +82,10 @@ fn has_corepack_pm(pm: &str) -> bool { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -110,6 +118,7 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> // Scrub FIRST (it removes YARN_* / SOCKET_* from the inherited env), then // set the hermetic flags so they survive. scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") // Hermetic: no global mirror/cache. Without this, yarn's persistent // `~/.yarn/berry` global cache serves a previously-fetched archive diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs index baa7fe35..c8e0faa4 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs @@ -42,6 +42,9 @@ use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/cache_env.rs"] +mod cache_env; + const ORG: &str = "test-org"; const DEP: &str = "left-pad"; const DEP_VERSION: &str = "1.3.0"; @@ -68,11 +71,14 @@ fn has_corepack_pm(pm: &str) -> bool { let Ok(probe) = tempfile::tempdir() else { return false; }; - Command::new("corepack") - .args([pm, "--version"]) + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) .current_dir(probe.path()) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") - .stdout(Stdio::null()) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) @@ -95,6 +101,7 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> // Scrub FIRST (it removes YARN_* / SOCKET_* from the inherited env), then // set the hermetic flags so they survive. scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); for (k, v) in extra_env { cmd.env(k, v); diff --git a/crates/socket-patch-cli/tests/e2e_scan.rs b/crates/socket-patch-cli/tests/e2e_scan.rs index 0290c213..a0f6f4f9 100644 --- a/crates/socket-patch-cli/tests/e2e_scan.rs +++ b/crates/socket-patch-cli/tests/e2e_scan.rs @@ -31,6 +31,9 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + // --------------------------------------------------------------------------- // Constants (shared with e2e_npm; duplicated here because Rust integration // test binaries don't share modules without `tests/common/mod.rs` tricks @@ -63,8 +66,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -133,11 +138,10 @@ fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) { } fn npm_run(cwd: &Path, args: &[&str]) { - let out = Command::new("npm") - .args(args) - .current_dir(cwd) - .output() - .expect("failed to run npm"); + let mut cmd = Command::new("npm"); + cmd.args(args).current_dir(cwd); + cache_env::isolate(&mut cmd); + let out = cmd.output().expect("failed to run npm"); assert!( out.status.success(), "npm {args:?} failed (exit {:?}).\nstdout:\n{}\nstderr:\n{}", diff --git a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs index a629c1d9..ebb8e04c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs @@ -31,6 +31,9 @@ use std::process::{Command, Output, Stdio}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; const DEP: &str = "left-pad"; @@ -43,8 +46,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -52,14 +57,16 @@ fn has_command(cmd: &str) -> bool { .unwrap_or(false) } -/// Run `bun ` in `cwd` with the given private cache dir and every -/// `SOCKET_*` var scrubbed. +/// Run `bun ` in `cwd` with the given private cache dir, the shared +/// cache sandbox for everything bun keeps outside that dir (`~/.bun`, the +/// npmrc it reads), and every `SOCKET_*` var scrubbed. fn bun(cwd: &Path, args: &[&str], cache_dir: &Path) -> Output { let mut cmd = Command::new("bun"); cmd.args(args).current_dir(cwd); // Scrub BEFORE seeding: scrub_socket_env removes BUN_INSTALL_CACHE_DIR, // and Command's last env call wins. scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); cmd.env("BUN_INSTALL_CACHE_DIR", cache_dir); cmd.output().expect("failed to run bun") } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs index 46e16d0f..2840a5d2 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_composer_build.rs @@ -37,6 +37,9 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + /// Canonical lowercase patch uuid (a dedicated path level under /// `.socket/vendor/composer/`) — also what `dist.reference` must carry. const UUID: &str = "4d5e6f7a-8b9c-4a1b-8c2d-0123456789ab"; @@ -52,8 +55,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -85,11 +90,10 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { fn composer(cwd: &Path, args: &[&str], home: &Path, cache: &Path) -> Output { std::fs::create_dir_all(home).unwrap(); std::fs::create_dir_all(cache).unwrap(); - Command::new("composer") - .args(args) - .arg("--no-interaction") - .current_dir(cwd) - .env("COMPOSER_HOME", home) + let mut cmd = Command::new("composer"); + cmd.args(args).arg("--no-interaction").current_dir(cwd); + cache_env::isolate(&mut cmd); + cmd.env("COMPOSER_HOME", home) .env("COMPOSER_CACHE_DIR", cache) .output() .expect("failed to run composer") diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index c2d8d732..ad8df879 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -38,6 +38,9 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + /// Canonical lowercase patch uuid (a dedicated path level under /// `.socket/vendor/gem/`) — also the probe constant's runtime value. const UUID: &str = "3c4d5e6f-7a8b-4a1b-8c2d-0123456789ab"; @@ -51,8 +54,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -62,7 +67,13 @@ fn has_command(cmd: &str) -> bool { /// `bundle --version` → `(major, minor)`. `None` when the probe fails to run /// or parse (treated as "no usable bundler" by the caller). fn bundler_version() -> Option<(u32, u32)> { - let out = Command::new("bundle").arg("--version").output().ok()?; + let mut probe = Command::new("bundle"); + probe.arg("--version"); + // Isolated so the probe answers for the same environment the real + // `bundle install` below runs in (an rbenv/asdf setup must not make + // the two disagree and turn a runnable suite into a silent SKIP). + cache_env::isolate(&mut probe); + let out = probe.output().ok()?; if !out.status.success() { return None; } @@ -109,6 +120,9 @@ fn bundle(cwd: &Path, args: &[&str], frozen: bool) -> Output { cmd.env_remove(&k); } } + // After the `BUNDLE_*`/`GEM_*` scrub, which would otherwise take the + // sandbox's own BUNDLE_USER_HOME / GEM_SPEC_CACHE straight back out. + cache_env::isolate(&mut cmd); cmd.env("BUNDLE_APP_CONFIG", cwd.join(".bundle")); if frozen { cmd.env("BUNDLE_FROZEN", "true"); @@ -259,10 +273,10 @@ fn gem_vendor_fresh_checkout_bundle_install_and_revert() { .unwrap_or_else(|| panic!("could not read the resolved {DEP} version from Gemfile.lock")); // The installed gem dir under bundler's deployment layout. - let api = Command::new("ruby") - .args(["-e", "puts Gem.ruby_api_version"]) - .output() - .expect("failed to run ruby"); + let mut ruby = Command::new("ruby"); + ruby.args(["-e", "puts Gem.ruby_api_version"]); + cache_env::isolate(&mut ruby); + let api = ruby.output().expect("failed to run ruby"); assert!(api.status.success(), "ruby api version probe failed"); let api = String::from_utf8_lossy(&api.stdout).trim().to_string(); let gem_dir = proj diff --git a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs index 3c82d996..bd264fce 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_golang_build.rs @@ -17,6 +17,9 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + const UUID: &str = "3c4d5e6f-7081-4a1b-8c2d-0123456789ab"; const UMOD: &str = "example.com/upstream"; const UVER: &str = "v1.0.0"; @@ -31,8 +34,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -71,9 +76,17 @@ fn go_env<'a>(modcache: &'a str, proxy: &'a str) -> Vec<(&'a str, &'a str)> { ] } +/// Run `go` with its caches sandboxed, then the fixture's own env on top — +/// the per-test `GOMODCACHE` (including the deliberately EMPTY one the +/// fresh-checkout proof asserts against) still wins. +/// +/// `GOMODCACHE` alone is not isolation: `go build` keeps its compiled objects +/// in `GOCACHE`, a different directory that does not follow `GOPATH` either, +/// so without [`cache_env::isolate`] this test still filled the real home. fn go(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { let mut cmd = Command::new("go"); cmd.args(args).current_dir(dir); + cache_env::isolate(&mut cmd); for (k, v) in env { cmd.env(k, v); } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs index defa3517..26d2c855 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs @@ -26,6 +26,9 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + /// Canonical lowercase patch uuid (a dedicated path level under /// `.socket/vendor/npm/`). const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; @@ -41,8 +44,10 @@ fn binary() -> PathBuf { } fn has_command(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -94,6 +99,10 @@ fn npm(cwd: &Path, args: &[&str]) -> Output { cmd.env_remove(&k); } } + // After the scrub (it would otherwise strip an ambient `npm_config_cache` + // right back out) and before the caller's flags. The `--cache` argument + // each call site passes is a flag, not env, so it still wins. + cache_env::isolate(&mut cmd); cmd.output().expect("failed to run npm") } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs index c3540242..a3b65e24 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pnpm_build.rs @@ -29,6 +29,9 @@ use std::process::{Command, Output, Stdio}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + const UUID: &str = "1a2b3c4d-5e6f-4a1b-8c2d-0123456789ab"; const MARKER: &str = "/* SOCKET-PATCHED */\n"; const DEP: &str = "left-pad"; @@ -45,10 +48,13 @@ fn binary() -> PathBuf { } fn has_corepack_pm(pm: &str) -> bool { - Command::new("corepack") - .args([pm, "--version"]) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") - .stdout(Stdio::null()) + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) @@ -62,6 +68,9 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str]) -> Output { .current_dir(cwd) .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); scrub_socket_env(&mut cmd); + // After the scrub: it strips ambient `PNPM_*` and `npm_config_*`, which + // would otherwise take the sandbox values back out again. + cache_env::isolate(&mut cmd); cmd.output().expect("failed to run corepack") } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs index eb0bccd0..932c8530 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_pypi_build.rs @@ -31,6 +31,9 @@ use std::process::{Command, Output}; use sha2::{Digest, Sha256}; +#[path = "common/cache_env.rs"] +mod cache_env; + const UUID: &str = "4d5e6f70-8192-4a1b-8c2d-0123456789ab"; const PURL: &str = "pkg:pypi/six@1.16.0"; /// Appended to the installed `six.py` by the synthetic patch. @@ -67,13 +70,15 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { /// Resolve a Python interpreter (mirrors the core crawler's probe order). fn find_python() -> Option<&'static str> { for cmd in ["python3", "python"] { - let ok = Command::new(cmd) + let mut probe = Command::new(cmd); + probe .arg("--version") .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); + .stderr(std::process::Stdio::null()); + // Isolated so a pyenv shim resolves the same way here as in the + // fixture installs below (probe and install must not disagree). + cache_env::isolate(&mut probe); + let ok = probe.status().map(|s| s.success()).unwrap_or(false); if ok { return Some(cmd); } @@ -84,13 +89,13 @@ fn find_python() -> Option<&'static str> { /// Resolve `uv`: PATH first, then `~/.local/bin/uv` (the standalone /// installer's default location). fn find_uv() -> Option { - let on_path = Command::new("uv") + let mut probe = Command::new("uv"); + probe .arg("--version") .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); + .stderr(std::process::Stdio::null()); + cache_env::isolate(&mut probe); + let on_path = probe.status().map(|s| s.success()).unwrap_or(false); if on_path { return Some(PathBuf::from("uv")); } @@ -105,6 +110,13 @@ fn find_uv() -> Option { /// and `VIRTUAL_ENV` are all toolchain behavior inputs and must not leak /// from the developer's shell. Scrub BEFORE seeding the explicit env — the /// last env call wins. +/// +/// Cache isolation goes in the middle. The uv half of this file always passed +/// an explicit `UV_CACHE_DIR`; the pip half passed an empty env slice, so pip +/// used the developer's own cache. [`cache_env::isolate`] gives both halves a +/// sandboxed default, and the explicit per-test `UV_CACHE_DIR` — including +/// the deliberately EMPTY one the fresh-checkout proof relies on — still wins +/// because it is applied last. fn tool(exe: &Path, cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { let mut cmd = Command::new(exe); cmd.args(args).current_dir(cwd); @@ -115,6 +127,7 @@ fn tool(exe: &Path, cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { } } cmd.env_remove("VIRTUAL_ENV"); + cache_env::isolate(&mut cmd); for (k, v) in env { cmd.env(k, v); } diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs index 63118da2..6b6eab12 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs @@ -38,6 +38,9 @@ const DEP_VERSION: &str = "1.3.0"; /// Pinned yarn berry via corepack (matches the spike's 4.x). const YARN_BERRY: &str = "yarn@4.12.0"; +#[path = "common/cache_env.rs"] +mod cache_env; + // ── self-contained helpers ──────────────────────────────────────────── fn binary() -> PathBuf { @@ -45,10 +48,13 @@ fn binary() -> PathBuf { } fn has_corepack_pm(pm: &str) -> bool { - Command::new("corepack") - .args([pm, "--version"]) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") - .stdout(Stdio::null()) + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) @@ -61,6 +67,7 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> // Scrub FIRST (it removes YARN_* / SOCKET_* from the inherited env), then // seed the hermetic flags so they survive (Command: last env call wins). scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); for (k, v) in extra_env { cmd.env(k, v); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs index 849dbaeb..1470d09b 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_build.rs @@ -40,6 +40,9 @@ const DEP_VERSION: &str = "1.3.0"; /// Pinned yarn classic via corepack (matches the spike). const YARN_CLASSIC: &str = "yarn@1.22.22"; +#[path = "common/cache_env.rs"] +mod cache_env; + // ── self-contained helpers ──────────────────────────────────────────── fn binary() -> PathBuf { @@ -49,10 +52,13 @@ fn binary() -> PathBuf { /// `corepack --version` succeeds — the only liveness probe that /// distinguishes "corepack present" from "this yarn flavor is fetchable". fn has_corepack_pm(pm: &str) -> bool { - Command::new("corepack") - .args([pm, "--version"]) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") - .stdout(Stdio::null()) + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) @@ -69,6 +75,7 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> // call wins). Scrubbing last wiped the caller's private cache override, // so the fixture install silently used the developer's global cache. scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); for (k, v) in extra_env { cmd.env(k, v); diff --git a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_dev_flow.rs b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_dev_flow.rs index 13ba69c4..b9086494 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_dev_flow.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_yarn_classic_dev_flow.rs @@ -48,6 +48,9 @@ const NEIGHBOR_VERSION: &str = "1.20.0"; /// Pinned yarn classic via corepack (matches the fresh-checkout capstone). const YARN_CLASSIC: &str = "yarn@1.22.22"; +#[path = "common/cache_env.rs"] +mod cache_env; + // ── self-contained helpers (convention: e2e test files stay standalone) ─ fn binary() -> PathBuf { @@ -57,10 +60,13 @@ fn binary() -> PathBuf { /// `corepack --version` succeeds — the only liveness probe that /// distinguishes "corepack present" from "this yarn flavor is fetchable". fn has_corepack_pm(pm: &str) -> bool { - Command::new("corepack") - .args([pm, "--version"]) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") - .stdout(Stdio::null()) + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut cmd = Command::new("corepack"); + cmd.args([pm, "--version"]) + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut cmd); + cmd.stdout(Stdio::null()) .stderr(Stdio::null()) .status() .map(|s| s.success()) @@ -69,16 +75,21 @@ fn has_corepack_pm(pm: &str) -> bool { /// Run `corepack ` in `cwd` with the given extra env, the download /// prompt disabled, and every `SOCKET_*` var scrubbed. +/// +/// The scrub runs FIRST. It ends with `env_remove("YARN_CACHE_FOLDER")`, so +/// running it last (as this helper used to) wiped the private cache the +/// caller had just passed in and the fixture install quietly fell back to the +/// developer's global yarn cache — the same bug `e2e_vendor_yarn_classic_ +/// build.rs` already documents having fixed. fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output { let mut cmd = Command::new("corepack"); - cmd.arg(pm) - .args(args) - .current_dir(cwd) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cmd.arg(pm).args(args).current_dir(cwd); + scrub_socket_env(&mut cmd); + cache_env::isolate(&mut cmd); + cmd.env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); for (k, v) in extra_env { cmd.env(k, v); } - scrub_socket_env(&mut cmd); cmd.output().expect("failed to run corepack") } diff --git a/crates/socket-patch-cli/tests/global_packages_e2e.rs b/crates/socket-patch-cli/tests/global_packages_e2e.rs index 0249c65e..8ddabe13 100644 --- a/crates/socket-patch-cli/tests/global_packages_e2e.rs +++ b/crates/socket-patch-cli/tests/global_packages_e2e.rs @@ -23,6 +23,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; +#[path = "common/cache_env.rs"] +mod cache_env; + fn binary() -> PathBuf { env!("CARGO_BIN_EXE_socket-patch").into() } @@ -55,6 +58,18 @@ fn cli(cwd: &Path) -> Command { cmd.env_remove(&key); } } + // Download caches only — NOT the full `cache_env::isolate`. Resolving the + // real global prefixes is the whole point of this file, and those come out + // of `$HOME`/`PNPM_HOME`, so redirecting either would change the answer the + // tests assert on. These two cannot: on a machine where `pnpm`/`yarn` are + // corepack shims, the CLI's prefix probe makes corepack download the + // package manager (~900 files) into the caller's home, and npm drops a + // debug log in its cache when the probe fails. + cmd.env("COREPACK_HOME", cache_env::override_path("COREPACK_HOME")); + cmd.env( + "npm_config_cache", + cache_env::override_path("npm_config_cache"), + ); cmd } diff --git a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs index 0cb8e237..7cd4cb6b 100644 --- a/crates/socket-patch-cli/tests/in_process_alternate_installers.rs +++ b/crates/socket-patch-cli/tests/in_process_alternate_installers.rs @@ -12,6 +12,9 @@ use serial_test::serial; use sha2::{Digest, Sha256}; use socket_patch_cli::commands::apply::{run as apply_run, ApplyArgs}; +#[path = "common/cache_env.rs"] +mod cache_env; + fn git_sha256(content: &[u8]) -> String { let header = format!("blob {}\0", content.len()); let mut hasher = Sha256::new(); @@ -43,8 +46,10 @@ fn assert_patched(path: &Path, expected: &[u8], before_hash: &str, after_hash: & } fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") + let mut probe = Command::new(cmd); + probe.arg("--version"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -66,6 +71,13 @@ fn has(cmd: &str) -> bool { /// ever dropped the seed (not a developer's shell) turns the suite red /// immediately. Seed test-specific env AFTER this helper — `Command` env /// calls apply in order, so a later scrub would wipe the seed. +/// +/// Cache isolation is applied last, after the scrub, so these fixture +/// installs write to a sandbox instead of the home directory of whoever ran +/// `cargo test`. None of the tests in this file are `#[ignore]`d, so they all +/// run by default. A test that wants a specific cache elsewhere (the bun leg +/// pins its own `BUN_INSTALL_CACHE_DIR`) sets it on the returned `Command` +/// and still wins. fn pm_command(program: &str, prefixes: &[&str]) -> Command { let mut cmd = Command::new(program); for p in prefixes { @@ -90,6 +102,7 @@ fn pm_command(program: &str, prefixes: &[&str]) -> Command { cmd.env_remove(&k); } } + cache_env::isolate(&mut cmd); cmd } @@ -524,9 +537,14 @@ async fn bun_install_then_apply_patches_file() { // --------------------------------------------------------------------------- fn has_corepack_pm(pm: &str) -> bool { - Command::new("corepack") + // Isolated too: this probe is what actually downloads the package manager + // the first time, and corepack stores it under `COREPACK_HOME`. + let mut probe = Command::new("corepack"); + probe .args([pm, "--version"]) - .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") + .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0"); + cache_env::isolate(&mut probe); + probe .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() diff --git a/crates/socket-patch-cli/tests/in_process_gem_apply.rs b/crates/socket-patch-cli/tests/in_process_gem_apply.rs index 15f7fa4b..b53ce12e 100644 --- a/crates/socket-patch-cli/tests/in_process_gem_apply.rs +++ b/crates/socket-patch-cli/tests/in_process_gem_apply.rs @@ -14,6 +14,9 @@ use socket_patch_cli::commands::scan::{run as scan_run, ScanArgs}; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/cache_env.rs"] +mod cache_env; + const ORG: &str = "test-org"; const UUID: &str = "13131313-1313-4131-8131-131313131313"; const GEM_NAME: &str = "colorize"; @@ -61,16 +64,21 @@ fn install_colorize(tmp: &Path) -> PathBuf { let install_dir = tmp.join(format!("vendor/bundle/ruby/{ver}")); std::fs::create_dir_all(&install_dir).expect("create install dir"); - let status = Command::new("gem") - .args([ - "install", - "--no-document", - "--install-dir", - install_dir.to_str().unwrap(), - GEM_NAME, - "-v", - GEM_VERSION, - ]) + // `--install-dir` keeps the gem itself out of the user's gem environment, + // but RubyGems still writes its spec cache under the home directory. The + // sandbox catches that; `--install-dir` is a flag, so it is unaffected. + let mut cmd = Command::new("gem"); + cmd.args([ + "install", + "--no-document", + "--install-dir", + install_dir.to_str().unwrap(), + GEM_NAME, + "-v", + GEM_VERSION, + ]); + cache_env::isolate(&mut cmd); + let status = cmd .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .output() diff --git a/tests/setup_matrix/run-case.sh b/tests/setup_matrix/run-case.sh index 913d10b3..dc854ea5 100755 --- a/tests/setup_matrix/run-case.sh +++ b/tests/setup_matrix/run-case.sh @@ -664,6 +664,52 @@ export SOCKET_TELEMETRY_DISABLED=1 SOCKET_EXPERIMENTAL_MAVEN=1 SOCKET_EXPERIMENT # Isolate the pypi `.pth` hook's change-detection stamp per case so runs # don't bleed into each other (the stamp lives under XDG_CACHE_HOME). export XDG_CACHE_HOME="$WORKDIR/.cache" +# Give the case its own home so every package manager's cache, store and +# credentials stay inside WORKDIR. Under `--host` these run on the +# developer's machine, where the default is their real home. The vars +# below outrank HOME for their tools, so they are pointed inside it +# rather than left to leak. +# +# RUSTUP_HOME is the exception: rustup's toolchains live under the real +# home (defaulting to $HOME/.rustup when unset) and are shared, not +# per-case. Pin it to that location BEFORE HOME is redirected, or rustup +# resolves toolchains under the empty fake home and `cargo fetch` fails +# even though CARGO_HOME is set. An already-exported RUSTUP_HOME (e.g. the +# cargo Docker image) is preserved. +export RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" +# Version-manager roots get the same treatment: rbenv/pyenv/nvm/volta/asdf/ +# sdkman shims resolve their root from $HOME by default, so a redirected +# HOME with no root pinned makes ruby/python/node fail to launch at all +# under --host. Seed each root from the real home first — only when the +# variable is not already exported and the default directory actually +# exists, so this is a no-op on machines (and containers) without that +# manager. +for _vm in RBENV_ROOT:.rbenv PYENV_ROOT:.pyenv NVM_DIR:.nvm \ + VOLTA_HOME:.volta ASDF_DIR:.asdf ASDF_DATA_DIR:.asdf \ + SDKMAN_DIR:.sdkman; do + _vm_var="${_vm%%:*}" + _vm_dir="$HOME/${_vm#*:}" + if [ -z "${!_vm_var:-}" ] && [ -d "$_vm_dir" ]; then + export "$_vm_var=$_vm_dir" + fi +done +unset _vm _vm_var _vm_dir +mkdir -p "$WORKDIR/home" +# asdf/mise read the global tool selection from ~/.tool-versions and accept +# no absolute path to it from the environment — carry the file itself over. +if [ -f "$HOME/.tool-versions" ]; then + cp "$HOME/.tool-versions" "$WORKDIR/home/.tool-versions" +fi +export HOME="$WORKDIR/home" +export XDG_DATA_HOME="$WORKDIR/home/.local/share" XDG_CONFIG_HOME="$WORKDIR/home/.config" XDG_STATE_HOME="$WORKDIR/home/.local/state" +export CARGO_HOME="$WORKDIR/home/.cargo" GOPATH="$WORKDIR/home/go" GOMODCACHE="$WORKDIR/home/go/pkg/mod" GOCACHE="$WORKDIR/home/.cache/go-build" +export PNPM_HOME="$WORKDIR/home/.pnpm" COREPACK_HOME="$WORKDIR/home/.corepack" NUGET_PACKAGES="$WORKDIR/home/.nuget/packages" +# Maven ignores $HOME entirely: the JVM computes `user.home` from the +# passwd entry (getpwuid / NSHomeDirectory), so without this mvn keeps +# writing its ~/.m2 into the REAL home while the resolve_target maven arm +# (and the socket binary, which is $HOME-based) look in the case home. +# -Duser.home moves the whole ~/.m2 (repository + settings) with the case. +export MAVEN_OPTS="${MAVEN_OPTS:+$MAVEN_OPTS }-Duser.home=$WORKDIR/home" # NOTE: deliberately do NOT export SOCKET_CWD. The install hook's apply # must run with whatever cwd the package manager sets for the lifecycle # script — the project root for a single project, and the *member* dir From e01295c4f322b21d536ef1ce3a7c333988901754 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 5 Aug 2026 15:03:54 -0400 Subject: [PATCH 2/2] =?UTF-8?q?test(setup-matrix):=20seed=20fnm/mise=20roo?= =?UTF-8?q?ts=20too=20=E2=80=94=20complete=20the=20TOOLCHAIN=5FROOTS=20mir?= =?UTF-8?q?ror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugbot caught the gap: the run-case.sh seed loop claimed to mirror cache_env.rs TOOLCHAIN_ROOTS but omitted FNM_DIR, MISE_DATA_DIR and MISE_CONFIG_DIR. Since the block copies ~/.tool-versions (a mise file) and redirects XDG_DATA_HOME/XDG_CONFIG_HOME into the empty case home, a mise user under --host would have had shims that resolve no toolchains. Same only-if-unset + dir-exists semantics as the rest. Co-Authored-By: Claude Fable 5 --- tests/setup_matrix/run-case.sh | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/setup_matrix/run-case.sh b/tests/setup_matrix/run-case.sh index dc854ea5..13c21959 100755 --- a/tests/setup_matrix/run-case.sh +++ b/tests/setup_matrix/run-case.sh @@ -677,16 +677,18 @@ export XDG_CACHE_HOME="$WORKDIR/.cache" # even though CARGO_HOME is set. An already-exported RUSTUP_HOME (e.g. the # cargo Docker image) is preserved. export RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" -# Version-manager roots get the same treatment: rbenv/pyenv/nvm/volta/asdf/ -# sdkman shims resolve their root from $HOME by default, so a redirected -# HOME with no root pinned makes ruby/python/node fail to launch at all -# under --host. Seed each root from the real home first — only when the -# variable is not already exported and the default directory actually -# exists, so this is a no-op on machines (and containers) without that -# manager. +# Version-manager roots get the same treatment: rbenv/pyenv/nvm/fnm/volta/ +# asdf/sdkman/mise shims resolve their root from $HOME (or the XDG dirs, +# which are also redirected below) by default, so a redirected HOME with no +# root pinned makes ruby/python/node fail to launch at all under --host. +# Seed each root from the real home first — only when the variable is not +# already exported and the default directory actually exists, so this is a +# no-op on machines (and containers) without that manager. Same list as +# cache_env.rs TOOLCHAIN_ROOTS. for _vm in RBENV_ROOT:.rbenv PYENV_ROOT:.pyenv NVM_DIR:.nvm \ - VOLTA_HOME:.volta ASDF_DIR:.asdf ASDF_DATA_DIR:.asdf \ - SDKMAN_DIR:.sdkman; do + FNM_DIR:.fnm VOLTA_HOME:.volta ASDF_DIR:.asdf ASDF_DATA_DIR:.asdf \ + SDKMAN_DIR:.sdkman MISE_DATA_DIR:.local/share/mise \ + MISE_CONFIG_DIR:.config/mise; do _vm_var="${_vm%%:*}" _vm_dir="$HOME/${_vm#*:}" if [ -z "${!_vm_var:-}" ] && [ -d "$_vm_dir" ]; then