From 60399622eab9395582f7ce0ed78cd2c0a8ba29ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 20:52:29 +0000 Subject: [PATCH 1/3] Start hermetic gem/vendor spawns for #823 Assisted-by: Claude Code:claude-opus-5-5 From 402664cf034089d3131cc30ce6fb2e555300e072 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 21:16:35 +0000 Subject: [PATCH 2/3] Spawn gem and vendor test children hermetically e2e_gem, vendor_gem_lockfile_only_e2e and vendor_rerun_no_network_e2e started socket-patch with a bare Command::new and their own SOCKET_* sweeps, which let an ambient SOCKET_NO_CONFIG through and never forced the update check off. None of them scrubbed Bundler's environment: an ambient BUNDLE_PATH, or a ~/.bundle/config that sets one, adds the developer's gems to the crawl, so both gem scan tests fail on main (3 gems found instead of 2). All three now spawn through hermetic::command with Extra::Bundle; e2e_gem's unscoped scans also scrub an activated venv and use the stand-in HOME, and its bundle install child scrubs BUNDLE_* before setting its own BUNDLE_PATH. The files leave PENDING_RAW_SPAWNS, so the spawn_env_hygiene ratchet keeps them hermetic. Refs #823 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/e2e_gem.rs | 29 ++++++++------ .../tests/spawn_env_hygiene.rs | 3 -- .../vendor/vendor_gem_lockfile_only_e2e.rs | 16 +++----- .../vendor/vendor_rerun_no_network_e2e.rs | 40 ++++++------------- 4 files changed, 34 insertions(+), 54 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 9399b9949..31b2f3ed2 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -20,7 +20,7 @@ #[path = "common/mod.rs"] mod common; -use common::{binary, git_sha256}; +use common::{binary, git_sha256, hermetic}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -60,20 +60,18 @@ fn git_sha256_file(path: &Path) -> String { git_sha256(&content) } +/// Run the binary with the hermetic `SOCKET_*` environment (no token, so the +/// scans take the public-proxy path `--proxy-url` pins). The gem crawler +/// reads `BUNDLE_PATH`, `BUNDLE_APP_CONFIG` and the user's +/// `~/.bundle/config`, and the scans are unscoped, so every `BUNDLE_*` var +/// and an activated venv are scrubbed and `HOME` points at a stand-in +/// (`jvm_env::isolate_cli`): any of them would add the developer's gems or +/// packages to the exact purl sets these tests assert. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); + let mut cmd = hermetic::command(&binary()); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle, hermetic::Extra::Venv]); + common::jvm_env::isolate_cli(&mut cmd); cmd.args(args).current_dir(cwd); - // Hermeticity, mirroring the production e2e suites: scrub every ambient - // `SOCKET_*` (token, api/proxy URL, org, legacy aliases — not just the - // two token vars) and block the socket-cli config.json, so a developer's - // login or configured apiBaseUrl cannot silently repoint or re-tier - // these runs. - for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") { - cmd.env_remove(&k); - } - } - cmd.env("SOCKET_NO_CONFIG", "true"); let out: Output = cmd.output().expect("failed to execute socket-patch binary"); let code = out.status.code().unwrap_or(-1); @@ -94,6 +92,11 @@ fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) { fn bundle_run(cwd: &Path, args: &[&str]) { let mut cmd = Command::new("bundle"); cmd.args(args).current_dir(cwd); + // Scrub first: the sweeps would otherwise remove the BUNDLE_PATH below, + // and an ambient `BUNDLE_*` (frozen, deployment, without, ...) would + // change what this install does. + hermetic::scrub_socket_vars(&mut cmd); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle]); // Bundler 4 removed `bundle install --path`; BUNDLE_PATH is honored by // bundler 2 through 4 and keeps the vendor/bundle/ruby/*/gems layout // `find_gem_dir` expects. It also upholds cache_env's hermeticity diff --git a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs index 55f4bbb1b..71a15c74a 100644 --- a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs +++ b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs @@ -33,7 +33,6 @@ const PENDING_RAW_SPAWNS: &[&str] = &[ "e2e_cargo.rs", "e2e_composer.rs", "e2e_composer_version_identity.rs", - "e2e_gem.rs", "e2e_hosted_production.rs", "e2e_maven.rs", "e2e_npm.rs", @@ -49,8 +48,6 @@ const PENDING_RAW_SPAWNS: &[&str] = &[ "e2e_vex_vendor.rs", "rollback/rollback_multicopy_blob_gate.rs", "vendor/in_process_vendor_bun.rs", - "vendor/vendor_gem_lockfile_only_e2e.rs", - "vendor/vendor_rerun_no_network_e2e.rs", ]; /// The environment `cmd`'s child would see: the parent environment with diff --git a/crates/socket-patch-cli/tests/vendor/vendor_gem_lockfile_only_e2e.rs b/crates/socket-patch-cli/tests/vendor/vendor_gem_lockfile_only_e2e.rs index 1c5f507a5..4bfebe85a 100644 --- a/crates/socket-patch-cli/tests/vendor/vendor_gem_lockfile_only_e2e.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_gem_lockfile_only_e2e.rs @@ -1,9 +1,8 @@ //! Service vendoring of portable gems without a local install or registry fetch. -use crate::common::{binary, git_sha256}; +use crate::common::{binary, git_sha256, hermetic}; use std::path::Path; -use std::process::Command; use wiremock::matchers::{method, path as wm_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -126,11 +125,14 @@ async fn mount_gem_download(mock: &MockServer, gem: Vec) { } /// `vendor --json --vendor-source ` through the built binary, with -/// every ambient `SOCKET_*` var scrubbed and the API pointed at a +/// the hermetic `SOCKET_*` environment, every ambient `BUNDLE_*` var +/// scrubbed (an ambient `BUNDLE_PATH` or app config moves the gem store the +/// crawler reads) and the API pointed at a /// guaranteed-dead endpoint (patch staging is satisfied from /// `.socket/blobs`, so nothing should reach it). fn run_vendor(root: &Path, source: &str, api_url: &str) -> (i32, serde_json::Value, String) { - let mut cmd = Command::new(binary()); + let mut cmd = hermetic::command(&binary()); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle]); cmd.args([ "vendor", "--json", @@ -146,12 +148,6 @@ fn run_vendor(root: &Path, source: &str, api_url: &str) -> (i32, serde_json::Val "test-org", ]) .current_dir(root); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); let fixture = crate::prebuilt_common::Server::project(root); fixture.command(&mut cmd); let out = cmd.output().expect("run socket-patch vendor"); diff --git a/crates/socket-patch-cli/tests/vendor/vendor_rerun_no_network_e2e.rs b/crates/socket-patch-cli/tests/vendor/vendor_rerun_no_network_e2e.rs index d8be5402c..398c09fc2 100644 --- a/crates/socket-patch-cli/tests/vendor/vendor_rerun_no_network_e2e.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_rerun_no_network_e2e.rs @@ -19,10 +19,9 @@ //! Hermetic: every registry base and the patch API point at a //! guaranteed-dead local endpoint, and patch staging reads `.socket/blobs`. -use crate::common::{binary, git_sha256}; +use crate::common::{binary, git_sha256, hermetic}; use std::path::Path; -use std::process::Command; use sha2::{Digest, Sha256}; @@ -67,16 +66,19 @@ fn write_manifest(root: &Path, purl: &str, uuid: &str, file: &str, before: &[u8] std::fs::write(socket.join("blobs").join(git_sha256(after)), after).unwrap(); } -/// `vendor --json --vendor-source build` through the built binary with -/// every ambient `SOCKET_*` var scrubbed, the API and every registry base -/// pointed at `dead`, and `env` on top. +/// `vendor --json --vendor-source service` through the built binary with +/// the hermetic `SOCKET_*` environment, an activated venv and every +/// ambient `BUNDLE_*` var scrubbed (they move the Python and gem stores the +/// crawlers read), the API and every registry base pointed at `dead`, and +/// `env` on top. fn run_vendor( root: &Path, dead: &str, extra: &[&str], env: &[(&str, &str)], ) -> (i32, serde_json::Value, String) { - let mut cmd = Command::new(binary()); + let mut cmd = hermetic::command(&binary()); + hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle, hermetic::Extra::Venv]); cmd.args([ "vendor", "--json", @@ -93,16 +95,10 @@ fn run_vendor( ]) .args(extra) .current_dir(root); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } - for var in ["VIRTUAL_ENV", "CONDA_PREFIX", "BUNDLE_PATH", "GEM_HOME"] { + for var in ["CONDA_PREFIX", "GEM_HOME"] { cmd.env_remove(var); } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1") - .env("SOCKET_CRATES_REGISTRY", dead) + cmd.env("SOCKET_CRATES_REGISTRY", dead) .env("SOCKET_GOPROXY", dead) .env("SOCKET_PYPI_JSON_API", dead) .env("SOCKET_NPM_REGISTRY", dead) @@ -850,7 +846,7 @@ async fn cargo_service_vendor_never_downloads_the_pristine_crate() { let empty_home = tmp.path().join("cargo-home"); std::fs::create_dir_all(&empty_home).unwrap(); - let mut cmd = Command::new(binary()); + let mut cmd = hermetic::command(&binary()); cmd.args([ "vendor", "--json", @@ -864,13 +860,7 @@ async fn cargo_service_vendor_never_downloads_the_pristine_crate() { "acme", ]) .current_dir(&root); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } let out = cmd - .env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_CRATES_REGISTRY", registry.uri()) .env("CARGO_HOME", &empty_home) .output() @@ -975,7 +965,7 @@ async fn cargo_service_never_vendors_a_git_or_custom_registry_crate() { let empty_home = tmp.path().join("cargo-home"); std::fs::create_dir_all(&empty_home).unwrap(); - let mut cmd = Command::new(binary()); + let mut cmd = hermetic::command(&binary()); cmd.args([ "vendor", "--json", @@ -989,13 +979,7 @@ async fn cargo_service_never_vendors_a_git_or_custom_registry_crate() { "acme", ]) .current_dir(&root); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } let out = cmd - .env("SOCKET_TELEMETRY_DISABLED", "1") .env("SOCKET_CRATES_REGISTRY", registry.uri()) .env("CARGO_HOME", &empty_home) .output() From abd04ccd52be345908334db01e83a54d392d2e96 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 21:27:00 +0000 Subject: [PATCH 3/3] Isolate HOME and gem homes for gem CLI children The ruby crawler also reads the user's ~/.bundle/config and the `gem env` homes (GEM_HOME, GEM_PATH, ~/.gem). The two vendor suites kept the real HOME, and none of the three removed GEM_PATH, so a developer's global bundler path or gem home could still change which gem store these tests crawl. Give the vendor suites' CLI children the stand-in HOME e2e_gem already uses, and remove both gem-home vars from every gem-crawling CLI child. Refs #823 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/e2e_gem.rs | 8 +++++--- .../tests/vendor/vendor_gem_lockfile_only_e2e.rs | 9 ++++++--- .../tests/vendor/vendor_rerun_no_network_e2e.rs | 11 ++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_gem.rs b/crates/socket-patch-cli/tests/e2e_gem.rs index 31b2f3ed2..3b6560f76 100644 --- a/crates/socket-patch-cli/tests/e2e_gem.rs +++ b/crates/socket-patch-cli/tests/e2e_gem.rs @@ -62,14 +62,16 @@ fn git_sha256_file(path: &Path) -> String { /// Run the binary with the hermetic `SOCKET_*` environment (no token, so the /// scans take the public-proxy path `--proxy-url` pins). The gem crawler -/// reads `BUNDLE_PATH`, `BUNDLE_APP_CONFIG` and the user's -/// `~/.bundle/config`, and the scans are unscoped, so every `BUNDLE_*` var -/// and an activated venv are scrubbed and `HOME` points at a stand-in +/// reads `BUNDLE_PATH`, `BUNDLE_APP_CONFIG`, the user's `~/.bundle/config` +/// and the `gem env` homes (`GEM_HOME`, `GEM_PATH`, `~/.gem`), and the scans +/// are unscoped, so every `BUNDLE_*` var, both gem-home vars and an +/// activated venv are scrubbed and `HOME` points at a stand-in /// (`jvm_env::isolate_cli`): any of them would add the developer's gems or /// packages to the exact purl sets these tests assert. fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { let mut cmd = hermetic::command(&binary()); hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle, hermetic::Extra::Venv]); + cmd.env_remove("GEM_HOME").env_remove("GEM_PATH"); common::jvm_env::isolate_cli(&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/vendor/vendor_gem_lockfile_only_e2e.rs b/crates/socket-patch-cli/tests/vendor/vendor_gem_lockfile_only_e2e.rs index 4bfebe85a..b7bf802a3 100644 --- a/crates/socket-patch-cli/tests/vendor/vendor_gem_lockfile_only_e2e.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_gem_lockfile_only_e2e.rs @@ -125,14 +125,17 @@ async fn mount_gem_download(mock: &MockServer, gem: Vec) { } /// `vendor --json --vendor-source ` through the built binary, with -/// the hermetic `SOCKET_*` environment, every ambient `BUNDLE_*` var -/// scrubbed (an ambient `BUNDLE_PATH` or app config moves the gem store the -/// crawler reads) and the API pointed at a +/// the hermetic `SOCKET_*` environment, every ambient `BUNDLE_*` var and +/// both gem-home vars scrubbed and `HOME` on a stand-in (an ambient +/// `BUNDLE_PATH`, app config, `~/.bundle/config` or gem home moves the gem +/// store the crawler reads) and the API pointed at a /// guaranteed-dead endpoint (patch staging is satisfied from /// `.socket/blobs`, so nothing should reach it). fn run_vendor(root: &Path, source: &str, api_url: &str) -> (i32, serde_json::Value, String) { let mut cmd = hermetic::command(&binary()); hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle]); + cmd.env_remove("GEM_HOME").env_remove("GEM_PATH"); + crate::common::jvm_env::isolate_cli(&mut cmd); cmd.args([ "vendor", "--json", diff --git a/crates/socket-patch-cli/tests/vendor/vendor_rerun_no_network_e2e.rs b/crates/socket-patch-cli/tests/vendor/vendor_rerun_no_network_e2e.rs index 398c09fc2..be3e8aac8 100644 --- a/crates/socket-patch-cli/tests/vendor/vendor_rerun_no_network_e2e.rs +++ b/crates/socket-patch-cli/tests/vendor/vendor_rerun_no_network_e2e.rs @@ -67,10 +67,10 @@ fn write_manifest(root: &Path, purl: &str, uuid: &str, file: &str, before: &[u8] } /// `vendor --json --vendor-source service` through the built binary with -/// the hermetic `SOCKET_*` environment, an activated venv and every -/// ambient `BUNDLE_*` var scrubbed (they move the Python and gem stores the -/// crawlers read), the API and every registry base pointed at `dead`, and -/// `env` on top. +/// the hermetic `SOCKET_*` environment, an activated venv, the gem homes +/// and every ambient `BUNDLE_*` var scrubbed and `HOME` on a stand-in (they +/// move the Python and gem stores the crawlers read), the API and every +/// registry base pointed at `dead`, and `env` on top. fn run_vendor( root: &Path, dead: &str, @@ -79,6 +79,7 @@ fn run_vendor( ) -> (i32, serde_json::Value, String) { let mut cmd = hermetic::command(&binary()); hermetic::scrub_extra(&mut cmd, &[hermetic::Extra::Bundle, hermetic::Extra::Venv]); + crate::common::jvm_env::isolate_cli(&mut cmd); cmd.args([ "vendor", "--json", @@ -95,7 +96,7 @@ fn run_vendor( ]) .args(extra) .current_dir(root); - for var in ["CONDA_PREFIX", "GEM_HOME"] { + for var in ["CONDA_PREFIX", "GEM_HOME", "GEM_PATH"] { cmd.env_remove(var); } cmd.env("SOCKET_CRATES_REGISTRY", dead)