Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
394 changes: 394 additions & 0 deletions crates/socket-patch-cli/tests/common/cache_env.rs

Large diffs are not rendered by default.

18 changes: 16 additions & 2 deletions crates/socket-patch-cli/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ use std::process::{Command, Output};

use sha2::{Digest, Sha256};

/// Cache isolation for the package managers these helpers spawn. Files
/// that don't need the rest of this module pull it in on its own with
/// `#[path = "common/cache_env.rs"] mod cache_env;`.
pub mod cache_env;

// ── Binary discovery + invocation ─────────────────────────────────────

/// Absolute path to the built `socket-patch` binary that cargo
Expand All @@ -36,8 +41,10 @@ pub fn binary() -> PathBuf {
/// (CI gates the toolchain at the workflow level; this is a
/// belt-and-braces guard for local runs).
pub fn has_command(cmd: &str) -> bool {
Command::new(cmd)
.arg("--version")
let mut probe = Command::new(cmd);
probe.arg("--version");
cache_env::isolate(&mut probe);
probe
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
Expand Down Expand Up @@ -198,9 +205,13 @@ pub fn pnpm_run(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
/// Run `cargo` in `cwd`. Returns the raw Output so callers can
/// inspect stdout/stderr/exit on either pass or fail — the cargo
/// e2e test wants both passing and failing cases (negative control).
///
/// Caches are sandboxed by [`cache_env::isolate`] before `extra_env`
/// is applied, so a caller that pins its own `CARGO_HOME` still wins.
pub fn cargo_run(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Output {
let mut cmd = Command::new("cargo");
cmd.args(args).current_dir(cwd);
cache_env::isolate(&mut cmd);
for (k, v) in extra_env {
cmd.env(k, v);
}
Expand All @@ -210,6 +221,9 @@ pub fn cargo_run(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Outpu
fn run_toolchain(cwd: &Path, exe: &str, args: &[&str], extra_env: &[(&str, &str)]) {
let mut cmd = Command::new(exe);
cmd.args(args).current_dir(cwd);
// Sandbox the caches first so `extra_env` can still override any
// individual one (`Command` env ops are last-write-wins per name).
cache_env::isolate(&mut cmd);
for (k, v) in extra_env {
cmd.env(k, v);
}
Expand Down
18 changes: 11 additions & 7 deletions crates/socket-patch-cli/tests/e2e_gem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ use sha2::{Digest, Sha256};
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[path = "common/cache_env.rs"]
mod cache_env;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
Expand All @@ -40,8 +43,10 @@ fn binary() -> PathBuf {
}

fn has_command(cmd: &str) -> bool {
Command::new(cmd)
.arg("--version")
let mut probe = Command::new(cmd);
probe.arg("--version");
cache_env::isolate(&mut probe);
probe
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
Expand Down Expand Up @@ -87,11 +92,10 @@ fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) {
}

fn bundle_run(cwd: &Path, args: &[&str]) {
let out = Command::new("bundle")
.args(args)
.current_dir(cwd)
.output()
.expect("failed to run bundle");
let mut cmd = Command::new("bundle");
cmd.args(args).current_dir(cwd);
cache_env::isolate(&mut cmd);
let out = cmd.output().expect("failed to run bundle");
assert!(
out.status.success(),
"bundle {args:?} failed (exit {:?}).\nstdout:\n{}\nstderr:\n{}",
Expand Down
8 changes: 7 additions & 1 deletion crates/socket-patch-cli/tests/e2e_golang_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use std::process::Command;
#[path = "common/mod.rs"]
mod common;

use common::{binary, git_sha256, has_command};
use common::{binary, cache_env, git_sha256, has_command};

const UMOD: &str = "example.com/upstream";
const UVER: &str = "v1.0.0";
Expand Down Expand Up @@ -63,9 +63,15 @@ fn run_socket(cwd: &Path, args: &[&str], modcache: &Path) -> (i32, String, Strin
)
}

/// Run `go` with its caches sandboxed, then the fixture's own env on top.
///
/// `GOMODCACHE` alone is not isolation: `go build` keeps its compiled objects
/// in `GOCACHE`, a different directory that does not follow `GOPATH` either,
/// so without [`cache_env::isolate`] this test still filled the real home.
fn go(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> std::process::Output {
let mut cmd = Command::new("go");
cmd.args(args).current_dir(dir);
cache_env::isolate(&mut cmd);
for (k, v) in env {
cmd.env(k, v);
}
Expand Down
52 changes: 41 additions & 11 deletions crates/socket-patch-cli/tests/e2e_hosted_production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ use std::process::{Command, Output};

use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS};

#[path = "common/cache_env.rs"]
mod cache_env;

// ---------------------------------------------------------------------------
// Production endpoints + required-patch catalog
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -215,13 +218,17 @@ fn has_command(cmd: &str) -> bool {
} else {
&["--version"]
};
Command::new(cmd)
let mut probe_cmd = Command::new(cmd);
probe_cmd
.args(probe)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
.stderr(std::process::Stdio::null());
// Where pnpm/yarn are corepack shims, this probe is what actually
// downloads the package manager — keep that out of the real
// COREPACK_HOME, and let the probe answer for the same environment
// the leg's `tool()` invocations run in.
cache_env::isolate(&mut probe_cmd);
probe_cmd.status().map(|s| s.success()).unwrap_or(false)
}

/// The three legacy `SOCKET_PATCH_*` names still honored at runtime via
Expand Down Expand Up @@ -358,6 +365,10 @@ fn redirected_count(env: &serde_json::Value) -> u64 {
fn tool(cwd: &Path, program: &str, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut cmd = Command::new(program);
cmd.args(args).current_dir(cwd);
// Sandbox everything the per-leg `env` below does not name — corepack's
// downloaded package managers most of all — so a run leaves the caller's
// home alone.
cache_env::isolate(&mut cmd);
// Keep every toolchain's cache inside the fixture so the reinstall leg
// starts genuinely cold and cannot be satisfied from a warm host cache
// holding the *pristine* artifact.
Expand Down Expand Up @@ -1360,7 +1371,24 @@ fn gem_bundler_hosted_redirect_and_known_install_defect() {
}
let detail = dump(&reinstall);
let is_known_defect = detail.contains("APIResponseMismatchError")
|| detail.contains("revealed dependencies not in the API");
|| detail.contains("revealed dependencies not in the API")
// depscan#23630 (deployed 2026-08-02) made the compact-index routes
// fail closed: they 404 with `{"error":"not_built"}` until the
// requeued gem-package rebuild populates `package_gem_index_deps`,
// and bundler's /api/v1/dependencies fallback then gets HTTP 200
// with a ZERO-byte body, so unmarshalling dies. Same server defect
// saga, new signature — and the exact error text depends on the
// bundler generation: classic Marshal (bundler 2.x) raises
// `ArgumentError: marshal data too short`, while SafeMarshal
// (ruby 3.4+/bundler 4) raises `NoMethodError: undefined method
// 'bytes' for nil` reading the empty header ("bytes' for nil"
// matches both the old backtick and new ASCII-quote rubies). The
// conjunction with the dependency-api retry line is required so a
// generic marshal/corruption error from any other source cannot
// hide behind this branch.
|| (detail.contains("Retrying dependency api due to error")
&& (detail.contains("marshal data too short")
|| detail.contains("bytes' for nil")));
assert!(
!gem_strict,
"{LEG}: SOCKET_PATCH_HOSTED_E2E_GEM_STRICT=1 and `bundle install` from \
Expand All @@ -1373,11 +1401,13 @@ fn gem_bundler_hosted_redirect_and_known_install_defect() {
This is a new regression:\n{detail}"
);
println!(
"KNOWN PRODUCTION DEFECT {LEG}: the Socket gem patch-registry compact \
index omits runtime dependencies, so bundler refuses the download \
(APIResponseMismatchError). Hosted gem mode is unusable for gems with \
dependencies until the server emits them. Redirect assertions above \
all passed."
"KNOWN PRODUCTION DEFECT {LEG}: the Socket gem patch-registry either \
omits runtime dependencies from the compact index \
(APIResponseMismatchError) or, since depscan#23630, 404s the \
compact-index routes as not_built and serves an empty body from the \
dependency-API fallback (marshal data too short). Hosted gem mode is \
unusable for gems with dependencies until the registry rebuild \
completes. Redirect assertions above all passed."
);
}

Expand Down
52 changes: 35 additions & 17 deletions crates/socket-patch-cli/tests/e2e_npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ use std::process::{Command, Output};

use sha2::{Digest, Sha256};

#[path = "common/cache_env.rs"]
mod cache_env;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
Expand All @@ -40,8 +43,10 @@ fn binary() -> PathBuf {
}

fn has_command(cmd: &str) -> bool {
Command::new(cmd)
.arg("--version")
let mut probe = Command::new(cmd);
probe.arg("--version");
cache_env::isolate(&mut probe);
probe
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
Expand Down Expand Up @@ -83,6 +88,18 @@ fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) {
cmd.env_remove(&key);
}
}
// Partial cache isolation only — the global auto-discovery test below
// needs the binary's `npm root -g` / `yarn global dir` / `pnpm root -g`
// probes to resolve the REAL prefixes, which come out of $HOME, so a full
// cache_env::isolate() would defeat the test's purpose. These two pins
// cannot change a prefix answer; they only keep corepack's shim-triggered
// package-manager downloads and npm's cache/debug-logs out of the real
// home (same trade-off as global_packages_e2e.rs).
cmd.env("COREPACK_HOME", cache_env::override_path("COREPACK_HOME"));
cmd.env(
"npm_config_cache",
cache_env::override_path("npm_config_cache"),
);
let out: Output = cmd.output().expect("failed to execute socket-patch binary");

let code = out.status.code().unwrap_or(-1);
Expand All @@ -101,11 +118,10 @@ fn assert_run_ok(cwd: &Path, args: &[&str], context: &str) -> (String, String) {
}

fn npm_run(cwd: &Path, args: &[&str]) {
let out = Command::new("npm")
.args(args)
.current_dir(cwd)
.output()
.expect("failed to run npm");
let mut cmd = Command::new("npm");
cmd.args(args).current_dir(cwd);
cache_env::isolate(&mut cmd);
let out = cmd.output().expect("failed to run npm");
assert!(
out.status.success(),
"npm {args:?} failed (exit {:?}).\nstdout:\n{}\nstderr:\n{}",
Expand Down Expand Up @@ -337,16 +353,18 @@ fn test_npm_global_lifecycle() {
let cwd = cwd_dir.path();

// -- Setup: install minimist@1.2.2 globally into a temp prefix ----------
let out = Command::new("npm")
.args([
"install",
"-g",
"--prefix",
global_dir.path().to_str().unwrap(),
"minimist@1.2.2",
])
.output()
.expect("failed to run npm install -g");
let mut cmd = Command::new("npm");
cmd.args([
"install",
"-g",
"--prefix",
global_dir.path().to_str().unwrap(),
"minimist@1.2.2",
]);
// `--prefix` is a flag, so it still decides where the package lands; the
// sandbox only moves the download cache off the caller's home.
cache_env::isolate(&mut cmd);
let out = cmd.output().expect("failed to run npm install -g");
assert!(
out.status.success(),
"npm install -g failed.\nstdout:\n{}\nstderr:\n{}",
Expand Down
64 changes: 34 additions & 30 deletions crates/socket-patch-cli/tests/e2e_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ use std::process::{Command, Output};
use sha2::{Digest, Sha256};
use socket_patch_cli::args::{GLOBAL_ARG_ENV_VARS, LOCAL_ARG_ENV_VARS};

#[path = "common/cache_env.rs"]
mod cache_env;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -144,11 +147,10 @@ fn find_site_packages(cwd: &Path) -> PathBuf {

/// Create a venv and install pydantic-ai (without transitive deps for speed).
fn setup_venv(cwd: &Path) {
let status = Command::new("python3")
.args(["-m", "venv", ".venv"])
.current_dir(cwd)
.status()
.expect("failed to create venv");
let mut cmd = Command::new("python3");
cmd.args(["-m", "venv", ".venv"]).current_dir(cwd);
cache_env::isolate(&mut cmd);
let status = cmd.status().expect("failed to create venv");
assert!(status.success(), "python3 -m venv failed");

let pip = if cfg!(windows) {
Expand All @@ -160,17 +162,17 @@ fn setup_venv(cwd: &Path) {
// Install both the meta-package (for dist-info that matches the PURL)
// and the slim package (for the actual Python source files).
// --no-deps keeps the install fast by skipping transitive dependencies.
let out = Command::new(&pip)
.args([
"install",
"--no-deps",
"--disable-pip-version-check",
"pydantic-ai==0.0.36",
"pydantic-ai-slim==0.0.36",
])
.current_dir(cwd)
.output()
.expect("failed to run pip install");
let mut cmd = Command::new(&pip);
cmd.args([
"install",
"--no-deps",
"--disable-pip-version-check",
"pydantic-ai==0.0.36",
"pydantic-ai-slim==0.0.36",
])
.current_dir(cwd);
cache_env::isolate(&mut cmd);
let out = cmd.output().expect("failed to run pip install");
assert!(
out.status.success(),
"pip install failed.\nstdout:\n{}\nstderr:\n{}",
Expand Down Expand Up @@ -526,20 +528,22 @@ fn test_pypi_global_lifecycle() {
let cwd = cwd_dir.path();

// -- Setup: pip install --target into global_dir -------------------------
let out = Command::new("python3")
.args([
"-m",
"pip",
"install",
"--target",
global_dir.path().to_str().unwrap(),
"--no-deps",
"--disable-pip-version-check",
"pydantic-ai==0.0.36",
"pydantic-ai-slim==0.0.36",
])
.output()
.expect("failed to run pip install --target");
let mut cmd = Command::new("python3");
cmd.args([
"-m",
"pip",
"install",
"--target",
global_dir.path().to_str().unwrap(),
"--no-deps",
"--disable-pip-version-check",
"pydantic-ai==0.0.36",
"pydantic-ai-slim==0.0.36",
]);
// `--target` is a flag, so the packages still land in the temp dir the
// test asserts against; the sandbox only moves pip's cache.
cache_env::isolate(&mut cmd);
let out = cmd.output().expect("failed to run pip install --target");
assert!(
out.status.success(),
"pip install --target failed.\nstdout:\n{}\nstderr:\n{}",
Expand Down
Loading
Loading