From bb2bd5df7d0a6ca2ff1f70aaa9488914aa47349a Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 2 Aug 2026 03:11:28 -0400 Subject: [PATCH] test(cli): give the Rust test suite its own package-manager caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several suites under crates/socket-patch-cli/tests/ do real installs as part of their fixture setup — npm, corepack yarn/pnpm, bun, go build, pip, gem, bundler. None of them are #[ignore]d, so a plain `cargo test` runs them all, and with no environment of their own each one writes into the home directory of whoever ran the suite. Measured on macOS, one full run of the CLI integration tests left 3,601 files there: corepack's downloaded package managers, Go's build cache, yarn classic's global cache, bundler's compact index, the RubyGems spec cache. It also makes results depend on what happened to be lying around: a fixture install can succeed against something an unrelated run already cached, then fail on a clean CI runner. tests/common/cache_env.rs adds `isolate()`, which points one child process at a sandbox under the OS temp dir. It pins HOME plus every variable that outranks HOME — GOCACHE (separate from GOMODCACHE and GOPATH), COREPACK_HOME, PNPM_HOME, npm_config_cache, YARN_*, BUN_*, CARGO_HOME, PIP_CACHE_DIR, UV_CACHE_DIR, GEM_SPEC_CACHE, BUNDLE_USER_HOME, COMPOSER_*, NUGET_* — and carries over the version manager roots (RUSTUP_HOME, RBENV_ROOT, MISE_DATA_DIR, ~/.tool-versions, …) so a redirected home cannot make the toolchain itself unresolvable. The sandbox is a stable directory, not a fresh one per run: these fixtures reinstall the same handful of packages every time. Tests that need a genuinely cold cache keep passing their own empty directory, which still wins because `isolate()` runs before the test's own env. Two things this turned up: * e2e_vendor_yarn_classic_dev_flow.rs ran its ambient-env scrub AFTER seeding the caller's YARN_CACHE_FOLDER. The scrub ends with env_remove("YARN_CACHE_FOLDER"), so the private cache was wiped and the fixture install silently used the developer's global one (165 files). e2e_vendor_yarn_classic_build.rs documents having fixed the same bug; this file kept it. * The `has_command` / `has_corepack_pm` availability probes leaked more than the installs did. Where pnpm or yarn is a corepack shim, `pnpm --version` downloads the package manager (~900 files). The probes are now isolated too, which also makes them answer for the environment the install will actually run in. Same measurement after the change: 0 files. No test changed status — 136 test binaries, identical pass/fail before and after. --- .../tests/common/cache_env.rs | 386 ++++++++++++++++++ crates/socket-patch-cli/tests/common/mod.rs | 18 +- crates/socket-patch-cli/tests/e2e_gem.rs | 18 +- .../tests/e2e_golang_build.rs | 8 +- crates/socket-patch-cli/tests/e2e_npm.rs | 40 +- 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 | 12 +- .../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 | 11 + .../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 | 12 + .../tests/in_process_alternate_installers.rs | 26 +- .../tests/in_process_gem_apply.rs | 28 +- 25 files changed, 727 insertions(+), 141 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 0000000..1a928e3 --- /dev/null +++ b/crates/socket-patch-cli/tests/common/cache_env.rs @@ -0,0 +1,386 @@ +//! 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_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 564aab5..351f6f0 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 85a1e55..f9dc812 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 cc7f8ee..a1040d4 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_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index b18ca12..1fd2cee 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() @@ -101,11 +106,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 +341,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 b582091..2c9a5ed 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 fa95392..e4ebda5 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 c5189cf..180a338 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 e028f49..c39cb45 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 1209da5..dc4fa9f 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 baa7fe3..c8e0faa 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 0290c21..a0f6f4f 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 a629c1d..ebb8e04 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 46e16d0..2840a5d 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 c2d8d73..35c8dcf 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() @@ -109,6 +114,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"); 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 3c82d99..bd264fc 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 defa351..26d2c85 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 c354024..a3b65e2 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 eb0bccd..a1e9f31 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. @@ -105,6 +108,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 +125,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 63118da..6b6eab1 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 849dbae..1470d09 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 13ba69c..b908649 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 0249c65..b2e1cf7 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,15 @@ 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 0cb8e23..7cd4cb6 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 15f7fa4..b53ce12 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()