diff --git a/crates/socket-patch-cli/tests/common/cache_env.rs b/crates/socket-patch-cli/tests/common/cache_env.rs index d01fd7e4b..cde80a5e1 100644 --- a/crates/socket-patch-cli/tests/common/cache_env.rs +++ b/crates/socket-patch-cli/tests/common/cache_env.rs @@ -289,6 +289,15 @@ pub fn scrub_ambient_bun_env(cmd: &mut Command) { scrub_parent_vars(cmd, parent_var_names(), is_ambient_bun_var); } +/// [`scrub_ambient_bun_env`] minus `SOCKET_*`, for a `socket-patch` child +/// built with `hermetic::command`: the builder already handled `SOCKET_*`, +/// and removing them again would undo the opt-outs it forces on. +pub fn scrub_ambient_bun_tool_env(cmd: &mut Command) { + scrub_parent_vars(cmd, parent_var_names(), |name| { + !name.starts_with("SOCKET_") && is_ambient_bun_var(name) + }); +} + /// The names of the PARENT environment, read once. fn parent_var_names() -> Vec { std::env::vars_os().map(|(k, _)| k).collect() diff --git a/crates/socket-patch-cli/tests/common/hermetic.rs b/crates/socket-patch-cli/tests/common/hermetic.rs index b0e0b9538..c54cf49b6 100644 --- a/crates/socket-patch-cli/tests/common/hermetic.rs +++ b/crates/socket-patch-cli/tests/common/hermetic.rs @@ -48,6 +48,21 @@ const HOSTILE_SEEDS: &[(&str, &str)] = &[ ("SOCKET_UPDATE_STATE_DIR", "/nonexistent"), ]; +/// The opt-outs [`command`] forces on in every child: the config layer +/// (a developer's real `socket login` must never authenticate a test +/// child), the passive update notifier (no child may fetch release metadata +/// from real GitHub; the PTY suites hand the binary a real terminal, so the +/// stderr-TTY guard doesn't hold there) and telemetry (an unauthenticated +/// child would POST to the real public proxy; the workspace `[env]` default +/// can be overridden by the developer's shell). Suites that clear the +/// environment re-apply these after the clear. Notifier and telemetry +/// suites opt back in via caller env, which lands last. +pub const FORCED_OPT_OUTS: &[(&str, &str)] = &[ + ("SOCKET_NO_CONFIG", "1"), + ("SOCKET_NO_UPDATE_CHECK", "1"), + ("SOCKET_TELEMETRY_DISABLED", "1"), +]; + /// A `Command` for `bin` with the hermetic `SOCKET_*` environment: the /// hostile seeds scrubbed, every flag-bound `SOCKET_*` and every other /// ambient `SOCKET_*` removed (removing `SOCKET_API_TOKEN` also forces the @@ -75,23 +90,12 @@ pub fn command(bin: &Path) -> Command { } cmd.env_remove("SOCKET_API_TOKEN"); scrub_socket_vars(&mut cmd); - // Belt-and-braces on top of the `.cargo/config.toml` `[env]` default: - // a developer's real `socket login` (the socket-cli config.json token - // fallback) must never authenticate a test child — it would flip every - // "no token → public proxy" assertion onto the authed path. - cmd.env("SOCKET_NO_CONFIG", "1"); - // Same posture for the passive update notifier: no test child may ever - // fetch release metadata from real GitHub. The stderr-TTY guard covers - // piped children, but the PTY suites hand the binary a real terminal — - // this force-set is the layer that holds there. Notifier tests opt back - // in via caller env (which lands last). - cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); - // And for telemetry: an unauthenticated child POSTs its events to the - // real public proxy unless a suite points it at a mock. The workspace - // `[env]` default can be overridden by the developer's shell, so force - // it here too. Telemetry suites opt back in via caller env with - // `SOCKET_TELEMETRY_DISABLED=0` and a wiremock endpoint. - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + // Belt-and-braces on top of the `.cargo/config.toml` `[env]` defaults + // (see `FORCED_OPT_OUTS`): a developer's real `socket login` would flip + // every "no token → public proxy" assertion onto the authed path. + // Telemetry suites opt back in with `SOCKET_TELEMETRY_DISABLED=0` and a + // wiremock endpoint. + cmd.envs(FORCED_OPT_OUTS.iter().copied()); cmd } @@ -192,6 +196,13 @@ pub enum Extra { /// `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 + /// `UV_PROJECT_ENVIRONMENT` to a different venv. + Pdm, + /// Every `POETRY_*` var: Poetry reads any config key from + /// `POETRY_` (`POETRY_VIRTUALENVS_PATH`, repository URLs, ...). + Poetry, } /// Apply each of `extras` to `cmd` (see [`Extra`]). Run it before @@ -247,6 +258,21 @@ pub fn scrub_extra(cmd: &mut Command, extras: &[Extra]) { } cmd.env_remove("BUNDLE_APP_CONFIG"); } + Extra::Pdm => { + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("PDM_") { + cmd.env_remove(&key); + } + } + cmd.env_remove("UV_PROJECT_ENVIRONMENT"); + } + Extra::Poetry => { + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("POETRY_") { + cmd.env_remove(&key); + } + } + } } } } diff --git a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs index 7713a940c..068823228 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_common/mod.rs @@ -18,6 +18,9 @@ use tokio_util::sync::CancellationToken; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; +#[path = "../common/hermetic.rs"] +pub mod hermetic; + pub const ORG: &str = "test-org"; pub fn fixtures_root() -> PathBuf { @@ -405,8 +408,12 @@ fn disk_command( // The checkout is its own repo: no socket.yml above the temp dir applies. std::fs::create_dir_all(checkout.path().join(".git")).unwrap(); let cwd = checkout.path().join(cwd_rel); - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); + // A cleared environment: the disk scan must see none of the host's tool + // state either, so only the vars set below reach the child, including + // the hermetic builder's forced opt-outs, re-applied after the clear. + let mut cmd = hermetic::binary_command(); cmd.env_clear(); + cmd.envs(hermetic::FORCED_OPT_OUTS.iter().copied()); for keep in [ "SYSTEMROOT", "SystemRoot", @@ -431,10 +438,7 @@ fn disk_command( .env("GOPATH", empty("go")) .env("GOMODCACHE", empty("gomodcache")) .env("GEM_HOME", empty("gem")) - .env("XDG_CONFIG_HOME", empty("xdg")) - .env("SOCKET_NO_CONFIG", "1") - .env("SOCKET_TELEMETRY_DISABLED", "1") - .env("SOCKET_NO_UPDATE_CHECK", "1"); + .env("XDG_CONFIG_HOME", empty("xdg")); cmd.args([ "scan", "--mode", diff --git a/crates/socket-patch-cli/tests/hosted_memory_engine.rs b/crates/socket-patch-cli/tests/hosted_memory_engine.rs index 39a3fb0cd..060f3ab36 100644 --- a/crates/socket-patch-cli/tests/hosted_memory_engine.rs +++ b/crates/socket-patch-cli/tests/hosted_memory_engine.rs @@ -808,7 +808,7 @@ async fn hosted_bundle_command_prints_the_engine_result() { .map(|(k, v)| (k, String::from_utf8(v).unwrap())) .collect(); let bundle = serde_json::json!({ "files": files, "presentOnly": [".pnp.loader.mjs"] }); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")) + let mut child = hermetic::binary_command() .args([ "hosted-bundle", "--org", @@ -818,8 +818,6 @@ async fn hosted_bundle_command_prints_the_engine_result() { "--api-url", &server.uri(), ]) - .env("SOCKET_NO_CONFIG", "1") - .env("SOCKET_TELEMETRY_DISABLED", "1") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -852,11 +850,8 @@ async fn hosted_bundle_command_prints_the_engine_result() { .unwrap() .starts_with(env!("CARGO_PKG_VERSION"))); - let missing = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")) + let missing = hermetic::binary_command() .args(["hosted-bundle"]) - .env_remove("SOCKET_API_TOKEN") - .env_remove("SOCKET_ORG_SLUG") - .env("SOCKET_NO_CONFIG", "1") .stdin(std::process::Stdio::null()) .output() .unwrap(); diff --git a/crates/socket-patch-cli/tests/hosted_superseding_pypi.rs b/crates/socket-patch-cli/tests/hosted_superseding_pypi.rs index 9dd8b0e59..27226239a 100644 --- a/crates/socket-patch-cli/tests/hosted_superseding_pypi.rs +++ b/crates/socket-patch-cli/tests/hosted_superseding_pypi.rs @@ -13,13 +13,15 @@ use std::io::Write as _; use std::path::Path; -use std::process::Command; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/hermetic.rs"] +mod hermetic; + const ORG: &str = "test-org"; const PURL: &str = "pkg:pypi/six@1.16.0"; const WHEEL: &str = "six-1.16.0-py2.py3-none-any.whl"; @@ -110,13 +112,13 @@ async fn offer(server: &MockServer, uuid: &str, grant: &str, generation: u8) -> hosted_url } -/// `scan --mode hosted --json` with every ambient `SOCKET_*` var scrubbed. +/// `scan --mode hosted --json` in the hermetic environment. /// `VIRTUAL_ENV` points at the fixture venv ([`project`]), which keeps the /// installed-tree probes off the host's Python. fn hosted_scan(root: &Path, server: &MockServer) -> (i32, Value) { let venv = root.join("../venv"); let uri = server.uri(); - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.args([ "scan", "--mode", @@ -136,14 +138,8 @@ fn hosted_scan(root: &Path, server: &MockServer) -> (i32, Value) { ]) .arg("--cwd") .arg(root) - .current_dir(root); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") { - cmd.env_remove(key); - } - } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1") - .env("VIRTUAL_ENV", &venv); + .current_dir(root) + .env("VIRTUAL_ENV", &venv); let out = cmd.output().expect("spawn socket-patch"); let stdout = String::from_utf8_lossy(&out.stdout); let env = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| { diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index d4f7dfe3c..d58508bb6 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -8,6 +8,8 @@ //! This is the CLI counterpart of the depscan-side install-verify e2e; the //! rewriter bytes themselves are pinned by the shared golden fixtures. +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "common/rollback_json.rs"] mod rollback_json; @@ -2273,27 +2275,12 @@ async fn native_binary_no_matching_version_preserves_exact_bytes() { .exists()); } -/// A `socket-patch` Command with the ambient `SOCKET_*` env surface scrubbed, -/// for the subprocess tests below: the binary binds a wide clap env surface -/// (SOCKET_DRY_RUN, SOCKET_OFFLINE, SOCKET_ECOSYSTEMS, SOCKET_PROXY_URL, ...), -/// and an ambient value silently changes what these tests exercise — ambient -/// `SOCKET_DRY_RUN=true` turns the rewrite into a no-op and every on-disk -/// oracle red. Seed-then-scrub (the `common/mod.rs` pattern): the hostile -/// seeds never reach the child because `env_remove` clears them too, but if a -/// scrub line is ever dropped the seed turns the suite red immediately. -/// Telemetry opt-outs are deliberately kept so an opted-out dev stays opted -/// out. The in-process tests above don't need this — their literal `ScanArgs` -/// bypass clap's env bindings entirely. +/// A `socket-patch` Command in the hermetic environment +/// (`tests/common/hermetic.rs`), for the subprocess tests below. The +/// in-process tests above don't need it: their literal `ScanArgs` bypass +/// clap's env bindings entirely. fn scrubbed_cli() -> std::process::Command { - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); - cmd.env("SOCKET_DRY_RUN", "true") - .env("SOCKET_OFFLINE", "true") - .env("SOCKET_ECOSYSTEMS", "cargo") - .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") - .env_remove("SOCKET_DRY_RUN") - .env_remove("SOCKET_OFFLINE") - .env_remove("SOCKET_ECOSYSTEMS") - .env_remove("SOCKET_MANIFEST_PATH"); + let mut cmd = hermetic::binary_command(); // A registry exported by npm (`npm_config_registry`) or Bun would // steer the Bun restores off the fixtures' registry. for key in [ @@ -2303,25 +2290,6 @@ fn scrubbed_cli() -> std::process::Command { ] { cmd.env_remove(key); } - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" - { - cmd.env_remove(&key); - } - } - // In-process tests in this binary `std::env::set_var` these via - // `apply_env_toggles`; one set by a parallel test between the scan - // above and the spawn would be inherited, so remove them - // unconditionally (see in_process_vendor.rs `run_cli`). - for key in [ - "SOCKET_OFFLINE", - "SOCKET_DEBUG", - "SOCKET_API_URL", - "SOCKET_PROXY_URL", - ] { - cmd.env_remove(key); - } cmd } diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs index eb5bfd816..10d5b0a3a 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pdm.rs @@ -30,6 +30,8 @@ use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; use vex_e2e_common::{ @@ -667,26 +669,21 @@ async fn assert_relock_roundtrip(lock: &str, relocked: &str) { ); } -/// Spawn `scan --mode hosted --json` on `root` with a scrubbed environment -/// (no ambient `SOCKET_*`, `PDM_*` or `VIRTUAL_ENV`). +/// Spawn `scan --mode hosted --json` on `root` in the hermetic environment, +/// with no ambient `PDM_*`, `VIRTUAL_ENV` or `UV_PROJECT_ENVIRONMENT` either. async fn scan_json( root: &Path, server: &MockServer, extra: &[&str], ) -> (Option, serde_json::Value) { - let mut cmd = tokio::process::Command::new(binary()); - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") - || name.starts_with("PDM_") - || name == "VIRTUAL_ENV" - || name == "UV_PROJECT_ENVIRONMENT" - { - cmd.env_remove(key); - } - } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1") - .args(["scan", "--mode", "hosted", "--yes", "--json", "--cwd"]) + let mut std_cmd = hermetic::binary_command(); + hermetic::scrub_extra(&mut std_cmd, &[hermetic::Extra::Venv, hermetic::Extra::Pdm]); + // The #[serial] in-process tests here `set_var` this for their own + // mock; a non-serial scan building its command meanwhile would inherit + // it, so remove it by name rather than only via the builder's sweep. + std_cmd.env_remove("SOCKET_PYPI_JSON_API"); + let mut cmd = tokio::process::Command::from(std_cmd); + cmd.args(["scan", "--mode", "hosted", "--yes", "--json", "--cwd"]) .arg(root) .args([ "--api-url", diff --git a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs index f7c136dee..ebae6ac90 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs @@ -17,6 +17,8 @@ use socket_patch_cli::commands::rollback::{self, RollbackArgs}; use socket_patch_cli::commands::scan::{run, ScanArgs, ScanMode}; use std::path::Path; +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; use wiremock::matchers::{method, path, path_regex}; @@ -1031,34 +1033,12 @@ async fn hosted_pnpm_manifestless_vex_from_lockfile_legacy_ledger_and_api() { } } -/// The CLI binary with ambient SOCKET_* env scrubbed (same hermeticity rule -/// as `in_process_redirect.rs`'s `scrubbed_cli`; duplicated because test -/// binaries cannot share helpers). Subprocess (not in-process `run`) so the -/// `--json` stdout envelope can be read back — the diagnostics under test -/// ARE the envelope's `redirect.warnings`. +/// The CLI binary in the hermetic environment (`tests/common/hermetic.rs`). +/// Subprocess (not in-process `run`) so the `--json` stdout envelope can be +/// read back — the diagnostics under test ARE the envelope's +/// `redirect.warnings`. fn scrubbed_cli() -> std::process::Command { - let cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); - let mut cmd = cmd; - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" - { - cmd.env_remove(&key); - } - } - // In-process tests in this binary `std::env::set_var` these via - // `apply_env_toggles`; one set by a parallel test between the scan - // above and the spawn would be inherited, so remove them - // unconditionally (see in_process_vendor.rs `run_cli`). - for key in [ - "SOCKET_OFFLINE", - "SOCKET_DEBUG", - "SOCKET_API_URL", - "SOCKET_PROXY_URL", - ] { - cmd.env_remove(key); - } - cmd + hermetic::binary_command() } /// Run `scan --mode hosted --json` as a subprocess against `cwd` and parse diff --git a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs index c068ddd3c..78248d9e9 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect_poetry.rs @@ -11,6 +11,8 @@ use std::path::Path; +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; @@ -384,29 +386,19 @@ fn install_package(root: &Path, venv: &str, bytes: &[u8]) -> std::path::PathBuf } async fn scan_output(root: &Path, server: &MockServer, extra: &[&str]) -> std::process::Output { - let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") || name.starts_with("POETRY_") || name == "VIRTUAL_ENV" { - cmd.env_remove(key); - } - } - // In-process tests in this binary `std::env::set_var` these via - // `apply_env_toggles`; one set by a parallel test between the scan - // above and the spawn would be inherited, so remove them - // unconditionally (see in_process_vendor.rs `run_cli`). - for key in [ - "SOCKET_OFFLINE", - "SOCKET_DEBUG", - "SOCKET_API_URL", - "SOCKET_PROXY_URL", - ] { - cmd.env_remove(key); - } + let mut std_cmd = hermetic::binary_command(); + hermetic::scrub_extra( + &mut std_cmd, + &[hermetic::Extra::Venv, hermetic::Extra::Poetry], + ); + // The #[serial] in-process tests here `set_var` this for their own + // mock; a non-serial scan building its command meanwhile would inherit + // it, so remove it by name rather than only via the builder's sweep. + std_cmd.env_remove("SOCKET_PYPI_JSON_API"); + let mut cmd = tokio::process::Command::from(std_cmd); // Hermetic against the runner's own Python user site, which a // `--system-site-packages` venv reaches (#409). - cmd.env("SOCKET_TELEMETRY_DISABLED", "1") - .env("PYTHONNOUSERSITE", "1") + cmd.env("PYTHONNOUSERSITE", "1") .args(["scan", "--mode", "hosted", "--yes", "--cwd"]) .arg(root) .args([ diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 6d1ce8c70..d5ebdd35a 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -27,6 +27,8 @@ //! `#[serial]`: every command's `run` mirrors env toggles into //! process-global env vars (`apply_env_toggles`). +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "common/rollback_json.rs"] mod rollback_json; @@ -162,24 +164,10 @@ async fn rollback_online(cwd: &Path, server: &MockServer) -> i32 { code } -/// A `socket-patch` Command with the ambient `SOCKET_*` env surface scrubbed -/// (the `in_process_redirect.rs` seed-then-scrub pattern): hostile seeds -/// never reach the child because `env_remove` clears them too, but if a -/// scrub line is ever dropped the seed turns the suite red immediately. -/// Telemetry opt-outs are deliberately kept so an opted-out dev stays -/// opted out. +/// A `socket-patch` Command in the hermetic environment +/// (`tests/common/hermetic.rs`). fn scrubbed_cli() -> std::process::Command { - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_socket-patch")); - cmd.env("SOCKET_DRY_RUN", "true") - .env("SOCKET_OFFLINE", "true") - .env("SOCKET_ECOSYSTEMS", "cargo") - .env("SOCKET_MANIFEST_PATH", "/nonexistent/manifest.json") - .env("SOCKET_PRESERVE_STATE", "true") - .env_remove("SOCKET_DRY_RUN") - .env_remove("SOCKET_OFFLINE") - .env_remove("SOCKET_ECOSYSTEMS") - .env_remove("SOCKET_MANIFEST_PATH") - .env_remove("SOCKET_PRESERVE_STATE"); + let mut cmd = hermetic::binary_command(); // A registry exported by npm (`npm_config_registry`) or Bun would // steer the Bun restores off the fixtures' registry. for key in [ @@ -189,25 +177,6 @@ fn scrubbed_cli() -> std::process::Command { ] { cmd.env_remove(key); } - for (key, _) in std::env::vars_os() { - let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" - { - cmd.env_remove(&key); - } - } - // In-process tests in this binary `std::env::set_var` these via - // `apply_env_toggles`; one set by a parallel test between the scan - // above and the spawn would be inherited, so remove them - // unconditionally (see in_process_vendor.rs `run_cli`). - for key in [ - "SOCKET_OFFLINE", - "SOCKET_DEBUG", - "SOCKET_API_URL", - "SOCKET_PROXY_URL", - ] { - cmd.env_remove(key); - } cmd } diff --git a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs index dae29d8d1..d1366360b 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_vendored.rs @@ -27,6 +27,8 @@ #[path = "common/rollback_json.rs"] mod rollback_json; +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; @@ -37,7 +39,6 @@ async fn vendor_run(mut args: VendorArgs) -> i32 { } use std::path::{Path, PathBuf}; -use std::process::Command; use serde_json::{json, Value}; use socket_patch_cli::args::GlobalArgs; @@ -231,30 +232,12 @@ fn rollback_args(cwd: &Path, preserve_state: bool) -> RollbackArgs { // ───────────────────────── subprocess runner ───────────────────────── -/// Run the built `socket-patch` binary with every ambient `SOCKET_*` env var -/// scrubbed from the child (env-robustness: the assertions must reflect the -/// argv, not the developer's shell) and telemetry hard-disabled. +/// Run the built `socket-patch` binary in the hermetic environment +/// (`tests/common/hermetic.rs`): the assertions must reflect the argv, not +/// the developer's shell. fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.args(args).current_dir(cwd); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } - // In-process tests in this binary `std::env::set_var` these via - // `apply_env_toggles`; one set by a parallel test between the scan - // above and the spawn would be inherited, so remove them - // unconditionally (see in_process_vendor.rs `run_cli`). - for key in [ - "SOCKET_OFFLINE", - "SOCKET_DEBUG", - "SOCKET_API_URL", - "SOCKET_PROXY_URL", - ] { - cmd.env_remove(key); - } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); let out = cmd.output().expect("spawn socket-patch binary"); ( out.status.code().unwrap_or(-1), diff --git a/crates/socket-patch-cli/tests/in_process_vendor.rs b/crates/socket-patch-cli/tests/in_process_vendor.rs index c53d2eaa8..f87655021 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor.rs @@ -22,6 +22,8 @@ #[path = "common/rollback_json.rs"] mod rollback_json; +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; @@ -32,7 +34,6 @@ async fn vendor_run(mut args: VendorArgs) -> i32 { } use std::path::{Path, PathBuf}; -use std::process::Command; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -237,34 +238,16 @@ fn vendor_args(cwd: &Path) -> VendorArgs { // ───────────────────────── subprocess runner ───────────────────────── -/// Run the built `socket-patch` binary with every ambient `SOCKET_*` env var -/// scrubbed from the child (env-robustness: the assertions must reflect the -/// argv, not the developer's shell) and telemetry hard-disabled. Returns -/// `(exit_code, stdout, stderr)`. +/// Run the built `socket-patch` binary in the hermetic environment +/// (`tests/common/hermetic.rs`): the assertions must reflect the argv, not +/// the developer's shell. The builder also removes every flag-bound +/// `SOCKET_*` by name, so a toggle a parallel in-process test's +/// `apply_env_toggles` sets before the spawn is not inherited either (seen +/// on test-release: a hosted scan inherited SOCKET_OFFLINE=1 and refused to +/// run). Returns `(exit_code, stdout, stderr)`. fn run_cli(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.args(args).current_dir(cwd); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } - // The in-process tests in this binary run `apply_env_toggles`, which - // `std::env::set_var`s these on the shared test process. A toggle set - // by a parallel test after the scan above but before the spawn would - // be inherited, so remove them unconditionally: `Command` applies the - // removals to the environment captured at spawn time. Seen on - // test-release: a hosted scan here inherited SOCKET_OFFLINE=1 and - // refused to run ("cannot run with --offline/SOCKET_OFFLINE"). - for key in [ - "SOCKET_OFFLINE", - "SOCKET_DEBUG", - "SOCKET_API_URL", - "SOCKET_PROXY_URL", - ] { - cmd.env_remove(key); - } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); for (k, v) in extra_env { cmd.env(k, v); } diff --git a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs index 700bf8ed2..51d2017a8 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs @@ -48,11 +48,12 @@ #[path = "common/rollback_json.rs"] mod rollback_json; +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; use std::path::Path; -use std::process::Command; use base64::Engine as _; use serde_json::{json, Value}; @@ -352,9 +353,8 @@ fn registry_uri() -> &'static str { }) } -/// Run the built `socket-patch` binary with every ambient `SOCKET_*` var -/// scrubbed (except the hermetic `SOCKET_NO_CONFIG`) and telemetry -/// hard-disabled; `https://patch.socket.dev` counts as the patch server +/// Run the built `socket-patch` binary in the hermetic environment +/// (`tests/common/hermetic.rs`); `https://patch.socket.dev` counts as the patch server /// (`SOCKET_PATCH_SERVER_URL`) and the registry is the shared mirror /// (`SOCKET_NPM_REGISTRY`). Returns `(exit_code, stdout, stderr)`. fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { @@ -364,13 +364,8 @@ fn run_cli(cwd: &Path, args: &[&str]) -> (i32, String, String) { /// [`run_cli`] with `env` set last, over the stand-in `HOME` (a test's own /// user-level Bun config, #1276). fn run_cli_env(cwd: &Path, args: &[&str], env: &[(&str, String)]) -> (i32, String, String) { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.current_dir(cwd); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") && key != "SOCKET_NO_CONFIG" { - cmd.env_remove(key); - } - } // A registry exported by npm (`npm_config_registry`) or Bun would // steer the Bun restores off the fixtures' registries (#992). // So would a user-level `.npmrc` / bunfig under `XDG_CONFIG_HOME` @@ -383,8 +378,7 @@ fn run_cli_env(cwd: &Path, args: &[&str], env: &[(&str, String)]) -> (i32, Strin ] { cmd.env_remove(key); } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1") - .env("SOCKET_NPM_REGISTRY", registry_uri()); + cmd.env("SOCKET_NPM_REGISTRY", registry_uri()); let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); for (key, value) in env { cmd.env(key, value); diff --git a/crates/socket-patch-cli/tests/in_process_vendor_npm_v1_takeover.rs b/crates/socket-patch-cli/tests/in_process_vendor_npm_v1_takeover.rs index d2a20b9dd..a8605eaea 100644 --- a/crates/socket-patch-cli/tests/in_process_vendor_npm_v1_takeover.rs +++ b/crates/socket-patch-cli/tests/in_process_vendor_npm_v1_takeover.rs @@ -14,11 +14,12 @@ //! Every child process gets the ambient `SOCKET_*` vars scrubbed and //! telemetry hard-disabled; each test runs in its own tempdir. +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; use std::path::Path; -use std::process::Command; use base64::Engine as _; use serde_json::{json, Value}; @@ -191,18 +192,12 @@ async fn mock_api(server: &MockServer) { // ───────────────────────── subprocess runner ───────────────────────── -/// Run the built binary with every ambient `SOCKET_*` var scrubbed and the -/// npm registry pointed at the mock. Returns `(exit_code, envelope)`. +/// Run the built binary in the hermetic environment +/// (`tests/common/hermetic.rs`) with the npm registry pointed at the mock. +/// Returns `(exit_code, envelope)`. fn run_json(cwd: &Path, registry: &str, args: &[&str]) -> (i32, Value) { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); - cmd.current_dir(cwd); - 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") - .env("SOCKET_NPM_REGISTRY", registry); + let mut cmd = hermetic::binary_command(); + cmd.current_dir(cwd).env("SOCKET_NPM_REGISTRY", registry); let _fixture = prebuilt_common::prepare_command(&mut cmd, cwd, args, &[]); let out = cmd.output().expect("spawn socket-patch binary"); let stdout = String::from_utf8_lossy(&out.stdout); diff --git a/crates/socket-patch-cli/tests/mode_migration_bun.rs b/crates/socket-patch-cli/tests/mode_migration_bun.rs index e74d15aef..d4d2ec20b 100644 --- a/crates/socket-patch-cli/tests/mode_migration_bun.rs +++ b/crates/socket-patch-cli/tests/mode_migration_bun.rs @@ -360,12 +360,13 @@ fn bun(cwd: &Path, args: &[&str], bun_home: &Path) -> Output { /// `#[serial]`, so one slot is enough. static ONLINE_ENV: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); -/// The real binary with `--no-telemetry` appended: nothing in this suite -/// should ever post a telemetry event, mocked API or not. +/// The real binary in the hermetic environment (`tests/common/hermetic.rs`) +/// with `--no-telemetry` appended: nothing in this suite should ever post a +/// telemetry event, mocked API or not. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); + let mut cmd = common::hermetic_command(&binary()); cmd.args(args).arg("--no-telemetry").current_dir(cwd); - scrub_env(&mut cmd); + cache_env::scrub_ambient_bun_tool_env(&mut cmd); for (k, v) in ONLINE_ENV.lock().unwrap_or_else(|e| e.into_inner()).iter() { cmd.env(k, v); } diff --git a/crates/socket-patch-cli/tests/mode_migration_cargo.rs b/crates/socket-patch-cli/tests/mode_migration_cargo.rs index 403d17c96..2f38abde6 100644 --- a/crates/socket-patch-cli/tests/mode_migration_cargo.rs +++ b/crates/socket-patch-cli/tests/mode_migration_cargo.rs @@ -46,7 +46,7 @@ use common::{binary, git_sha256}; mod prebuilt_common; use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +use std::process::Output; use sha2::{Digest, Sha256}; use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; @@ -84,14 +84,9 @@ fn run_socket_env( cargo_home: &Path, env: &[(&str, &str)], ) -> (i32, String, String) { - let mut cmd = Command::new(binary()); + let mut cmd = common::hermetic_command(&binary()); cmd.current_dir(cwd); - for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("SOCKET_") && k.to_string_lossy() != "SOCKET_NO_CONFIG" { - cmd.env_remove(&k); - } - } - cmd.env_remove("VIRTUAL_ENV"); + common::hermetic::scrub_extra(&mut cmd, &[common::hermetic::Extra::Venv]); cmd.env("CARGO_HOME", cargo_home); for (k, v) in env { cmd.env(k, v); diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index b4e76d466..122b8de57 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -11,12 +11,13 @@ //! from the staged manifest by the prebuilt fixture server, and the hosted //! API + hosted wheel are a wiremock. +#[path = "common/hermetic.rs"] +mod hermetic; #[path = "prebuilt_common/mod.rs"] mod prebuilt_common; use std::io::Write as _; use std::path::Path; -use std::process::Command; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; @@ -145,15 +146,9 @@ fn run_raw(root: &Path, args: &[&str], extra: &[(&str, &str)]) -> (i32, String, "lib/python3.11/site-packages" })) .unwrap(); - let mut cmd = Command::new(env!("CARGO_BIN_EXE_socket-patch")); + let mut cmd = hermetic::binary_command(); cmd.args(args).arg("--cwd").arg(root).current_dir(root); - for (key, _) in std::env::vars() { - if key.starts_with("SOCKET_") { - cmd.env_remove(key); - } - } - cmd.env("SOCKET_TELEMETRY_DISABLED", "1") - .env("VIRTUAL_ENV", &venv) + cmd.env("VIRTUAL_ENV", &venv) .env("PIPENV_IGNORE_VIRTUALENVS", "0") .envs(extra.iter().copied()); let fixture = (args.first() == Some(&"vendor")).then(|| { diff --git a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs index 832f3d4c3..67c0a319a 100644 --- a/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs +++ b/crates/socket-patch-cli/tests/remove_rollback_api_overrides.rs @@ -18,60 +18,12 @@ use std::io::{Read, Write}; use std::net::TcpListener; -use std::process::Command; use std::sync::{Arc, Mutex}; #[path = "common/mod.rs"] mod common; use common::{binary, git_sha256}; -/// Every `SOCKET_*` env var `GlobalArgs` reads as a flag fallback — scrubbed -/// so behavior is driven only by the explicit flags under test (an ambient -/// `SOCKET_API_TOKEN` would let the buggy env-fallback path pass). -const SOCKET_ENV_VARS: &[&str] = &[ - "SOCKET_API_TOKEN", - "SOCKET_CWD", - "SOCKET_MANIFEST_PATH", - "SOCKET_API_URL", - "SOCKET_ORG_SLUG", - "SOCKET_PROXY_URL", - "SOCKET_ECOSYSTEMS", - "SOCKET_VENDOR_SOURCE", - "SOCKET_VENDOR_URL", - "SOCKET_PATCH_SERVER_URL", - "SOCKET_OFFLINE", - "SOCKET_STRICT", - "SOCKET_GLOBAL", - "SOCKET_GLOBAL_PREFIX", - "SOCKET_JSON", - "SOCKET_VERBOSE", - "SOCKET_SILENT", - "SOCKET_DRY_RUN", - "SOCKET_YES", - "SOCKET_LOCK_TIMEOUT", - "SOCKET_DEBUG", - "SOCKET_TELEMETRY_DISABLED", - "SOCKET_SKIP_ROLLBACK", - "SOCKET_NO_TRUST_LOCKFILE_CONFIG", - "SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG", - "SOCKET_NO_VLT_INSTALL_CLEANUP", -]; - -/// Drift guard: the scrub must cover every env var `GlobalArgs` binds — the -/// production `GLOBAL_ARG_ENV_VARS` list is the source of truth. A var -/// missing here escapes the scrub, so an ambient value in the developer's -/// shell or CI (e.g. `SOCKET_STRICT=garbage`) aborts every invocation in -/// this file. Mirrors `cli_parse_vendor.rs`. -#[test] -fn env_scrub_covers_every_global_arg_env_var() { - for var in socket_patch_cli::args::GLOBAL_ARG_ENV_VARS { - assert!( - SOCKET_ENV_VARS.contains(var), - "{var} is bound by GlobalArgs but missing from SOCKET_ENV_VARS — the scrub won't strip it", - ); - } -} - /// Minimal HTTP stub playing the authenticated API: serves `blob` bytes at /// any path ending in `/patches/blob/`, 404 otherwise, and records /// every request path. The accept thread is detached; it dies with the test @@ -189,7 +141,11 @@ fn remove_rollback_downloads_missing_blob_via_flag_overrides() { ); std::fs::write(socket.join("manifest.json"), manifest).unwrap(); - let mut cmd = Command::new(binary()); + // The hermetic builder removes every `SOCKET_*` var `GlobalArgs` reads + // as a flag fallback, so behavior is driven only by the explicit flags + // under test (an ambient `SOCKET_API_TOKEN` would let the buggy + // env-fallback path pass). + let mut cmd = common::hermetic_command(&binary()); cmd.args([ "remove", "pkg:npm/__ovr_test__@1.0.0", @@ -202,11 +158,7 @@ fn remove_rollback_downloads_missing_blob_via_flag_overrides() { "testorg", ]) .current_dir(tmp.path()); - for var in SOCKET_ENV_VARS { - cmd.env_remove(var); - } cmd.env("SOCKET_PROXY_URL", format!("http://127.0.0.1:{dead}")); - cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); let out = cmd.output().expect("run socket-patch remove"); let stdout = String::from_utf8_lossy(&out.stdout); diff --git a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs index 234371a09..07be71cae 100644 --- a/crates/socket-patch-cli/tests/spawn_env_hygiene.rs +++ b/crates/socket-patch-cli/tests/spawn_env_hygiene.rs @@ -81,22 +81,6 @@ const PENDING_RAW_SPAWNS: &[&str] = &[ "e2e_vex_lockfile/yarn.rs", "e2e_vex_redirect.rs", "e2e_vex_vendor.rs", - "hosted_memory_common/mod.rs", - "hosted_memory_engine.rs", - "hosted_superseding_pypi.rs", - "in_process_redirect.rs", - "in_process_redirect_pdm.rs", - "in_process_redirect_pnpm.rs", - "in_process_redirect_poetry.rs", - "in_process_rollback_hosted.rs", - "in_process_rollback_vendored.rs", - "in_process_vendor.rs", - "in_process_vendor_bun_takeover.rs", - "in_process_vendor_npm_v1_takeover.rs", - "mode_migration_bun.rs", - "mode_migration_cargo.rs", - "mode_migration_pypi.rs", - "remove_rollback_api_overrides.rs", "rollback/rollback_multicopy_blob_gate.rs", "scan_api_retry_e2e.rs", "scan_pnpm_relocated_store_cwd_e2e.rs",