Skip to content

Commit bb2bd5d

Browse files
committed
test(cli): give the Rust test suite its own package-manager caches
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.
1 parent 53aa893 commit bb2bd5d

25 files changed

Lines changed: 727 additions & 141 deletions
Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
//! Package-manager cache isolation for the integration tests.
2+
//!
3+
//! Several suites do REAL installs as part of their fixture setup — `npm
4+
//! install`, `corepack yarn install`, `pnpm install`, `bun install`, `go
5+
//! build`, `pip install`, `gem install`, `bundle install`. None of that is
6+
//! `#[ignore]`d, so a plain `cargo test` runs it, and with no environment of
7+
//! its own every one of those commands writes into the home directory of
8+
//! whoever ran the suite: the npm cache, the pnpm store, the Go build cache,
9+
//! the corepack download cache, the RubyGems spec cache.
10+
//!
11+
//! That is bad twice over. It pollutes the machine, and it makes results
12+
//! depend on what happened to be lying around — a fixture install can succeed
13+
//! against a package that a previous, unrelated run already cached, and the
14+
//! same test then fails on a clean CI runner.
15+
//!
16+
//! [`isolate`] fixes one child process. Call it on the `Command` for any
17+
//! package manager the tests spawn, and everything that tool caches lands
18+
//! under [`cache_root`] instead.
19+
//!
20+
//! ## Setting `HOME` is not enough
21+
//!
22+
//! Every tool below reads its own variable *in preference to* `HOME`, so a
23+
//! redirected home alone leaves the real cache in play whenever a developer
24+
//! (or a CI action — `pnpm/action-setup` exports `PNPM_HOME`) has one of them
25+
//! exported. Each is therefore pinned explicitly. The two that catch people
26+
//! out:
27+
//!
28+
//! * `GOCACHE` is a **separate** cache from `GOPATH`/`GOMODCACHE`. Setting
29+
//! the module cache and stopping there still leaves `go build` writing its
30+
//! compiled objects to the real home.
31+
//! * `COREPACK_HOME` holds the package managers corepack downloads. A single
32+
//! `corepack pnpm --version` against an empty home writes ~890 files.
33+
//!
34+
//! ## Why a stable directory rather than a fresh one per run
35+
//!
36+
//! These fixtures install the same handful of packages (`ms@2.1.3`,
37+
//! `left-pad@1.3.0`, `six==1.16.0`, `colorize@1.1.0`) on every run. A
38+
//! throwaway directory per run would re-download all of it every time and buy
39+
//! no extra safety, because the sandbox is outside the home directory either
40+
//! way. Tests that specifically assert *cold-install* behavior already pass
41+
//! their own empty directory as explicit env, which wins — see the ordering
42+
//! rule below.
43+
//!
44+
//! Nothing here deletes the sandbox. Go writes its module cache read-only, so
45+
//! a plain `rm -rf` fails partway through with permission errors; use `go
46+
//! clean -modcache` first, or `chmod -R u+w` the tree, if you want it gone.
47+
//!
48+
//! ## Ordering
49+
//!
50+
//! `Command`'s env operations are keyed by variable name and the last call
51+
//! for a given name wins. So:
52+
//!
53+
//! 1. scrub ambient config first (the existing `SOCKET_*` / `npm_config_*` /
54+
//! `YARN_*` prefix scrubs — they iterate the *parent* environment and
55+
//! would otherwise remove the values seeded here),
56+
//! 2. then `isolate`,
57+
//! 3. then any env the individual test needs, which is free to point a
58+
//! specific cache somewhere else.
59+
60+
#![allow(dead_code)]
61+
62+
use std::path::PathBuf;
63+
use std::process::Command;
64+
65+
/// Variables that decide where a *toolchain* lives, as opposed to where it
66+
/// caches. Each defaults to a path under the real home, so redirecting `HOME`
67+
/// without carrying them over can make the tool itself unresolvable — an
68+
/// rbenv shim that cannot find `~/.rbenv` fails to launch ruby at all, and a
69+
/// `cargo` that cannot find `~/.rustup` cannot pick a toolchain. That failure
70+
/// mode is worse than the leak being fixed, because most of these suites
71+
/// respond to a failed fixture install by printing SKIP and returning, so the
72+
/// coverage would disappear silently.
73+
///
74+
/// Each entry is seeded only when the variable is not already set and the
75+
/// default directory actually exists, which makes it a no-op on machines
76+
/// (and CI runners) that do not use the version manager in question.
77+
const TOOLCHAIN_ROOTS: &[(&str, &str)] = &[
78+
("RUSTUP_HOME", ".rustup"),
79+
("RBENV_ROOT", ".rbenv"),
80+
("PYENV_ROOT", ".pyenv"),
81+
("NVM_DIR", ".nvm"),
82+
("FNM_DIR", ".fnm"),
83+
("VOLTA_HOME", ".volta"),
84+
("ASDF_DIR", ".asdf"),
85+
("ASDF_DATA_DIR", ".asdf"),
86+
("SDKMAN_DIR", ".sdkman"),
87+
("MISE_DATA_DIR", ".local/share/mise"),
88+
("MISE_CONFIG_DIR", ".config/mise"),
89+
];
90+
91+
/// The home directory of the account running the tests, read from the parent
92+
/// process before anything is redirected.
93+
pub fn real_home() -> Option<PathBuf> {
94+
std::env::var_os("HOME")
95+
.or_else(|| std::env::var_os("USERPROFILE"))
96+
.map(PathBuf::from)
97+
.filter(|p| !p.as_os_str().is_empty())
98+
}
99+
100+
/// Root of the shared cache sandbox, under the OS temp dir.
101+
///
102+
/// The account name is part of the directory name because `/tmp` is shared on
103+
/// Linux: without it the first user to run the suite on a multi-user box owns
104+
/// the root, and everyone else hits `EACCES` partway through an install.
105+
pub fn cache_root() -> PathBuf {
106+
let account = std::env::var("USER")
107+
.or_else(|_| std::env::var("USERNAME"))
108+
.unwrap_or_default();
109+
let account: String = account
110+
.chars()
111+
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
112+
.collect();
113+
let name = if account.is_empty() {
114+
"socket-patch-test-caches".to_string()
115+
} else {
116+
format!("socket-patch-test-caches-{account}")
117+
};
118+
std::env::temp_dir().join(name)
119+
}
120+
121+
/// The stand-in home directory handed to every isolated child.
122+
pub fn sandbox_home() -> PathBuf {
123+
cache_root().join("home")
124+
}
125+
126+
/// Every variable [`isolate`] pins, with the sandbox path it points at.
127+
///
128+
/// Exposed so the self-tests below can assert the list stays complete, and so
129+
/// a test that wants to inspect a cache after the fact can find it.
130+
pub fn overrides() -> Vec<(&'static str, PathBuf)> {
131+
let root = cache_root();
132+
let home = sandbox_home();
133+
vec![
134+
// The catch-all. Everything with no variable of its own — Go's
135+
// telemetry counters, `~/.npmrc`, `~/.gemrc` — follows this.
136+
("HOME", home.clone()),
137+
("USERPROFILE", home.clone()),
138+
// XDG cache/data/state, which several Linux tools prefer over $HOME.
139+
// XDG_CONFIG_HOME is deliberately left alone: it is not a cache, and
140+
// when a developer has set it explicitly it usually points at real
141+
// configuration (a registry mirror, a corporate CA bundle) that the
142+
// installs still need.
143+
("XDG_CACHE_HOME", home.join(".cache")),
144+
("XDG_DATA_HOME", home.join(".local/share")),
145+
("XDG_STATE_HOME", home.join(".local/state")),
146+
// npm.
147+
("npm_config_cache", root.join("npm")),
148+
// pnpm: store and global bin both hang off PNPM_HOME.
149+
("PNPM_HOME", root.join("pnpm")),
150+
// yarn, both flavors (classic reads YARN_CACHE_FOLDER, berry's global
151+
// cache lives under YARN_GLOBAL_FOLDER).
152+
("YARN_CACHE_FOLDER", root.join("yarn/cache")),
153+
("YARN_GLOBAL_FOLDER", root.join("yarn/global")),
154+
// corepack's downloaded package managers.
155+
("COREPACK_HOME", root.join("corepack")),
156+
// bun.
157+
("BUN_INSTALL", root.join("bun")),
158+
("BUN_INSTALL_CACHE_DIR", root.join("bun/cache")),
159+
// Go. GOCACHE (compiled objects) is a different cache from GOMODCACHE
160+
// (downloaded modules) and neither follows GOPATH.
161+
("GOPATH", root.join("go/path")),
162+
("GOMODCACHE", root.join("go/mod")),
163+
("GOCACHE", root.join("go/build")),
164+
// Rust.
165+
("CARGO_HOME", root.join("cargo")),
166+
// Python.
167+
("PIP_CACHE_DIR", root.join("pip")),
168+
("UV_CACHE_DIR", root.join("uv")),
169+
// Ruby: the spec cache and bundler's per-user state.
170+
("GEM_SPEC_CACHE", root.join("gem/specs")),
171+
("BUNDLE_USER_HOME", root.join("bundle")),
172+
// PHP.
173+
("COMPOSER_HOME", root.join("composer/home")),
174+
("COMPOSER_CACHE_DIR", root.join("composer/cache")),
175+
// .NET.
176+
("NUGET_PACKAGES", root.join("nuget/packages")),
177+
("NUGET_HTTP_CACHE_PATH", root.join("nuget/http")),
178+
]
179+
}
180+
181+
/// The sandbox path [`isolate`] would pin for `var`.
182+
///
183+
/// For the rare caller that can only take a subset — `global_packages_e2e`
184+
/// asserts on the *real* npm/yarn/pnpm global prefixes, so it must keep the
185+
/// real `HOME`, but it can still redirect the download caches. Panics on an
186+
/// unknown name so a typo cannot quietly leave the value pointing at the
187+
/// caller's home.
188+
pub fn override_path(var: &str) -> PathBuf {
189+
overrides()
190+
.into_iter()
191+
.find(|(name, _)| *name == var)
192+
.map(|(_, path)| path)
193+
.unwrap_or_else(|| panic!("cache_env does not pin {var}"))
194+
}
195+
196+
/// Carry the toolchain-selection state that has no variable of its own into
197+
/// the sandbox home.
198+
///
199+
/// `asdf` and `mise` read the global tool version from `$HOME/.tool-versions`
200+
/// and neither takes an absolute path to it from the environment, so a
201+
/// redirected home would leave a `mise`-managed node/ruby/python resolving to
202+
/// nothing. The fixture install then fails and the test prints SKIP, quietly
203+
/// dropping the coverage. The file is a plain list of `<tool> <version>`
204+
/// lines.
205+
fn seed_sandbox_home(home: &std::path::Path, real: &std::path::Path) {
206+
let src = real.join(".tool-versions");
207+
if !src.is_file() {
208+
return;
209+
}
210+
let dst = home.join(".tool-versions");
211+
if std::fs::read(&src).ok() != std::fs::read(&dst).ok() {
212+
let _ = std::fs::copy(&src, &dst);
213+
}
214+
}
215+
216+
/// Point `cmd` at the shared cache sandbox.
217+
///
218+
/// Call this on any package-manager child process. See the module docs for
219+
/// where it belongs relative to an ambient-env scrub and the test's own env.
220+
pub fn isolate(cmd: &mut Command) -> &mut Command {
221+
let home = sandbox_home();
222+
// Some tools refuse to start when $HOME does not exist; the rest of the
223+
// tree is created by whichever tool needs it.
224+
let _ = std::fs::create_dir_all(&home);
225+
226+
if let Some(real) = real_home() {
227+
seed_sandbox_home(&home, &real);
228+
for (var, relative) in TOOLCHAIN_ROOTS {
229+
if std::env::var_os(var).is_some() {
230+
continue;
231+
}
232+
let path = real.join(relative);
233+
if path.is_dir() {
234+
cmd.env(var, path);
235+
}
236+
}
237+
}
238+
239+
for (var, path) in overrides() {
240+
cmd.env(var, path);
241+
}
242+
cmd
243+
}
244+
245+
// ── Self-tests ────────────────────────────────────────────────────────
246+
//
247+
// Integration-test crates do not get `cfg(test)`, so — exactly as in
248+
// `common/mod.rs` — these must stay ungated to run at all. They are pure
249+
// env/path arithmetic, so they cost nothing in the binaries that pick this
250+
// module up.
251+
mod cache_env_selftests {
252+
use super::*;
253+
254+
/// The variables whose whole point is that they outrank `HOME`. A future
255+
/// edit that drops one would silently restore the leak this module
256+
/// exists to close, and nothing else in the suite would notice.
257+
const MUST_PIN: &[&str] = &[
258+
"HOME",
259+
"GOCACHE",
260+
"GOMODCACHE",
261+
"GOPATH",
262+
"COREPACK_HOME",
263+
"PNPM_HOME",
264+
"CARGO_HOME",
265+
"npm_config_cache",
266+
"YARN_CACHE_FOLDER",
267+
"BUN_INSTALL_CACHE_DIR",
268+
"PIP_CACHE_DIR",
269+
"UV_CACHE_DIR",
270+
"GEM_SPEC_CACHE",
271+
"NUGET_PACKAGES",
272+
];
273+
274+
#[test]
275+
fn every_leak_prone_var_is_pinned() {
276+
let pinned = overrides();
277+
for want in MUST_PIN {
278+
assert!(
279+
pinned.iter().any(|(var, _)| var == want),
280+
"{want} is no longer pinned by cache_env::overrides(); package-manager \
281+
caches will leak into the home directory of whoever runs the suite"
282+
);
283+
}
284+
}
285+
286+
#[test]
287+
fn every_override_lands_inside_the_sandbox() {
288+
let root = cache_root();
289+
for (var, path) in overrides() {
290+
assert!(
291+
path.starts_with(&root),
292+
"{var} points outside the cache sandbox: {} is not under {}",
293+
path.display(),
294+
root.display()
295+
);
296+
}
297+
}
298+
299+
#[test]
300+
fn no_override_points_into_the_real_home() {
301+
let Some(real) = real_home() else {
302+
return;
303+
};
304+
// A machine whose TMPDIR is itself inside the home directory has no
305+
// way to satisfy this; the sandbox is still a dedicated directory, so
306+
// skip rather than fail.
307+
if cache_root().starts_with(&real) {
308+
return;
309+
}
310+
for (var, path) in overrides() {
311+
assert!(
312+
!path.starts_with(&real),
313+
"{var} still resolves inside the real home: {}",
314+
path.display()
315+
);
316+
}
317+
}
318+
319+
#[test]
320+
fn isolate_applies_the_overrides_to_a_command() {
321+
let mut cmd = Command::new("true");
322+
isolate(&mut cmd);
323+
let applied: Vec<(String, Option<String>)> = cmd
324+
.get_envs()
325+
.map(|(k, v)| {
326+
(
327+
k.to_string_lossy().into_owned(),
328+
v.map(|v| v.to_string_lossy().into_owned()),
329+
)
330+
})
331+
.collect();
332+
for (var, path) in overrides() {
333+
let seen = applied
334+
.iter()
335+
.find(|(name, _)| name == var)
336+
.unwrap_or_else(|| panic!("isolate() did not set {var}"));
337+
assert_eq!(
338+
seen.1.as_deref(),
339+
Some(path.to_string_lossy().as_ref()),
340+
"isolate() set {var} to the wrong path"
341+
);
342+
}
343+
assert!(
344+
sandbox_home().is_dir(),
345+
"isolate() must create the sandbox home so tools that require \
346+
an existing $HOME can start"
347+
);
348+
}
349+
350+
#[test]
351+
fn toolchain_roots_are_only_seeded_when_they_exist() {
352+
// The preservation pass must never invent a path. Whatever it seeds
353+
// has to be a directory that is really there under the real home,
354+
// and it must leave a variable the caller already exported alone.
355+
let Some(real) = real_home() else {
356+
return;
357+
};
358+
let mut cmd = Command::new("true");
359+
isolate(&mut cmd);
360+
let applied: Vec<(String, Option<PathBuf>)> = cmd
361+
.get_envs()
362+
.map(|(k, v)| (k.to_string_lossy().into_owned(), v.map(PathBuf::from)))
363+
.collect();
364+
for (var, relative) in TOOLCHAIN_ROOTS {
365+
let Some((_, value)) = applied.iter().find(|(name, _)| name == var) else {
366+
continue;
367+
};
368+
if std::env::var_os(var).is_some() {
369+
// Already exported by the caller: inherited untouched, so it
370+
// must not appear in the command's explicit env at all.
371+
panic!("{var} was already set in the parent env; isolate() must not override it");
372+
}
373+
let value = value.as_ref().expect("seeded roots always have a value");
374+
assert_eq!(
375+
value,
376+
&real.join(relative),
377+
"{var} was seeded to something other than its default under the real home"
378+
);
379+
assert!(
380+
value.is_dir(),
381+
"{var} was seeded to a path that does not exist: {}",
382+
value.display()
383+
);
384+
}
385+
}
386+
}

0 commit comments

Comments
 (0)