From 27b224a60deffae3ae1c7b7f695d0d7a477e8f50 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:00:29 +0000 Subject: [PATCH 1/2] CI: share one bundler scrub across test helpers Three gem suites each swept BUNDLE_* and GEM_* by hand before running `bundle`, and the hosted production suite ran `bundle` with no bundler scrub at all. The GEM_* half went against cache_env.rs's rule that rvm/chruby's GEM_HOME/GEM_PATH must stay, so the suites could break a developer's version-managed ruby. The production suites' socket-patch child also kept an ambient BUNDLE_PATH or COMPOSER_VENDOR_DIR, which moves the crawl off the fixture. Every bundle child now goes through scrub_socket_vars plus hermetic::Extra::Bundle, which also drops RUBYOPT (a `bundle exec` shell pins every child to the outer Gemfile). socket-patch children that crawl a project (the production suites and run_vex) use the new hermetic::CRAWLER_EXTRAS. A ratchet in spawn_env_hygiene rejects new private BUNDLE_* sweeps. Refs #823 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/tests/common/hermetic.rs | 18 ++++++-- .../tests/e2e_hosted_production.rs | 15 ++++--- .../tests/e2e_redirect_gem_build.rs | 14 +++--- .../tests/e2e_vendor_gem_build.rs | 25 ++++++----- .../tests/e2e_vendored_production.rs | 35 +++++++-------- .../tests/spawn_env_hygiene.rs | 43 +++++++++++++++++++ .../tests/vex_e2e_common/mod.rs | 7 +-- 7 files changed, 107 insertions(+), 50 deletions(-) diff --git a/crates/socket-patch-cli/tests/common/hermetic.rs b/crates/socket-patch-cli/tests/common/hermetic.rs index bf4773463..cb4b22e00 100644 --- a/crates/socket-patch-cli/tests/common/hermetic.rs +++ b/crates/socket-patch-cli/tests/common/hermetic.rs @@ -251,9 +251,12 @@ pub enum Extra { /// Every `BUNDLE_*` var. Bundler lets any `.bundle/config` setting be /// overridden by env, and the gem crawler reads `BUNDLE_PATH`, /// `BUNDLE_APP_CONFIG` and friends, so an ambient value moves the gem - /// store the suite points it at. Seeded with a bogus - /// `BUNDLE_APP_CONFIG` and then scrubbed; run it before the suite sets - /// its own `BUNDLE_*` values. + /// store the suite points it at. Also `RUBYOPT`: under `bundle exec` it + /// carries `-rbundler/setup`, which pins every child ruby to the outer + /// Gemfile. `GEM_HOME`/`GEM_PATH` are kept on purpose: rvm/chruby + /// export them and `bundle` itself resolves through them (see + /// `cache_env.rs`). Seeded with a bogus `BUNDLE_APP_CONFIG` and then + /// scrubbed; run it before the suite sets its own `BUNDLE_*` values. Bundle, /// Every `PDM_*` var plus `UV_PROJECT_ENVIRONMENT`: PDM reads any /// setting from `PDM_`, and its uv backend follows @@ -272,6 +275,14 @@ pub enum Extra { Composer, } +/// The [`Extra`] scrubs for a `socket-patch` child that crawls a real +/// project tree: the binary's own crawlers read `VIRTUAL_ENV`, bundler's +/// `BUNDLE_PATH`/`BUNDLE_APP_CONFIG`/`BUNDLE_GEMFILE` and Composer's vendor +/// dir and home, so an ambient value moves discovery (and the installed +/// evidence `vex` hashes) off the fixture. Any env the test sets after the +/// scrub still wins. +pub const CRAWLER_EXTRAS: [Extra; 3] = [Extra::Venv, Extra::Bundle, Extra::Composer]; + /// Apply each of `extras` to `cmd` (see [`Extra`]). Run it before /// `cache_env::isolate`, which sets some of the same names. pub fn scrub_extra(cmd: &mut Command, extras: &[Extra]) { @@ -324,6 +335,7 @@ pub fn scrub_extra(cmd: &mut Command, extras: &[Extra]) { } } cmd.env_remove("BUNDLE_APP_CONFIG"); + cmd.env_remove("RUBYOPT"); } Extra::Pdm => { for (key, _) in std::env::vars_os() { diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index fbea29354..0d3b8f13b 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -304,11 +304,12 @@ fn has_command(cmd: &str) -> bool { /// (`hermetic::binary_command`) seeds each of those with a hostile value and /// removes it, so if the scrub is ever dropped the seeds turn the suite red /// immediately instead of letting a developer's ambient shell decide what got -/// tested. An activated venv is scrubbed too: the hosted scan discovers -/// packages unscoped under the cwd. +/// tested. The crawler's own ambient inputs are scrubbed too +/// (`hermetic::CRAWLER_EXTRAS`: an activated venv, `BUNDLE_*`, +/// `COMPOSER_*`): the hosted scan discovers packages unscoped under the cwd. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = hermetic::binary_command(); - hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]); + hermetic::scrub_extra(&mut cmd, &hermetic::CRAWLER_EXTRAS); cmd.args(args).current_dir(cwd); let out: Output = cmd.output().expect("failed to execute socket-patch binary"); ( @@ -437,14 +438,16 @@ fn hosted_pin_count(env: &serde_json::Value) -> u64 { /// hermetic scrubs, applied before `cache_env::isolate` and the leg's own /// env so a leg's pins (the uv legs' `VIRTUAL_ENV`) survive: every ambient /// `SOCKET_*` ([`hermetic::scrub_socket_vars`]), an activated venv (uv -/// would install into the wrong interpreter) and the yarn/pnpm node-linker -/// overrides. Deliberately not the full `Extra::Yarn` / `Extra::Pnpm` +/// would install into the wrong interpreter), bundler's `BUNDLE_*` and +/// `RUBYOPT` ([`hermetic::Extra::Bundle`]; an ambient BUNDLE_PATH or frozen +/// mode changes what `bundle` installs and where) and the yarn/pnpm +/// node-linker overrides. Deliberately not the full `Extra::Yarn` / `Extra::Pnpm` /// sweeps: those remove every `YARN_*` / `npm_config_*`, including the /// proxy and CA settings these live-registry suites need behind a proxy. fn pm_command(program: &str) -> Command { let mut cmd = Command::new(program); hermetic::scrub_socket_vars(&mut cmd); - hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv, hermetic::Extra::Bundle]); cmd.env_remove("YARN_NODE_LINKER"); cmd.env_remove("npm_config_node_linker"); cmd diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index f7fda3257..db3bd3610 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -175,8 +175,10 @@ fn run_socket_env(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (i32, Str ) } -/// Run `bundle ` in `cwd`: ambient `BUNDLE_*`/`GEM_*` scrubbed, caches -/// isolated, `BUNDLE_APP_CONFIG` pinned to the project's own `.bundle/`, and +/// Run `bundle ` in `cwd`: ambient `SOCKET_*` and bundler config +/// scrubbed ([`hermetic::Extra::Bundle`]; `GEM_HOME`/`GEM_PATH` stay, see +/// `cache_env.rs`, and every install here uses the project-local `path` +/// config), caches isolated, `BUNDLE_APP_CONFIG` pinned to the project's own `.bundle/`, and /// a PER-PROJECT `BUNDLE_USER_HOME` so each stage's compact-index cache is /// cold (the fresh-checkout install must be forced through the wiremock /// registry, never satisfied from the scan project's cache). @@ -190,12 +192,8 @@ fn bundle(cwd: &Path, args: &[&str]) -> Output { fn bundle_env(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output { let mut cmd = Command::new("bundle"); cmd.args(args).current_dir(cwd); - for (k, _) in std::env::vars_os() { - let key = k.to_string_lossy().into_owned(); - if key.starts_with("BUNDLE_") || key.starts_with("GEM_") { - cmd.env_remove(&k); - } - } + hermetic::scrub_socket_vars(&mut cmd); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle]); cache_env::isolate(&mut cmd); cmd.env("BUNDLE_APP_CONFIG", cwd.join(".bundle")); cmd.env("BUNDLE_USER_HOME", cwd.join(".bundle-user-home")); 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 0098e4d40..084ad9c8c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -120,22 +120,21 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { ) } -/// Run `bundle ` in `cwd` with the ambient `BUNDLE_*`/`GEM_*` state -/// scrubbed (a developer's global bundler config — a different BUNDLE_PATH, -/// frozen mode, a custom gem home — must not leak into the fixture) and -/// `BUNDLE_APP_CONFIG` pinned to the project's own `.bundle/` so -/// `bundle config set --local` writes a real committable file. +/// Run `bundle ` in `cwd` with ambient `SOCKET_*` and bundler config +/// scrubbed ([`hermetic::Extra::Bundle`]: a developer's global bundler +/// config — a different BUNDLE_PATH, frozen mode, a RUBYOPT require — must +/// not leak into the fixture) and `BUNDLE_APP_CONFIG` pinned to the +/// project's own `.bundle/` so `bundle config set --local` writes a real +/// committable file. `GEM_HOME`/`GEM_PATH` stay: `bundle` resolves through +/// them under rvm/chruby, and every install here uses the project-local +/// `path` config, so no gem lands in the ambient gem home. fn bundle(cwd: &Path, args: &[&str], frozen: bool) -> Output { let mut cmd = Command::new("bundle"); cmd.args(args).current_dir(cwd); - for (k, _) in std::env::vars_os() { - let key = k.to_string_lossy().into_owned(); - if key.starts_with("BUNDLE_") || key.starts_with("GEM_") { - 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. + hermetic::scrub_socket_vars(&mut cmd); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle]); + // After the scrub, which would otherwise take the sandbox's own + // BUNDLE_USER_HOME straight back out. cache_env::isolate(&mut cmd); cmd.env("BUNDLE_APP_CONFIG", cwd.join(".bundle")); if frozen { diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index a05f6d9c0..7fc1eafc4 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -327,10 +327,13 @@ fn has_corepack_pm(pm: &str) -> bool { /// every ambient `SOCKET_*`, seeds those three with hostile values and removes /// them (so a dropped scrub reddens the suite instead of leaking a developer's /// shell), and forces `SOCKET_NO_CONFIG=1` to block the socket-cli -/// config.json. An activated venv is scrubbed too. +/// config.json. The crawler's own ambient inputs are scrubbed too +/// (`hermetic::CRAWLER_EXTRAS`: an activated venv, `BUNDLE_*`, `COMPOSER_*`), +/// so an ambient BUNDLE_PATH can't move the gem leg's crawl off +/// `vendor/bundle`. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = hermetic::binary_command(); - hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]); + hermetic::scrub_extra(&mut cmd, &hermetic::CRAWLER_EXTRAS); cmd.args(args).current_dir(cwd); let out: Output = cmd.output().expect("failed to execute socket-patch binary"); ( @@ -501,34 +504,32 @@ fn vendor_revert(cwd: &Path, leg: &str) -> u64 { /// hermetic scrubs, applied before `cache_env::isolate` and the leg's own /// env so a leg's pins (the uv legs' `VIRTUAL_ENV`) survive: every ambient /// `SOCKET_*` ([`hermetic::scrub_socket_vars`]), an activated venv (uv/pip -/// would install into the wrong interpreter) and the yarn/pnpm node-linker -/// overrides. Deliberately not the full `Extra::Yarn` / `Extra::Pnpm` +/// would install into the wrong interpreter), bundler's `BUNDLE_*` and +/// `RUBYOPT` ([`hermetic::Extra::Bundle`]; an ambient BUNDLE_PATH or frozen +/// mode changes what `bundle` installs and where) and the yarn/pnpm +/// node-linker overrides. Deliberately not the full `Extra::Yarn` / `Extra::Pnpm` /// sweeps: those remove every `YARN_*` / `npm_config_*`, including the /// proxy and CA settings these live-registry suites need behind a proxy. fn pm_command(program: &str) -> Command { let mut cmd = Command::new(program); hermetic::scrub_socket_vars(&mut cmd); - hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv, hermetic::Extra::Bundle]); cmd.env_remove("YARN_NODE_LINKER"); cmd.env_remove("npm_config_node_linker"); cmd } -/// Run `bundle ` with the ambient `BUNDLE_*`/`GEM_*`/`RUBYOPT` state -/// scrubbed first (a developer's global bundler config — frozen mode, a -/// custom BUNDLE_PATH or gem home, a RUBYOPT require — must not leak into a -/// leg that hard-asserts bundler outcomes; mirrors e2e_vendor_gem_build.rs). -/// The cache sandbox re-pins its own BUNDLE_USER_HOME/GEM_SPEC_CACHE after -/// the scrub, and the per-leg `env` is applied last so the leg's pins win. +/// Run `bundle ` with the ambient bundler config scrubbed first +/// ([`hermetic::Extra::Bundle`]: every `BUNDLE_*` and `RUBYOPT`). A +/// developer's global bundler config (frozen mode, a custom BUNDLE_PATH, a +/// RUBYOPT require) must not leak into a leg that hard-asserts bundler +/// outcomes. `GEM_HOME`/`GEM_PATH` stay, since `bundle` resolves through +/// them under rvm/chruby; every install here pins BUNDLE_PATH. The cache +/// sandbox re-pins its own BUNDLE_USER_HOME/GEM_SPEC_CACHE after the scrub, +/// and the per-leg `env` is applied last so the leg's pins win. fn bundle(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { let mut cmd = pm_command("bundle"); cmd.args(args).current_dir(cwd); - for (k, _) in std::env::vars_os() { - let key = k.to_string_lossy().into_owned(); - if key.starts_with("BUNDLE_") || key.starts_with("GEM_") || key == "RUBYOPT" { - cmd.env_remove(&k); - } - } cache_env::isolate(&mut cmd); for (k, v) in env { cmd.env(k, v); diff --git a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs index 671c95895..1b21f28a8 100644 --- a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs +++ b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs @@ -459,6 +459,8 @@ fn bundle_extra_sweeps_every_bundler_override() { ("BUNDLE_APP_CONFIG", "/ambient/.bundle"), ("BUNDLE_GEMFILE", "/ambient/Gemfile"), ("GEM_HOME", "/ambient/gems"), + ("GEM_PATH", "/ambient/gems"), + ("RUBYOPT", "-rbundler/setup"), ], || { let mut cmd = Command::new("bundle"); @@ -467,7 +469,10 @@ fn bundle_extra_sweeps_every_bundler_override() { assert_eq!(get(&env, "BUNDLE_PATH"), None); assert_eq!(get(&env, "BUNDLE_APP_CONFIG"), None); assert_eq!(get(&env, "BUNDLE_GEMFILE"), None); + assert_eq!(get(&env, "RUBYOPT"), None); + // rvm/chruby homes stay: `bundle` resolves through them. assert_eq!(get(&env, "GEM_HOME"), Some("/ambient/gems")); + assert_eq!(get(&env, "GEM_PATH"), Some("/ambient/gems")); // A suite's own BUNDLE_PATH, set after the scrub, survives. cmd.env("BUNDLE_PATH", "/store"); @@ -615,6 +620,44 @@ fn no_scrub_socket_env_copies() { ); } +#[test] +fn crawler_extras_sweep_bundler_and_composer_overrides() { + with_ambient( + &[ + ("VIRTUAL_ENV", "/ambient/venv"), + ("BUNDLE_PATH", "/ambient/bundle"), + ("COMPOSER_VENDOR_DIR", "/ambient/vendor"), + ], + || { + let mut cmd = hermetic::binary_command(); + hermetic::scrub_extra(&mut cmd, &hermetic::CRAWLER_EXTRAS); + let env = effective_env(&cmd); + assert_eq!(get(&env, "VIRTUAL_ENV"), None); + assert_eq!(get(&env, "BUNDLE_PATH"), None); + assert_eq!(get(&env, "COMPOSER_VENDOR_DIR"), None); + }, + ); +} + +#[test] +fn no_private_bundler_sweeps() { + let found: Vec = test_sources() + .into_iter() + .filter(|(rel, text)| { + rel != "spawn_env_hygiene.rs" + && rel != "common/hermetic.rs" + && text.contains("starts_with(\"BUNDLE_\")") + }) + .map(|(rel, _)| rel) + .collect(); + assert!( + found.is_empty(), + "these test files sweep `BUNDLE_*` by hand: {found:?}. Use \ + `hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle])`, \ + which also keeps rvm/chruby's GEM_HOME/GEM_PATH (see cache_env.rs)." + ); +} + #[test] fn no_new_bare_binary_spawns() { let found: Vec = test_sources() diff --git a/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs b/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs index 668f6e0bc..79f2c5352 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs @@ -51,8 +51,9 @@ //! Notes: //! - Every run is hermetic: ambient `SOCKET_*` is scrubbed, telemetry and //! the socket-cli config are off, ambient API tokens are vetoed (only -//! [`VexRun::api_token`] authenticates) and `VIRTUAL_ENV` is removed. -//! Package-manager homes/caches the installed-tree lookups need go in +//! [`VexRun::api_token`] authenticates) and the crawler's ambient inputs +//! (`VIRTUAL_ENV`, `BUNDLE_*`, `COMPOSER_*`; `hermetic::CRAWLER_EXTRAS`) +//! are removed. Package-manager homes/caches the installed-tree lookups need go in //! [`VexRun::envs`]. //! - `--json` is the default ([`VexRun::human`] opts out), so //! [`VexOutcome::envelope`] is always the parsed stdout object. @@ -425,7 +426,7 @@ pub fn run_vex(bin: &Path, project: &Path, run: &VexRun) -> VexOutcome { args.extend(run.extra_args.iter().map(OsString::from)); let mut cmd = hermetic::command(bin); - hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv]); + hermetic::scrub_extra(&mut cmd, &hermetic::CRAWLER_EXTRAS); cmd.env("SOCKET_NO_API_TOKEN", "1"); for (key, value) in &run.envs { cmd.env(key, value); From 7dfba36e401ff50b46d2c1311d46a6f835a69cca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:22:21 +0000 Subject: [PATCH 2/2] CI: pin bundler user config for crawler spawns Review fixes for the shared bundler scrub: - socket-patch children that crawl a real tree now go through hermetic::scrub_crawler_inputs, which also points BUNDLE_USER_CONFIG at a missing file. Without it the ruby crawler fell back to the developer's ~/.bundle/config, so a global `path` or `gemfile` there could still move the gem crawl off the fixture. - Extra::Bundle also drops RUBYLIB, which `bundle exec` sets to the outer bundler's lib dir. - The new hygiene test is #[serial] like every other with_ambient caller, and it now checks the pins and the RUBYOPT/RUBYLIB removal. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/tests/common/hermetic.rs | 26 ++++++++++++--- .../tests/e2e_hosted_production.rs | 2 +- .../tests/e2e_redirect_gem_build.rs | 8 ++--- .../tests/e2e_vendor_gem_build.rs | 9 ++--- .../tests/e2e_vendored_production.rs | 4 +-- .../tests/spawn_env_hygiene.rs | 33 +++++++++++++++++-- .../tests/vex_e2e_common/mod.rs | 2 +- 7 files changed, 66 insertions(+), 18 deletions(-) diff --git a/crates/socket-patch-cli/tests/common/hermetic.rs b/crates/socket-patch-cli/tests/common/hermetic.rs index cb4b22e00..d2421c62b 100644 --- a/crates/socket-patch-cli/tests/common/hermetic.rs +++ b/crates/socket-patch-cli/tests/common/hermetic.rs @@ -251,9 +251,10 @@ pub enum Extra { /// Every `BUNDLE_*` var. Bundler lets any `.bundle/config` setting be /// overridden by env, and the gem crawler reads `BUNDLE_PATH`, /// `BUNDLE_APP_CONFIG` and friends, so an ambient value moves the gem - /// store the suite points it at. Also `RUBYOPT`: under `bundle exec` it - /// carries `-rbundler/setup`, which pins every child ruby to the outer - /// Gemfile. `GEM_HOME`/`GEM_PATH` are kept on purpose: rvm/chruby + /// store the suite points it at. Also `RUBYOPT` and `RUBYLIB`: under + /// `bundle exec` they carry `-rbundler/setup` and the outer bundler's + /// lib dir, which pin every child ruby to the outer Gemfile and bundler. + /// `GEM_HOME`/`GEM_PATH` are kept on purpose: rvm/chruby /// export them and `bundle` itself resolves through them (see /// `cache_env.rs`). Seeded with a bogus `BUNDLE_APP_CONFIG` and then /// scrubbed; run it before the suite sets its own `BUNDLE_*` values. @@ -280,9 +281,25 @@ pub enum Extra { /// `BUNDLE_PATH`/`BUNDLE_APP_CONFIG`/`BUNDLE_GEMFILE` and Composer's vendor /// dir and home, so an ambient value moves discovery (and the installed /// evidence `vex` hashes) off the fixture. Any env the test sets after the -/// scrub still wins. +/// scrub still wins. Apply it with [`scrub_crawler_inputs`]. pub const CRAWLER_EXTRAS: [Extra; 3] = [Extra::Venv, Extra::Bundle, Extra::Composer]; +/// Where [`scrub_crawler_inputs`] points `BUNDLE_USER_CONFIG`. +pub const NO_BUNDLE_USER_CONFIG: &str = "/nonexistent/bundle-user-config"; + +/// Apply [`CRAWLER_EXTRAS`] to a `socket-patch` child, and pin +/// `BUNDLE_USER_CONFIG` to a missing file. Without the pin the ruby +/// crawler falls back to `$HOME/.bundle/config`, the developer's global +/// bundler config (a `path` or `gemfile` there moves the gem crawl), the +/// same way `Extra::Composer` pins `COMPOSER_HOME`. The pin lives here, +/// not in `Extra::Bundle`, because `bundle` children get their own +/// `BUNDLE_USER_HOME` from `cache_env::isolate`, which the pin would +/// shadow. A test that sets `BUNDLE_USER_CONFIG` afterwards still wins. +pub fn scrub_crawler_inputs(cmd: &mut Command) { + scrub_extra(cmd, &CRAWLER_EXTRAS); + cmd.env("BUNDLE_USER_CONFIG", NO_BUNDLE_USER_CONFIG); +} + /// Apply each of `extras` to `cmd` (see [`Extra`]). Run it before /// `cache_env::isolate`, which sets some of the same names. pub fn scrub_extra(cmd: &mut Command, extras: &[Extra]) { @@ -336,6 +353,7 @@ pub fn scrub_extra(cmd: &mut Command, extras: &[Extra]) { } cmd.env_remove("BUNDLE_APP_CONFIG"); cmd.env_remove("RUBYOPT"); + cmd.env_remove("RUBYLIB"); } Extra::Pdm => { for (key, _) in std::env::vars_os() { diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 0d3b8f13b..f1232680e 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -309,7 +309,7 @@ fn has_command(cmd: &str) -> bool { /// `COMPOSER_*`): the hosted scan discovers packages unscoped under the cwd. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = hermetic::binary_command(); - hermetic::scrub_extra(&mut cmd, &hermetic::CRAWLER_EXTRAS); + hermetic::scrub_crawler_inputs(&mut cmd); cmd.args(args).current_dir(cwd); let out: Output = cmd.output().expect("failed to execute socket-patch binary"); ( diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index db3bd3610..adaa83816 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -152,9 +152,9 @@ const TINY_LIB: &str = "module TinyDep\n VALUE = \"tiny-ok\"\nend\n"; /// Run the socket-patch binary in the hermetic environment /// (`tests/common/hermetic.rs`: a developer's `SOCKET_DRY_RUN=1` must not -/// steer the assertions) with `VIRTUAL_ENV` (crawler discovery input) and -/// every ambient `BUNDLE_*` var (the crawler reads `BUNDLE_GEMFILE`, -/// `BUNDLE_PATH`, mirrors, ...) removed. +/// steer the assertions) with the crawler's discovery inputs scrubbed +/// (`hermetic::scrub_crawler_inputs`: `VIRTUAL_ENV`, every `BUNDLE_*` the +/// crawler reads, and `~/.bundle/config` via a pinned `BUNDLE_USER_CONFIG`). fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { run_socket_env(cwd, args, &[]) } @@ -162,7 +162,7 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { /// [`run_socket`] with extra environment on top of the scrubbed surface. fn run_socket_env(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) { let mut cmd = hermetic_command(&binary()); - hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv, hermetic::Extra::Bundle]); + hermetic::scrub_crawler_inputs(&mut cmd); cmd.args(args).current_dir(cwd); for (k, v) in envs { cmd.env(k, v); 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 084ad9c8c..d5dc62056 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -104,12 +104,13 @@ fn argv(args: &[String]) -> Vec<&str> { /// Run the socket-patch binary in the hermetic environment /// (`tests/common/hermetic.rs`: no ambient `SOCKET_*`, so a developer's -/// `SOCKET_DRY_RUN=1` etc. can't flip behavior) with `VIRTUAL_ENV` and every -/// `BUNDLE_*` (crawler discovery inputs: an ambient `BUNDLE_PATH` or -/// `BUNDLE_GEMFILE` would point the crawler away from the fixture) removed. +/// `SOCKET_DRY_RUN=1` etc. can't flip behavior) with the crawler's discovery +/// inputs scrubbed (`hermetic::scrub_crawler_inputs`: an ambient +/// `BUNDLE_PATH`, `BUNDLE_GEMFILE` or `~/.bundle/config` would point the +/// crawler away from the fixture). fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = hermetic_command(&binary()); - hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Venv, hermetic::Extra::Bundle]); + hermetic::scrub_crawler_inputs(&mut cmd); cmd.current_dir(cwd); let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); let out = cmd.output().expect("failed to run socket-patch binary"); diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 7fc1eafc4..724c5fbd0 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -333,7 +333,7 @@ fn has_corepack_pm(pm: &str) -> bool { /// `vendor/bundle`. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = hermetic::binary_command(); - hermetic::scrub_extra(&mut cmd, &hermetic::CRAWLER_EXTRAS); + hermetic::scrub_crawler_inputs(&mut cmd); cmd.args(args).current_dir(cwd); let out: Output = cmd.output().expect("failed to execute socket-patch binary"); ( @@ -525,7 +525,7 @@ fn pm_command(program: &str) -> Command { /// RUBYOPT require) must not leak into a leg that hard-asserts bundler /// outcomes. `GEM_HOME`/`GEM_PATH` stay, since `bundle` resolves through /// them under rvm/chruby; every install here pins BUNDLE_PATH. The cache -/// sandbox re-pins its own BUNDLE_USER_HOME/GEM_SPEC_CACHE after the scrub, +/// sandbox pins its own BUNDLE_USER_HOME/GEM_SPEC_CACHE after the scrub, /// and the per-leg `env` is applied last so the leg's pins win. fn bundle(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { let mut cmd = pm_command("bundle"); diff --git a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs index 1b21f28a8..39d422430 100644 --- a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs +++ b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs @@ -461,6 +461,7 @@ fn bundle_extra_sweeps_every_bundler_override() { ("GEM_HOME", "/ambient/gems"), ("GEM_PATH", "/ambient/gems"), ("RUBYOPT", "-rbundler/setup"), + ("RUBYLIB", "/ambient/bundler/lib"), ], || { let mut cmd = Command::new("bundle"); @@ -470,6 +471,7 @@ fn bundle_extra_sweeps_every_bundler_override() { assert_eq!(get(&env, "BUNDLE_APP_CONFIG"), None); assert_eq!(get(&env, "BUNDLE_GEMFILE"), None); assert_eq!(get(&env, "RUBYOPT"), None); + assert_eq!(get(&env, "RUBYLIB"), None); // rvm/chruby homes stay: `bundle` resolves through them. assert_eq!(get(&env, "GEM_HOME"), Some("/ambient/gems")); assert_eq!(get(&env, "GEM_PATH"), Some("/ambient/gems")); @@ -621,20 +623,47 @@ fn no_scrub_socket_env_copies() { } #[test] -fn crawler_extras_sweep_bundler_and_composer_overrides() { +#[serial] +fn crawler_inputs_sweep_bundler_and_composer_overrides() { with_ambient( &[ ("VIRTUAL_ENV", "/ambient/venv"), ("BUNDLE_PATH", "/ambient/bundle"), + ("BUNDLE_GEMFILE", "/ambient/Gemfile"), + ("BUNDLE_USER_CONFIG", "/ambient/bundle-config"), + ("RUBYOPT", "-rbundler/setup"), + ("RUBYLIB", "/ambient/bundler/lib"), ("COMPOSER_VENDOR_DIR", "/ambient/vendor"), + ("COMPOSER_HOME", "/ambient/composer"), ], || { let mut cmd = hermetic::binary_command(); - hermetic::scrub_extra(&mut cmd, &hermetic::CRAWLER_EXTRAS); + hermetic::scrub_crawler_inputs(&mut cmd); let env = effective_env(&cmd); assert_eq!(get(&env, "VIRTUAL_ENV"), None); assert_eq!(get(&env, "BUNDLE_PATH"), None); + assert_eq!(get(&env, "BUNDLE_GEMFILE"), None); + assert_eq!(get(&env, "RUBYOPT"), None); + assert_eq!(get(&env, "RUBYLIB"), None); assert_eq!(get(&env, "COMPOSER_VENDOR_DIR"), None); + // Global configs are pinned to missing files, not left to fall + // back to the developer's `~/.bundle/config` / composer home. + assert_eq!( + get(&env, "BUNDLE_USER_CONFIG"), + Some(hermetic::NO_BUNDLE_USER_CONFIG) + ); + assert_eq!( + get(&env, "COMPOSER_HOME"), + Some("/nonexistent/composer-home") + ); + + // A test's own pin, set after the scrub, survives. + cmd.env("BUNDLE_USER_CONFIG", "/fixture/bundle-config"); + let env = effective_env(&cmd); + assert_eq!( + get(&env, "BUNDLE_USER_CONFIG"), + Some("/fixture/bundle-config") + ); }, ); } diff --git a/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs b/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs index 79f2c5352..430bbcfbc 100644 --- a/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs +++ b/crates/socket-patch-cli/tests/vex_e2e_common/mod.rs @@ -426,7 +426,7 @@ pub fn run_vex(bin: &Path, project: &Path, run: &VexRun) -> VexOutcome { args.extend(run.extra_args.iter().map(OsString::from)); let mut cmd = hermetic::command(bin); - hermetic::scrub_extra(&mut cmd, &hermetic::CRAWLER_EXTRAS); + hermetic::scrub_crawler_inputs(&mut cmd); cmd.env("SOCKET_NO_API_TOKEN", "1"); for (key, value) in &run.envs { cmd.env(key, value);