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