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()