diff --git a/crates/socket-patch-cli/tests/common/hermetic.rs b/crates/socket-patch-cli/tests/common/hermetic.rs index bf4773463..d2421c62b 100644 --- a/crates/socket-patch-cli/tests/common/hermetic.rs +++ b/crates/socket-patch-cli/tests/common/hermetic.rs @@ -251,9 +251,13 @@ 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` 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. Bundle, /// Every `PDM_*` var plus `UV_PROJECT_ENVIRONMENT`: PDM reads any /// setting from `PDM_`, and its uv backend follows @@ -272,6 +276,30 @@ 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. 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]) { @@ -324,6 +352,8 @@ 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 fbea29354..f1232680e 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_crawler_inputs(&mut cmd); 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..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); @@ -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..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"); @@ -120,22 +121,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..724c5fbd0 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_crawler_inputs(&mut cmd); 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 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..39d422430 100644 --- a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs +++ b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs @@ -459,6 +459,9 @@ 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"), + ("RUBYLIB", "/ambient/bundler/lib"), ], || { let mut cmd = Command::new("bundle"); @@ -467,7 +470,11 @@ 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); + 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")); // A suite's own BUNDLE_PATH, set after the scrub, survives. cmd.env("BUNDLE_PATH", "/store"); @@ -615,6 +622,71 @@ fn no_scrub_socket_env_copies() { ); } +#[test] +#[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_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") + ); + }, + ); +} + +#[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..430bbcfbc 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_crawler_inputs(&mut cmd); cmd.env("SOCKET_NO_API_TOKEN", "1"); for (key, value) in &run.envs { cmd.env(key, value);