From b3943f4863e3681812f4c46b942999cc2e5c0c17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 04:57:46 +0000 Subject: [PATCH 1/2] Start #1968: one docker test harness Assisted-by: Claude Code:claude-opus-5-5 From 22c76cc86f38c0d8f4e0be42971dbcda0fb31a87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 05:03:31 +0000 Subject: [PATCH 2/2] Share one docker test harness Add tests/docker_common as the one home for the container plumbing every docker suite copied: cov_docker_args (with sbt's SOCKET_PATCH_DOCKER_BIN branch), skip_if_no_image / skip_or_require_image, run_container and assert_vex_agent_attested. Delete the copies in the 10 docker_e2e agent suites and docker_vendor_common, the plain_sha256/sha256_hex copies (common has sha256_hex), deno's SOCKET_PATCH_REQUIRE_DOCKER gate (set nowhere), the dead run_in_image and npm's unused Duration import. Every agent suite's skip now honours SOCKET_PATCH_DOCKER_E2E_REQUIRED like the sbt and vendor suites. CI sets it only on legs whose images it builds, so no CI leg changes outcome. Refs #1968 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/docker_common/mod.rs | 128 +++++++++++++++ .../tests/docker_e2e_cargo.rs | 113 +------------ .../tests/docker_e2e_composer.rs | 127 ++------------- .../socket-patch-cli/tests/docker_e2e_deno.rs | 84 +--------- .../socket-patch-cli/tests/docker_e2e_gem.rs | 129 ++------------- .../tests/docker_e2e_golang.rs | 109 +------------ .../tests/docker_e2e_maven.rs | 109 +------------ .../socket-patch-cli/tests/docker_e2e_npm.rs | 148 +++--------------- .../tests/docker_e2e_nuget.rs | 126 ++------------- .../socket-patch-cli/tests/docker_e2e_pypi.rs | 141 ++--------------- .../socket-patch-cli/tests/docker_e2e_sbt.rs | 51 +----- .../tests/docker_e2e_vendor_composer.rs | 5 +- .../tests/docker_e2e_vendor_gem.rs | 5 +- .../tests/docker_e2e_vendor_maven.rs | 5 +- .../tests/docker_e2e_vendor_nuget.rs | 5 +- .../tests/docker_e2e_vendor_pypi_pm.rs | 5 +- .../tests/docker_vendor_common/mod.rs | 78 +-------- .../vendor/docker_vendor_common_selftest.rs | 3 +- crates/socket-patch-cli/tests/vendor/main.rs | 2 + 19 files changed, 260 insertions(+), 1113 deletions(-) create mode 100644 crates/socket-patch-cli/tests/docker_common/mod.rs diff --git a/crates/socket-patch-cli/tests/docker_common/mod.rs b/crates/socket-patch-cli/tests/docker_common/mod.rs new file mode 100644 index 000000000..ae1caaff5 --- /dev/null +++ b/crates/socket-patch-cli/tests/docker_common/mod.rs @@ -0,0 +1,128 @@ +//! Container plumbing shared by every `docker_e2e_*` suite. Include it with +//! `#[path = "docker_common/mod.rs"] mod docker_common;` (suites that also +//! include `docker_vendor_common` need both: that module builds on this one). +//! +//! `#![allow(dead_code)]`: each suite uses a different subset. + +#![allow(dead_code)] + +use std::process::{Command, Output}; + +/// Docker args that shadow the image's baked-in socket-patch. With +/// `SOCKET_PATCH_DOCKER_BIN` set, that host binary is mounted (a local Linux +/// build). Otherwise the CI coverage-docker job sets `SOCKET_PATCH_COV_BIN` +/// (an llvm-cov-instrumented binary) and `SOCKET_PATCH_COV_PROFRAW_DIR` (where +/// the in-container profraws land). Locally all are unset and this is empty. +pub fn cov_docker_args() -> Vec { + if let Ok(bin) = std::env::var("SOCKET_PATCH_DOCKER_BIN") { + return vec!["-v".into(), format!("{bin}:/usr/local/bin/socket-patch:ro")]; + } + let (Ok(bin), Ok(dir)) = ( + std::env::var("SOCKET_PATCH_COV_BIN"), + std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR"), + ) else { + return Vec::new(); + }; + vec![ + "-v".into(), + format!("{bin}:/usr/local/bin/socket-patch:ro"), + "-v".into(), + format!("{dir}:/coverage"), + "-e".into(), + "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), + ] +} + +/// Returns `true` when the test should skip: `docker` missing from PATH or +/// `image` not built. Prints a skip notice, because Rust integration tests +/// have no native "skipped" outcome and the test then reports `ok`. Build +/// locally with `docker build -f tests/docker/Dockerfile.base -t +/// socket-patch-test-base:latest .`, then `docker build -f +/// tests/docker/Dockerfile. -t socket-patch-test-:latest .`. +/// +/// With `SOCKET_PATCH_DOCKER_E2E_REQUIRED=1` (a CI job that built the image +/// itself) a missing docker or image fails the test instead, so a tag +/// mismatch cannot pass the job with zero assertions. +#[must_use] +pub fn skip_if_no_image(image: &str) -> bool { + skip_or_require_image( + image, + std::env::var("SOCKET_PATCH_DOCKER_E2E_REQUIRED").is_ok_and(|v| v == "1"), + ) +} + +/// [`skip_if_no_image`] with the required switch passed in. +#[must_use] +pub fn skip_or_require_image(image: &str, required: bool) -> bool { + let reason = match Command::new("docker") + .args(["image", "inspect", image]) + .output() + { + Err(_) => "`docker` not on PATH".to_string(), + Ok(out) if !out.status.success() => format!("docker image `{image}` not present"), + Ok(_) => return false, + }; + assert!( + !required, + "{reason}; SOCKET_PATCH_DOCKER_E2E_REQUIRED=1 forbids skipping" + ); + eprintln!("skipping: {reason}"); + true +} + +/// Run `script` (bash) in a throwaway `image` container that can reach a +/// host mock server at `host.docker.internal`. +pub fn run_container(image: &str, script: &str) -> Output { + Command::new("docker") + .args([ + "run", + "--rm", + "--add-host=host.docker.internal:host-gateway", + "-i", + ]) + .args(cov_docker_args()) + .args([image, "bash", "-c", script]) + .output() + .expect("docker run") +} + +/// Host-side oracle over the VEX document the container printed between +/// `===VEX DOC BEGIN===` and `===VEX DOC END===` (the agent suites keep the +/// workspace inside the container, so the doc comes from captured stdout). +/// Asserts exactly one `not_affected` statement for `ghsa` on the +/// `subcomponent_purl`, with a PLAIN impact statement: no `(vendored)` or +/// `(redirected)` marker, which is what distinguishes agent provenance. +pub fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str, ghsa: &str) { + const BEGIN: &str = "===VEX DOC BEGIN==="; + const END: &str = "===VEX DOC END==="; + let start = stdout + .find(BEGIN) + .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) + + BEGIN.len(); + let stop = stdout[start..] + .find(END) + .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) + + start; + let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) + .expect("emitted VEX document must be valid JSON"); + let stmts = doc["statements"] + .as_array() + .expect("VEX document must have a statements array"); + assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); + let st = &stmts[0]; + assert_eq!(st["vulnerability"]["name"], ghsa, "attested GHSA mismatch"); + assert_eq!(st["status"], "not_affected"); + assert_eq!( + st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, + "subcomponent must be the patched package purl" + ); + let impact = st["impact_statement"] + .as_str() + .expect("statement must carry an impact_statement"); + assert!( + impact.contains("Patched via Socket patch") + && !impact.contains("(vendored)") + && !impact.contains("(redirected)"), + "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" + ); +} diff --git a/crates/socket-patch-cli/tests/docker_e2e_cargo.rs b/crates/socket-patch-cli/tests/docker_e2e_cargo.rs index d0de4b2f2..4a8df6b3c 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_cargo.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_cargo.rs @@ -10,14 +10,16 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; - -use std::process::Command; +use docker_common::{assert_vex_agent_attested, run_container, skip_if_no_image}; use base64::Engine; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-cargo:latest"; const ORG: &str = "test-org"; const PURL: &str = "pkg:cargo/cfg-if@1.0.0"; const UUID: &str = "14141414-1414-4141-8141-141414141414"; @@ -31,26 +33,6 @@ const PATCHED_RS: &[u8] = b"// SOCKET-PATCH-E2E-MARKER\n\ #[macro_export]\n\ macro_rules! cfg_if {\n ($($t:tt)*) => {};\n}\n"; -/// See docker_e2e_npm.rs::cov_docker_args for the coverage hook -/// semantics. The CI coverage-docker job sets the env vars; locally -/// they're unset and this returns an empty Vec. -fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - async fn make_mock_server(after_hash: &str) -> MockServer { let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; @@ -324,96 +306,15 @@ exit 0 ) } -/// Host-side oracle over the VEX document the container emitted between the -/// `===VEX DOC BEGIN===` / `===VEX DOC END===` markers (these agent suites run -/// the workspace inside the container with no bind mount, so the doc is parsed -/// from captured stdout). Asserts exactly one statement attesting the agent -/// patch: the fixture GHSA, `not_affected`, the installed-package subcomponent -/// purl, and a PLAIN impact statement with NO `(vendored)`/`(redirected)` -/// marker — the marker's absence is what distinguishes agent provenance. -fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str) { - const BEGIN: &str = "===VEX DOC BEGIN==="; - const END: &str = "===VEX DOC END==="; - let start = stdout - .find(BEGIN) - .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) - + BEGIN.len(); - let stop = stdout[start..] - .find(END) - .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) - + start; - let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) - .expect("emitted VEX document must be valid JSON"); - let stmts = doc["statements"] - .as_array() - .expect("VEX document must have a statements array"); - assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); - let st = &stmts[0]; - assert_eq!(st["vulnerability"]["name"], GHSA, "attested GHSA mismatch"); - assert_eq!(st["status"], "not_affected"); - assert_eq!( - st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, - "subcomponent must be the patched package purl" - ); - let impact = st["impact_statement"] - .as_str() - .expect("statement must carry an impact_statement"); - assert!( - impact.contains("Patched via Socket patch") - && !impact.contains("(vendored)") - && !impact.contains("(redirected)"), - "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" - ); -} - -/// Returns `true` when the test should skip (docker missing, image -/// missing). Prints a skip notice to stderr in that case so the test -/// log shows *why* the test did nothing — the test still reports as -/// `ok` because Rust integration tests have no native "skipped" outcome. -/// -/// Local devs: build the image with -/// `docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest .` -/// then -/// `docker build -f tests/docker/Dockerfile.cargo -t socket-patch-test-cargo:latest .` -#[must_use] -fn skip_if_no_image() -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-cargo:latest"]) - .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `socket-patch-test-cargo:latest` not present"); - return true; - } - false -} - #[tokio::test] async fn cargo_fetch_full_apply_chain() { let after_hash = git_sha256(PATCHED_RS); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args([ - "socket-patch-test-cargo:latest", - "bash", - "-c", - &local_script(&api_url, &after_hash), - ]); - let out = cmd.output().expect("docker run"); + let out = run_container(IMAGE, &local_script(&api_url, &after_hash)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -429,7 +330,7 @@ async fn cargo_fetch_full_apply_chain() { stderr.contains("===VEX VERIFIED==="), "agent-mode VEX leg did not run/pass (===VEX VERIFIED=== missing).\nstderr=\n{stderr}" ); - assert_vex_agent_attested(&stdout, PURL); + assert_vex_agent_attested(&stdout, PURL, GHSA); // The script gates on an exact git-blob-hash match; confirm the // expected hash actually appears in the log so a future edit that diff --git a/crates/socket-patch-cli/tests/docker_e2e_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_composer.rs index 0a165dde1..459de7fe7 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_composer.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_composer.rs @@ -13,15 +13,16 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; - -use std::process::Command; +use docker_common::{assert_vex_agent_attested, run_container, skip_if_no_image}; use base64::Engine; -use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-composer:latest"; const ORG: &str = "test-org"; const PURL: &str = "pkg:composer/monolog/monolog@3.5.0"; const UUID: &str = "17171717-1717-4171-8171-171717171717"; @@ -36,36 +37,6 @@ const PATCHED_PHP: &[u8] = b" Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - -/// Plain SHA-256 of the bytes (no git blob header) — matches what -/// `sha256sum` reports inside the container, so the test can assert the -/// installed file is byte-identical to the patch blob, not merely that -/// it contains the marker substring. -fn plain_sha256(content: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(content); - hex::encode(hasher.finalize()) -} - /// Shared verification block for both scripts. Expects `PHP_FILE`, /// `EXPECTED_SHA`, `PRE_SHA`, and `APPLY_EXIT` to be set, plus the JSON /// captured in `/tmp/scan.json` and `/tmp/apply.json`. @@ -346,82 +317,6 @@ cat /tmp/apply.err >&2 ) } -/// Returns `true` when the test should skip (docker missing, image -/// missing). Prints a skip notice to stderr — the test still reports -/// as `ok` because Rust integration tests have no native "skipped" -/// outcome. Build locally with -/// `docker build -f tests/docker/Dockerfile.composer -t socket-patch-test-composer:latest .` -#[must_use] -fn skip_if_no_image() -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-composer:latest"]) - .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `socket-patch-test-composer:latest` not present"); - return true; - } - false -} - -fn run_container(script: &str) -> std::process::Output { - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args(["socket-patch-test-composer:latest", "bash", "-c", script]); - cmd.output().expect("docker run") -} - -/// Host-side oracle over the VEX document the container emitted between the -/// `===VEX DOC BEGIN===` / `===VEX DOC END===` markers (these agent suites run -/// the workspace inside the container with no bind mount, so the doc is parsed -/// from captured stdout). Asserts exactly one statement attesting the agent -/// patch: the fixture GHSA, `not_affected`, the installed-package subcomponent -/// purl, and a PLAIN impact statement with NO `(vendored)`/`(redirected)` -/// marker — the marker's absence is what distinguishes agent provenance. -fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str) { - const BEGIN: &str = "===VEX DOC BEGIN==="; - const END: &str = "===VEX DOC END==="; - let start = stdout - .find(BEGIN) - .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) - + BEGIN.len(); - let stop = stdout[start..] - .find(END) - .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) - + start; - let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) - .expect("emitted VEX document must be valid JSON"); - let stmts = doc["statements"] - .as_array() - .expect("VEX document must have a statements array"); - assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); - let st = &stmts[0]; - assert_eq!(st["vulnerability"]["name"], GHSA, "attested GHSA mismatch"); - assert_eq!(st["status"], "not_affected"); - assert_eq!( - st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, - "subcomponent must be the patched package purl" - ); - let impact = st["impact_statement"] - .as_str() - .expect("statement must carry an impact_statement"); - assert!( - impact.contains("Patched via Socket patch") - && !impact.contains("(vendored)") - && !impact.contains("(redirected)"), - "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" - ); -} - /// Independent (Rust-side) proof that the container exercised the real /// scan→sync network path against our mock — not a pre-baked/cached patch /// store. `scan --sync` must POST batch discovery and GET the full patch @@ -450,11 +345,11 @@ async fn composer_local_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_PHP); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let expected_sha = plain_sha256(PATCHED_PHP); - let out = run_container(&local_script(&api_url, &expected_sha)); + let expected_sha = common::sha256_hex(PATCHED_PHP); + let out = run_container(IMAGE, &local_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -469,7 +364,7 @@ async fn composer_local_install_full_apply_chain() { stderr.contains("===VEX VERIFIED==="), "agent-mode VEX leg did not run/pass (===VEX VERIFIED=== missing).\nstderr=\n{stderr}" ); - assert_vex_agent_attested(&stdout, PURL); + assert_vex_agent_attested(&stdout, PURL, GHSA); assert!( stderr.contains("===MANIFESTLESS AGENT VEX VERIFIED==="), "manifest-less agent-mode negative control did not run/pass.\nstderr=\n{stderr}" @@ -482,11 +377,11 @@ async fn composer_global_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_PHP); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let expected_sha = plain_sha256(PATCHED_PHP); - let out = run_container(&global_script(&api_url, &expected_sha)); + let expected_sha = common::sha256_hex(PATCHED_PHP); + let out = run_container(IMAGE, &global_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( diff --git a/crates/socket-patch-cli/tests/docker_e2e_deno.rs b/crates/socket-patch-cli/tests/docker_e2e_deno.rs index 1cadce993..81779e011 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_deno.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_deno.rs @@ -31,14 +31,16 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; - -use std::process::Command; +use docker_common::{run_container, skip_if_no_image}; use base64::Engine; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-deno:latest"; const ORG: &str = "test-org"; const NPM_PURL: &str = "pkg:npm/minimist@1.2.2"; const NPM_UUID: &str = "13131313-1313-4131-8131-131313131313"; @@ -48,27 +50,6 @@ const NPM_UUID: &str = "13131313-1313-4131-8131-131313131313"; const PATCHED_BYTES: &[u8] = b"/* SOCKET-PATCH-E2E-MARKER */\nmodule.exports = function () { return {}; };\n"; -/// Coverage instrumentation hook — same shape as every other docker -/// e2e test file. When `SOCKET_PATCH_COV_BIN` is set, mounts the -/// instrumented socket-patch binary into the container and pipes -/// profraw output back to a host-visible directory. -fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - /// Build the wiremock for the npm-via-deno-install variant. Same /// minimist fixture as `docker_e2e_npm.rs`; we duplicate it here to /// keep this test file self-contained. @@ -487,65 +468,16 @@ fn deno_jsr_script_pins_scan_to_the_mock_api() { ); } -/// Returns `true` when the test must skip because the docker image is -/// absent. Rust integration tests have no native "skipped" outcome, so a -/// missing image silently makes the whole test vacuous — that is itself a -/// loophole. To make the skip auditable, set `SOCKET_PATCH_REQUIRE_DOCKER=1` -/// (CI does this): the helper then PANICS instead of skipping, so a green -/// run proves the assertions actually executed rather than no-op'd. -#[must_use] -fn skip_if_no_image() -> bool { - let require = std::env::var("SOCKET_PATCH_REQUIRE_DOCKER") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - let out = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-deno:latest"]) - .output(); - match out { - Ok(o) if o.status.success() => false, - Ok(_) => { - assert!( - !require, - "SOCKET_PATCH_REQUIRE_DOCKER=1 but image \ - `socket-patch-test-deno:latest` is not present" - ); - eprintln!("skipping: docker image `socket-patch-test-deno:latest` not present"); - true - } - Err(_) => { - assert!( - !require, - "SOCKET_PATCH_REQUIRE_DOCKER=1 but `docker` is not on PATH" - ); - eprintln!("skipping: `docker` not on PATH"); - true - } - } -} - -fn run_container(script: &str) -> std::process::Output { - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args(["socket-patch-test-deno:latest", "bash", "-c", script]); - cmd.output().expect("docker run") -} - #[tokio::test] async fn deno_install_node_modules_full_apply_chain() { let after_hash = git_sha256(PATCHED_BYTES); let server = make_npm_mock_server(&after_hash).await; let api_url = api_url_for_container(&server); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } let blob_b64 = base64::engine::general_purpose::STANDARD.encode(PATCHED_BYTES); - let out = run_container(&deno_node_modules_script(&api_url, &blob_b64)); + let out = run_container(IMAGE, &deno_node_modules_script(&api_url, &blob_b64)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -582,10 +514,10 @@ async fn deno_install_node_modules_full_apply_chain() { async fn deno_jsr_synthetic_layout_scan_verifies_discovery() { let server = make_empty_batch_mock_server().await; let api_url = api_url_for_container(&server); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let out = run_container(&deno_jsr_script(&api_url)); + let out = run_container(IMAGE, &deno_jsr_script(&api_url)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( diff --git a/crates/socket-patch-cli/tests/docker_e2e_gem.rs b/crates/socket-patch-cli/tests/docker_e2e_gem.rs index 68ce3289f..4e28d8eb0 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_gem.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_gem.rs @@ -21,15 +21,16 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; - -use std::process::Command; +use docker_common::{assert_vex_agent_attested, run_container, skip_if_no_image}; use base64::Engine; -use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-gem:latest"; const ORG: &str = "test-org"; const PURL: &str = "pkg:gem/colorize@1.1.0"; const UUID: &str = "13131313-1313-4131-8131-131313131313"; @@ -42,36 +43,6 @@ const PATCHED_RB: &[u8] = b"# SOCKET-PATCH-E2E-MARKER\n\ # colorize.rb replaced by socket-patch e2e fixture\n\ module Colorize\n VERSION = '1.1.0-patched'\nend\n"; -/// See docker_e2e_npm.rs::cov_docker_args for the coverage hook -/// semantics. The CI coverage-docker job sets the env vars; locally -/// they're unset and this returns an empty Vec. -fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - -/// Plain SHA-256 of the bytes (no git blob header) — matches what -/// `sha256sum` reports inside the container, so the test can assert the -/// installed file is byte-identical to the patch blob, not merely that -/// it contains the marker substring. -fn plain_sha256(content: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(content); - hex::encode(hasher.finalize()) -} - /// Probe: install colorize 1.1.0 from the real registry and emit the /// git-blob sha256 of the exact `lib/colorize.rb` bytes `gem install` /// lays down — the value the fixture must serve as `beforeHash` for the @@ -96,7 +67,7 @@ F=/tmp/probe/gems/colorize-1.1.0/lib/colorize.rb fn upstream_before_hash() -> String { static HASH: std::sync::OnceLock = std::sync::OnceLock::new(); HASH.get_or_init(|| { - let out = run_container(BEFORE_HASH_PROBE_SCRIPT); + let out = run_container(IMAGE, BEFORE_HASH_PROBE_SCRIPT); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -376,82 +347,6 @@ cat /tmp/apply.err >&2 ) } -/// Returns `true` when the test should skip (docker missing, image -/// missing). Prints a skip notice to stderr — the test still reports -/// as `ok` because Rust integration tests have no native "skipped" -/// outcome. Build locally with -/// `docker build -f tests/docker/Dockerfile.gem -t socket-patch-test-gem:latest .` -#[must_use] -fn skip_if_no_image() -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-gem:latest"]) - .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `socket-patch-test-gem:latest` not present"); - return true; - } - false -} - -fn run_container(script: &str) -> std::process::Output { - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args(["socket-patch-test-gem:latest", "bash", "-c", script]); - cmd.output().expect("docker run") -} - -/// Host-side oracle over the VEX document the container emitted between the -/// `===VEX DOC BEGIN===` / `===VEX DOC END===` markers (these agent suites run -/// the workspace inside the container with no bind mount, so the doc is parsed -/// from captured stdout). Asserts exactly one statement attesting the agent -/// patch: the fixture GHSA, `not_affected`, the installed-package subcomponent -/// purl, and a PLAIN impact statement with NO `(vendored)`/`(redirected)` -/// marker — the marker's absence is what distinguishes agent provenance. -fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str) { - const BEGIN: &str = "===VEX DOC BEGIN==="; - const END: &str = "===VEX DOC END==="; - let start = stdout - .find(BEGIN) - .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) - + BEGIN.len(); - let stop = stdout[start..] - .find(END) - .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) - + start; - let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) - .expect("emitted VEX document must be valid JSON"); - let stmts = doc["statements"] - .as_array() - .expect("VEX document must have a statements array"); - assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); - let st = &stmts[0]; - assert_eq!(st["vulnerability"]["name"], GHSA, "attested GHSA mismatch"); - assert_eq!(st["status"], "not_affected"); - assert_eq!( - st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, - "subcomponent must be the patched package purl" - ); - let impact = st["impact_statement"] - .as_str() - .expect("statement must carry an impact_statement"); - assert!( - impact.contains("Patched via Socket patch") - && !impact.contains("(vendored)") - && !impact.contains("(redirected)"), - "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" - ); -} - /// Assert the wiremock actually served BOTH the metadata discovery /// (batch) AND the patch-content fetch (view). The in-container `echo` /// markers alone can't prove the real network path ran — a build that @@ -474,15 +369,15 @@ async fn assert_api_path_exercised(server: &MockServer) { #[tokio::test] async fn gem_local_install_full_apply_chain() { - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } let before_hash = upstream_before_hash(); let after_hash = git_sha256(PATCHED_RB); let server = make_mock_server(&before_hash, &after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - let expected_sha = plain_sha256(PATCHED_RB); - let out = run_container(&local_script(&api_url, &expected_sha)); + let expected_sha = common::sha256_hex(PATCHED_RB); + let out = run_container(IMAGE, &local_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -497,21 +392,21 @@ async fn gem_local_install_full_apply_chain() { stderr.contains("===VEX VERIFIED==="), "agent-mode VEX leg did not run/pass (===VEX VERIFIED=== missing).\nstderr=\n{stderr}" ); - assert_vex_agent_attested(&stdout, PURL); + assert_vex_agent_attested(&stdout, PURL, GHSA); assert_api_path_exercised(&server).await; } #[tokio::test] async fn gem_global_install_full_apply_chain() { - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } let before_hash = upstream_before_hash(); let after_hash = git_sha256(PATCHED_RB); let server = make_mock_server(&before_hash, &after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - let expected_sha = plain_sha256(PATCHED_RB); - let out = run_container(&global_script(&api_url, &expected_sha)); + let expected_sha = common::sha256_hex(PATCHED_RB); + let out = run_container(IMAGE, &global_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( diff --git a/crates/socket-patch-cli/tests/docker_e2e_golang.rs b/crates/socket-patch-cli/tests/docker_e2e_golang.rs index 0ecc085f7..27f877b71 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_golang.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_golang.rs @@ -9,14 +9,16 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; - -use std::process::Command; +use docker_common::{assert_vex_agent_attested, run_container, skip_if_no_image}; use base64::Engine; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-golang:latest"; const ORG: &str = "test-org"; const PURL: &str = "pkg:golang/github.com/gin-gonic/gin@v1.9.1"; const UUID: &str = "15151515-1515-4151-8151-151515151515"; @@ -29,26 +31,6 @@ const PATCHED_GO: &[u8] = b"// SOCKET-PATCH-E2E-MARKER\n\ // gin.go replaced by socket-patch e2e fixture\n\ package gin\n\nconst Version = \"v1.9.1-patched\"\n"; -/// See docker_e2e_npm.rs::cov_docker_args for the coverage hook -/// semantics. The CI coverage-docker job sets the env vars; locally -/// they're unset and this returns an empty Vec. -fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - async fn make_mock_server(after_hash: &str) -> MockServer { let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; @@ -296,92 +278,15 @@ exit 0 ) } -/// Host-side oracle over the VEX document the container emitted between the -/// `===VEX DOC BEGIN===` / `===VEX DOC END===` markers (these agent suites run -/// the workspace inside the container with no bind mount, so the doc is parsed -/// from captured stdout). Asserts exactly one statement attesting the agent -/// patch: the fixture GHSA, `not_affected`, the installed-package subcomponent -/// purl, and a PLAIN impact statement with NO `(vendored)`/`(redirected)` -/// marker — the marker's absence is what distinguishes agent provenance. -fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str) { - const BEGIN: &str = "===VEX DOC BEGIN==="; - const END: &str = "===VEX DOC END==="; - let start = stdout - .find(BEGIN) - .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) - + BEGIN.len(); - let stop = stdout[start..] - .find(END) - .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) - + start; - let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) - .expect("emitted VEX document must be valid JSON"); - let stmts = doc["statements"] - .as_array() - .expect("VEX document must have a statements array"); - assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); - let st = &stmts[0]; - assert_eq!(st["vulnerability"]["name"], GHSA, "attested GHSA mismatch"); - assert_eq!(st["status"], "not_affected"); - assert_eq!( - st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, - "subcomponent must be the patched package purl" - ); - let impact = st["impact_statement"] - .as_str() - .expect("statement must carry an impact_statement"); - assert!( - impact.contains("Patched via Socket patch") - && !impact.contains("(vendored)") - && !impact.contains("(redirected)"), - "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" - ); -} - -/// Returns `true` when the test should skip (docker missing, image -/// missing). Prints a skip notice to stderr — the test still reports -/// as `ok` because Rust integration tests have no native "skipped" -/// outcome. Build locally with -/// `docker build -f tests/docker/Dockerfile.golang -t socket-patch-test-golang:latest .` -#[must_use] -fn skip_if_no_image() -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-golang:latest"]) - .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `socket-patch-test-golang:latest` not present"); - return true; - } - false -} - #[tokio::test] async fn golang_download_full_apply_chain() { let after_hash = git_sha256(PATCHED_GO); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args([ - "socket-patch-test-golang:latest", - "bash", - "-c", - &local_script(&api_url, &after_hash), - ]); - let out = cmd.output().expect("docker run"); + let out = run_container(IMAGE, &local_script(&api_url, &after_hash)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -397,7 +302,7 @@ async fn golang_download_full_apply_chain() { stderr.contains("===VEX VERIFIED==="), "agent-mode VEX leg did not run/pass (===VEX VERIFIED=== missing).\nstderr=\n{stderr}" ); - assert_vex_agent_attested(&stdout, PURL); + assert_vex_agent_attested(&stdout, PURL, GHSA); // The script gates on an exact git-blob-hash match; confirm the // expected hash actually appears in the log so a future edit that diff --git a/crates/socket-patch-cli/tests/docker_e2e_maven.rs b/crates/socket-patch-cli/tests/docker_e2e_maven.rs index 0bdd23a69..37ae89099 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_maven.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_maven.rs @@ -13,14 +13,16 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; - -use std::process::Command; +use docker_common::{assert_vex_agent_attested, run_container, skip_if_no_image}; use base64::Engine; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-maven:latest"; const ORG: &str = "test-org"; const PURL: &str = "pkg:maven/org.apache.commons/commons-lang3@3.12.0"; const UUID: &str = "16161616-1616-4161-8161-161616161616"; @@ -41,26 +43,6 @@ const PATCHED_POM: &[u8] = b"\n\ 3.12.0-patched\n\ \n"; -/// See docker_e2e_npm.rs::cov_docker_args for the coverage hook -/// semantics. The CI coverage-docker job sets the env vars; locally -/// they're unset and this returns an empty Vec. -fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - async fn make_mock_server(after_hash: &str) -> MockServer { let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; @@ -352,92 +334,15 @@ exit 0 ) } -/// Host-side oracle over the VEX document the container emitted between the -/// `===VEX DOC BEGIN===` / `===VEX DOC END===` markers (these agent suites run -/// the workspace inside the container with no bind mount, so the doc is parsed -/// from captured stdout). Asserts exactly one statement attesting the agent -/// patch: the fixture GHSA, `not_affected`, the installed-package subcomponent -/// purl, and a PLAIN impact statement with NO `(vendored)`/`(redirected)` -/// marker — the marker's absence is what distinguishes agent provenance. -fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str) { - const BEGIN: &str = "===VEX DOC BEGIN==="; - const END: &str = "===VEX DOC END==="; - let start = stdout - .find(BEGIN) - .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) - + BEGIN.len(); - let stop = stdout[start..] - .find(END) - .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) - + start; - let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) - .expect("emitted VEX document must be valid JSON"); - let stmts = doc["statements"] - .as_array() - .expect("VEX document must have a statements array"); - assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); - let st = &stmts[0]; - assert_eq!(st["vulnerability"]["name"], GHSA, "attested GHSA mismatch"); - assert_eq!(st["status"], "not_affected"); - assert_eq!( - st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, - "subcomponent must be the patched package purl" - ); - let impact = st["impact_statement"] - .as_str() - .expect("statement must carry an impact_statement"); - assert!( - impact.contains("Patched via Socket patch") - && !impact.contains("(vendored)") - && !impact.contains("(redirected)"), - "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" - ); -} - -/// Returns `true` when the test should skip (docker missing, image -/// missing). Prints a skip notice to stderr — the test still reports -/// as `ok` because Rust integration tests have no native "skipped" -/// outcome. Build locally with -/// `docker build -f tests/docker/Dockerfile.maven -t socket-patch-test-maven:latest .` -#[must_use] -fn skip_if_no_image() -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-maven:latest"]) - .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `socket-patch-test-maven:latest` not present"); - return true; - } - false -} - #[tokio::test] async fn maven_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_POM); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args([ - "socket-patch-test-maven:latest", - "bash", - "-c", - &local_script(&api_url, &after_hash), - ]); - let out = cmd.output().expect("docker run"); + let out = run_container(IMAGE, &local_script(&api_url, &after_hash)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -453,7 +358,7 @@ async fn maven_install_full_apply_chain() { stderr.contains("===VEX VERIFIED==="), "agent-mode VEX leg did not run/pass (===VEX VERIFIED=== missing).\nstderr=\n{stderr}" ); - assert_vex_agent_attested(&stdout, PURL); + assert_vex_agent_attested(&stdout, PURL, GHSA); // The script gates on an exact git-blob-hash match; confirm the // expected hash actually appears in the log so a future edit that diff --git a/crates/socket-patch-cli/tests/docker_e2e_npm.rs b/crates/socket-patch-cli/tests/docker_e2e_npm.rs index 451f05a22..f78a1b835 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_npm.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_npm.rs @@ -27,7 +27,10 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; +use docker_common::{assert_vex_agent_attested, run_container, skip_if_no_image}; #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; @@ -35,11 +38,11 @@ mod docker_vendor_common; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::Command; -use std::time::Duration; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-npm:latest"; const ORG: &str = "test-org"; const PURL: &str = "pkg:npm/minimist@1.2.2"; const UUID: &str = "11111111-1111-4111-8111-111111111111"; @@ -53,32 +56,6 @@ const GHSA: &str = "GHSA-agent-npm-real"; const PATCHED_BYTES: &[u8] = b"/* SOCKET-PATCH-E2E-MARKER */\nmodule.exports = function () { return {}; };\n"; -/// Coverage instrumentation hook. The CI coverage-docker job sets -/// SOCKET_PATCH_COV_BIN (host path to an llvm-cov-instrumented -/// socket-patch binary) and SOCKET_PATCH_COV_PROFRAW_DIR (host dir -/// for in-container *.profraw output). When both are set, the docker -/// run mounts the instrumented binary over the image's baked-in -/// /usr/local/bin/socket-patch and points LLVM_PROFILE_FILE into a -/// host-visible volume so the in-container code paths contribute to -/// the host's lcov merge. Empty Vec when unset → tests use the -/// image's stock binary. -fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - fn host_mode() -> bool { std::env::var("SOCKET_PATCH_TEST_HOST") .map(|v| v == "1") @@ -101,7 +78,7 @@ fn workspace_root() -> PathBuf { async fn make_mock_server(after_hash: &str) -> MockServer { // Bind to 0.0.0.0 so the container can reach the host via the // `host.docker.internal` alias (added with `--add-host` in - // `run_in_container`). Random port chosen by the kernel. + // `run_container`). Random port chosen by the kernel. let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock to 0.0.0.0:0"); let server = MockServer::builder().listener(listener).start().await; @@ -559,19 +536,6 @@ exit 0 ) } -fn run_in_container(script: &str) -> std::process::Output { - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args(["socket-patch-test-npm:latest", "bash", "-c", script]); - cmd.output().expect("docker run failed to spawn") -} - fn run_on_host(script: &str) -> std::process::Output { // Host mode: write the script to a tempfile under a fresh tmp workspace // and execute it. Requires npm + socket-patch on PATH. @@ -591,32 +555,6 @@ fn run_on_host(script: &str) -> std::process::Output { .expect("bash failed to spawn") } -/// Returns `true` when the test should skip (docker missing, image -/// missing). Prints a skip notice to stderr — the test still reports as -/// `ok` because Rust integration tests have no native "skipped" outcome. -/// -/// Note that npm e2e also supports `SOCKET_PATCH_TEST_HOST=1` (see -/// [`host_mode`]) to run the test against host toolchains instead of -/// Docker; that's checked independently in the test body before this -/// helper runs. -#[must_use] -fn skip_if_no_docker_image() -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-npm:latest"]) - .output() - else { - eprintln!( - "skipping: `docker` not on PATH (set SOCKET_PATCH_TEST_HOST=1 to run on the host)" - ); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `socket-patch-test-npm:latest` not present"); - return true; - } - false -} - #[tokio::test] async fn npm_install_scan_apply_rollback_cycle() { let after_hash = git_sha256(PATCHED_BYTES); @@ -626,11 +564,11 @@ async fn npm_install_scan_apply_rollback_cycle() { let api = format!("http://127.0.0.1:{}", server.address().port()); run_on_host(&make_container_script(&api)) } else { - if skip_if_no_docker_image() { + if skip_if_no_image(IMAGE) { return; } let api = api_url_for_container(&server); - run_in_container(&make_container_script(&api)) + run_container(IMAGE, &make_container_script(&api)) }; let stdout = String::from_utf8_lossy(&output.stdout); @@ -665,7 +603,7 @@ async fn npm_install_scan_apply_rollback_cycle() { stderr.contains("===VEX VERIFIED==="), "agent-mode VEX leg did not run/pass (===VEX VERIFIED=== missing).\nstdout=\n{stdout}\nstderr=\n{stderr}" ); - assert_vex_agent_attested(&stdout, PURL); + assert_vex_agent_attested(&stdout, PURL, GHSA); // Keep the workspace_root reference alive; without this clippy warns // unused. @@ -690,11 +628,11 @@ async fn npm_global_install_full_apply_chain() { // mutate, so skip silently. Docker mode is the canonical run. return; } - if skip_if_no_docker_image() { + if skip_if_no_image(IMAGE) { return; } let api = api_url_for_container(&server); - let out = run_in_container(&make_global_script(&api)); + let out = run_container(IMAGE, &make_global_script(&api)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -706,50 +644,6 @@ async fn npm_global_install_full_apply_chain() { assert_real_api_pipeline_ran(&server).await; } -/// Host-side oracle over the VEX document the container emitted between the -/// `===VEX DOC BEGIN===` / `===VEX DOC END===` markers. These agent suites run -/// the workspace inside the container with no bind mount (unlike the vendor -/// capstones), so the doc is parsed straight from captured stdout. Asserts -/// exactly one statement attesting the agent patch: the fixture GHSA, -/// `not_affected`, the installed-package subcomponent purl, and a PLAIN impact -/// statement with NO `(vendored)`/`(redirected)` marker — the marker's absence -/// is precisely what distinguishes agent provenance from the vendored/hosted -/// modes. -fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str) { - const BEGIN: &str = "===VEX DOC BEGIN==="; - const END: &str = "===VEX DOC END==="; - let start = stdout - .find(BEGIN) - .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) - + BEGIN.len(); - let stop = stdout[start..] - .find(END) - .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) - + start; - let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) - .expect("emitted VEX document must be valid JSON"); - let stmts = doc["statements"] - .as_array() - .expect("VEX document must have a statements array"); - assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); - let st = &stmts[0]; - assert_eq!(st["vulnerability"]["name"], GHSA, "attested GHSA mismatch"); - assert_eq!(st["status"], "not_affected"); - assert_eq!( - st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, - "subcomponent must be the patched package purl" - ); - let impact = st["impact_statement"] - .as_str() - .expect("statement must carry an impact_statement"); - assert!( - impact.contains("Patched via Socket patch") - && !impact.contains("(vendored)") - && !impact.contains("(redirected)"), - "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" - ); -} - /// Shared check: the mock must have served BOTH the metadata discovery /// (batch) and an actual blob fetch (inline view or raw-blob fallback). /// Without the latter the full download→apply pipeline never ran the @@ -781,11 +675,11 @@ async fn npm_bun_install_full_apply_chain() { // Host mode would need bun installed locally; skip for now. return; } - if skip_if_no_docker_image() { + if skip_if_no_image(IMAGE) { return; } let api = api_url_for_container(&server); - let out = run_in_container(&make_bun_script(&api)); + let out = run_container(IMAGE, &make_bun_script(&api)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -982,11 +876,11 @@ async fn npm_berry_agent_install_apply_chain() { // in-process berry legs already cover the host toolchain. return; } - if skip_if_no_docker_image() { + if skip_if_no_image(IMAGE) { return; } let api = api_url_for_container(&server); - let out = run_in_container(&make_berry_agent_script(&api)); + let out = run_container(IMAGE, &make_berry_agent_script(&api)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -1006,7 +900,7 @@ async fn npm_berry_vendor_frozen_install_chain() { if host_mode() { return; } - if skip_if_no_docker_image() { + if skip_if_no_image(IMAGE) { return; } let tmp = tempfile::tempdir().unwrap(); @@ -1016,8 +910,7 @@ async fn npm_berry_vendor_frozen_install_chain() { docker_vendor_common::bash_prelude(), make_berry_vendor_script() ); - let (out, _service) = - docker_vendor_common::run_with_fixture("socket-patch-test-npm:latest", &host, &script); + let (out, _service) = docker_vendor_common::run_with_fixture(IMAGE, &host, &script); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -1046,14 +939,14 @@ fn npm_image_vlt_smoke() { let out = if host_mode() { Command::new("vlt").arg("--version").output() } else { - if skip_if_no_docker_image() { + if skip_if_no_image(IMAGE) { return; } Command::new("docker") .args([ "run", "--rm", - "socket-patch-test-npm:latest", + IMAGE, "bash", "-c", "VLT_TELEMETRY=0 vlt --version", @@ -1100,8 +993,3 @@ async fn npm_test_infrastructure_smoke() { .expect("read body"); assert_eq!(body.as_ref(), PATCHED_BYTES); } - -// Suppress the unused-import warning when SOCKET_PATCH_TEST_HOST=1 (host -// mode doesn't need Duration or workspace_root). Keep both functions -// available; the helper signatures are simple enough to keep cheap. -const _: Option = None; diff --git a/crates/socket-patch-cli/tests/docker_e2e_nuget.rs b/crates/socket-patch-cli/tests/docker_e2e_nuget.rs index ee4816763..cf66e2a8f 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_nuget.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_nuget.rs @@ -17,15 +17,16 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; - -use std::process::Command; +use docker_common::{assert_vex_agent_attested, run_container, skip_if_no_image}; use base64::Engine; -use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-nuget:latest"; const ORG: &str = "test-org"; // The nuget crawler reports installed packages with the lowercased // directory name (~/.nuget/packages stores them as lowercase dirs), and the @@ -45,35 +46,6 @@ const PATCHED_LICENSE: &[u8] = b"SOCKET-PATCH-E2E-MARKER\n\ The MIT License (MIT)\n\ Copyright (c) 2024 socket-patch e2e\n"; -/// See docker_e2e_npm.rs::cov_docker_args for the coverage hook -/// semantics. The CI coverage-docker job sets the env vars; locally -/// they're unset and this returns an empty Vec. -fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - -/// Plain SHA-256 of the bytes (what `sha256sum` in the container -/// reports). Used to verify the patched file's EXACT contents, not just -/// that it contains the marker substring. -fn plain_sha256(content: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(content); - hex::encode(hasher.finalize()) -} - async fn make_mock_server(after_hash: &str) -> MockServer { let listener = std::net::TcpListener::bind("0.0.0.0:0").expect("bind wiremock"); let server = MockServer::builder().listener(listener).start().await; @@ -467,82 +439,6 @@ exit 0 ) } -/// Returns `true` when the test should skip (docker missing, image -/// missing). Prints a skip notice to stderr — the test still reports -/// as `ok` because Rust integration tests have no native "skipped" -/// outcome. Build locally with -/// `docker build -f tests/docker/Dockerfile.nuget -t socket-patch-test-nuget:latest .` -#[must_use] -fn skip_if_no_image() -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-nuget:latest"]) - .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `socket-patch-test-nuget:latest` not present"); - return true; - } - false -} - -/// Host-side oracle over the VEX document the container emitted between the -/// `===VEX DOC BEGIN===` / `===VEX DOC END===` markers (these agent suites run -/// the workspace inside the container with no bind mount, so the doc is parsed -/// from captured stdout). Asserts exactly one statement attesting the agent -/// patch: the fixture GHSA, `not_affected`, the installed-package subcomponent -/// purl, and a PLAIN impact statement with NO `(vendored)`/`(redirected)` -/// marker — the marker's absence is what distinguishes agent provenance. -fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str) { - const BEGIN: &str = "===VEX DOC BEGIN==="; - const END: &str = "===VEX DOC END==="; - let start = stdout - .find(BEGIN) - .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) - + BEGIN.len(); - let stop = stdout[start..] - .find(END) - .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) - + start; - let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) - .expect("emitted VEX document must be valid JSON"); - let stmts = doc["statements"] - .as_array() - .expect("VEX document must have a statements array"); - assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); - let st = &stmts[0]; - assert_eq!(st["vulnerability"]["name"], GHSA, "attested GHSA mismatch"); - assert_eq!(st["status"], "not_affected"); - assert_eq!( - st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, - "subcomponent must be the patched package purl" - ); - let impact = st["impact_statement"] - .as_str() - .expect("statement must carry an impact_statement"); - assert!( - impact.contains("Patched via Socket patch") - && !impact.contains("(vendored)") - && !impact.contains("(redirected)"), - "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" - ); -} - -fn run_container(script: &str) -> std::process::Output { - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args(["socket-patch-test-nuget:latest", "bash", "-c", script]); - cmd.output().expect("docker run") -} - /// Assert the wiremock actually served BOTH the metadata discovery /// (batch) AND the patch-content fetch (view). Without the latter, the /// download→apply content path never ran even if a marker somehow @@ -563,13 +459,13 @@ async fn assert_api_path_exercised(server: &MockServer) { #[tokio::test] async fn nuget_local_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_LICENSE); - let expected_sha = plain_sha256(PATCHED_LICENSE); + let expected_sha = common::sha256_hex(PATCHED_LICENSE); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let out = run_container(&local_script(&api_url, &expected_sha)); + let out = run_container(IMAGE, &local_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -590,7 +486,7 @@ async fn nuget_local_install_full_apply_chain() { "agent-mode VEX leg did not run/pass (===VEX VERIFIED=== missing).\nstderr=\n{stderr}" ); // Agent VEX names the manifest key: the API's spelling. - assert_vex_agent_attested(&stdout, API_PURL); + assert_vex_agent_attested(&stdout, API_PURL, GHSA); assert!( stderr.contains("===MANIFESTLESS VEX VERIFIED==="), "manifest-less agent-mode VEX leg did not run/pass.\nstderr=\n{stderr}" @@ -601,13 +497,13 @@ async fn nuget_local_install_full_apply_chain() { #[tokio::test] async fn nuget_global_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_LICENSE); - let expected_sha = plain_sha256(PATCHED_LICENSE); + let expected_sha = common::sha256_hex(PATCHED_LICENSE); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let out = run_container(&global_script(&api_url, &expected_sha)); + let out = run_container(IMAGE, &global_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( diff --git a/crates/socket-patch-cli/tests/docker_e2e_pypi.rs b/crates/socket-patch-cli/tests/docker_e2e_pypi.rs index 583df9d59..7cdeda193 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_pypi.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_pypi.rs @@ -19,15 +19,16 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; - -use std::process::Command; +use docker_common::{assert_vex_agent_attested, run_container, skip_if_no_image}; use base64::Engine; -use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; +const IMAGE: &str = "socket-patch-test-pypi:latest"; const ORG: &str = "test-org"; const PURL: &str = "pkg:pypi/six@1.16.0"; const UUID: &str = "12121212-1212-4121-8121-121212121212"; @@ -42,42 +43,6 @@ const PATCHED_PY: &[u8] = b"# SOCKET-PATCH-E2E-MARKER\n\ # six.py replaced by socket-patch e2e fixture\n\ __version__ = \"1.16.0-patched\"\n"; -/// Coverage instrumentation hook. The CI coverage-docker job sets -/// SOCKET_PATCH_COV_BIN (host path to an llvm-cov-instrumented -/// socket-patch binary) and SOCKET_PATCH_COV_PROFRAW_DIR (host dir -/// for in-container *.profraw output). When both are set, the docker -/// run mounts the instrumented binary over the image's baked-in -/// /usr/local/bin/socket-patch and points LLVM_PROFILE_FILE into a -/// host-visible volume so in-container code paths contribute to the -/// host's lcov merge. Empty Vec when unset. -fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - -/// Plain SHA256 (NOT git-blob) of the content — used as an independent -/// oracle for the on-disk file after apply. The marker grep alone only -/// proves the marker is *somewhere* in the file; comparing the full -/// sha256 against the exact bytes we served proves apply wrote the whole -/// blob faithfully, catching a partial/garbled/truncated write. -fn sha256_hex(content: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(content); - hex::encode(hasher.finalize()) -} - /// Assert the wiremock saw the real scan→sync API path: a batch search /// for metadata AND a content fetch via the inline-blob view endpoint. /// Without the latter the download→apply content pipeline never ran even @@ -749,90 +714,16 @@ fn uv_tool_script_pins_scans_to_the_mock_api() { ); } -/// Returns `true` when the test should skip (docker missing, image -/// missing). Prints a skip notice to stderr — the test still reports as -/// `ok` because Rust integration tests have no native "skipped" outcome. -#[must_use] -fn skip_if_no_image() -> bool { - let Ok(out) = Command::new("docker") - .args(["image", "inspect", "socket-patch-test-pypi:latest"]) - .output() - else { - eprintln!("skipping: `docker` not on PATH"); - return true; - }; - if !out.status.success() { - eprintln!("skipping: docker image `socket-patch-test-pypi:latest` not present"); - return true; - } - false -} - -/// Host-side oracle over the VEX document the container emitted between the -/// `===VEX DOC BEGIN===` / `===VEX DOC END===` markers (these agent suites run -/// the workspace inside the container with no bind mount, so the doc is parsed -/// from captured stdout). Asserts exactly one statement attesting the agent -/// patch: the fixture GHSA, `not_affected`, the installed-package subcomponent -/// purl, and a PLAIN impact statement with NO `(vendored)`/`(redirected)` -/// marker — the marker's absence is what distinguishes agent provenance. -fn assert_vex_agent_attested(stdout: &str, subcomponent_purl: &str) { - const BEGIN: &str = "===VEX DOC BEGIN==="; - const END: &str = "===VEX DOC END==="; - let start = stdout - .find(BEGIN) - .unwrap_or_else(|| panic!("VEX DOC BEGIN marker missing from stdout:\n{stdout}")) - + BEGIN.len(); - let stop = stdout[start..] - .find(END) - .unwrap_or_else(|| panic!("VEX DOC END marker missing from stdout:\n{stdout}")) - + start; - let doc: serde_json::Value = serde_json::from_str(stdout[start..stop].trim()) - .expect("emitted VEX document must be valid JSON"); - let stmts = doc["statements"] - .as_array() - .expect("VEX document must have a statements array"); - assert_eq!(stmts.len(), 1, "exactly one VEX statement expected: {doc}"); - let st = &stmts[0]; - assert_eq!(st["vulnerability"]["name"], GHSA, "attested GHSA mismatch"); - assert_eq!(st["status"], "not_affected"); - assert_eq!( - st["products"][0]["subcomponents"][0]["@id"], subcomponent_purl, - "subcomponent must be the patched package purl" - ); - let impact = st["impact_statement"] - .as_str() - .expect("statement must carry an impact_statement"); - assert!( - impact.contains("Patched via Socket patch") - && !impact.contains("(vendored)") - && !impact.contains("(redirected)"), - "agent-mode attestation must carry a PLAIN impact statement (no vendored/redirected marker): {impact}" - ); -} - -fn run_container(_api_url: &str, script: &str) -> std::process::Output { - let mut cmd = Command::new("docker"); - cmd.args([ - "run", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-i", - ]) - .args(cov_docker_args()) - .args(["socket-patch-test-pypi:latest", "bash", "-c", script]); - cmd.output().expect("docker run") -} - #[tokio::test] async fn pypi_local_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_PY); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let expected_sha = sha256_hex(PATCHED_PY); - let out = run_container(&api_url, &local_script(&api_url, &expected_sha)); + let expected_sha = common::sha256_hex(PATCHED_PY); + let out = run_container(IMAGE, &local_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -850,7 +741,7 @@ async fn pypi_local_install_full_apply_chain() { stderr.contains("===VEX VERIFIED==="), "agent-mode VEX leg did not run/pass (===VEX VERIFIED=== missing).\nstderr=\n{stderr}" ); - assert_vex_agent_attested(&stdout, PURL); + assert_vex_agent_attested(&stdout, PURL, GHSA); assert_api_path_exercised(&server).await; } @@ -859,11 +750,11 @@ async fn pypi_global_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_PY); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let expected_sha = sha256_hex(PATCHED_PY); - let out = run_container(&api_url, &global_script(&api_url, &expected_sha)); + let expected_sha = common::sha256_hex(PATCHED_PY); + let out = run_container(IMAGE, &global_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -884,11 +775,11 @@ async fn pypi_uv_venv_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_PY); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } - let expected_sha = sha256_hex(PATCHED_PY); - let out = run_container(&api_url, &uv_venv_script(&api_url, &expected_sha)); + let expected_sha = common::sha256_hex(PATCHED_PY); + let out = run_container(IMAGE, &uv_venv_script(&api_url, &expected_sha)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( @@ -912,11 +803,11 @@ async fn pypi_uv_tool_install_full_apply_chain() { let after_hash = git_sha256(PATCHED_PY); let server = make_mock_server(&after_hash).await; let api_url = format!("http://host.docker.internal:{}", server.address().port()); - if skip_if_no_image() { + if skip_if_no_image(IMAGE) { return; } let marker = "uv-tool-discovery-ok"; - let out = run_container(&api_url, &uv_tool_script(&api_url, marker)); + let out = run_container(IMAGE, &uv_tool_script(&api_url, marker)); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); assert!( diff --git a/crates/socket-patch-cli/tests/docker_e2e_sbt.rs b/crates/socket-patch-cli/tests/docker_e2e_sbt.rs index 62cbdb06e..01ac461c5 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_sbt.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_sbt.rs @@ -43,7 +43,10 @@ #[path = "common/mod.rs"] mod common; +#[path = "docker_common/mod.rs"] +mod docker_common; use common::git_sha256; +use docker_common::{cov_docker_args, skip_if_no_image}; use std::io::{Read as _, Write as _}; use std::process::Command; @@ -437,53 +440,9 @@ echo "===E2E PASS===" ) } -fn required() -> bool { - std::env::var("SOCKET_PATCH_DOCKER_E2E_REQUIRED").is_ok_and(|v| v == "1") -} - -/// `true` when the cell should be skipped (no docker / no image). -#[must_use] -fn skip_if_no_image() -> bool { - let reason = match Command::new("docker") - .args(["image", "inspect", &image()]) - .output() - { - Err(_) => "`docker` not on PATH", - Ok(out) if !out.status.success() => "the sbt docker image is not present", - Ok(_) => return false, - }; - assert!( - !required(), - "SOCKET_PATCH_DOCKER_E2E_REQUIRED=1 but {reason}" - ); - eprintln!("skipping: {reason}"); - true -} - -/// The binary mounts: a local Linux build, or the coverage hook. -fn binary_mounts() -> Vec { - if let Ok(bin) = std::env::var("SOCKET_PATCH_DOCKER_BIN") { - return vec!["-v".into(), format!("{bin}:/usr/local/bin/socket-patch:ro")]; - } - let (Ok(bin), Ok(dir)) = ( - std::env::var("SOCKET_PATCH_COV_BIN"), - std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR"), - ) else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - /// Run one cell in a fresh container and assert its markers. async fn run_cell(tool: Tool) { - if skip_if_no_image() { + if skip_if_no_image(&image()) { return; } let (pristine, patched) = jars().await; @@ -499,7 +458,7 @@ async fn run_cell(tool: Tool) { "2g", "--add-host=host.docker.internal:host-gateway", ]) - .args(binary_mounts()) + .args(cov_docker_args()) .args(["-e", &format!("SOCKET_PATCH_JDK={}", sbt_jdk("1.13.0"))]) .args([image().as_str(), "bash", "-c", &script]) .output() diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs index 82b89bbd3..afef4aefa 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_composer.rs @@ -50,14 +50,17 @@ mod cache_env; #[path = "composer_e2e_common/mod.rs"] mod composer_e2e_common; +#[path = "docker_common/mod.rs"] +mod docker_common; #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; +use docker_common::skip_if_no_image; use docker_vendor_common::{ assert_stage_markers, bash_prelude, json_assert_fns, run_in_image_network_none, - run_with_fixture, run_with_service, skip_if_no_image, stage_patch_fn, + run_with_fixture, run_with_service, stage_patch_fn, }; const IMAGE: &str = "socket-patch-test-composer:latest"; diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs index 5046c3547..36adc9081 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_gem.rs @@ -49,12 +49,15 @@ #![cfg(feature = "docker-e2e")] +#[path = "docker_common/mod.rs"] +mod docker_common; #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; +use docker_common::skip_if_no_image; use docker_vendor_common::{ assert_stage_markers, bash_prelude, json_assert_fns, run_in_image_network_none, - run_with_fixture, run_with_service, skip_if_no_image, stage_patch_fn, + run_with_fixture, run_with_service, stage_patch_fn, }; const IMAGE: &str = "socket-patch-test-gem:latest"; diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_maven.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_maven.rs index a8939e3c4..fc39993e7 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_maven.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_maven.rs @@ -40,14 +40,17 @@ #![cfg(feature = "docker-e2e")] +#[path = "docker_common/mod.rs"] +mod docker_common; #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; +use docker_common::skip_if_no_image; use docker_vendor_common::{ assert_stage_markers, bash_prelude, json_assert_fns, run_in_image_network_none, - run_with_fixture, run_with_service, skip_if_no_image, stage_patch_fn, + run_with_fixture, run_with_service, stage_patch_fn, }; const IMAGE: &str = "socket-patch-test-maven:latest"; diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_nuget.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_nuget.rs index e1a672915..393828119 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_nuget.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_nuget.rs @@ -32,14 +32,17 @@ #![cfg(feature = "docker-e2e")] +#[path = "docker_common/mod.rs"] +mod docker_common; #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; +use docker_common::skip_if_no_image; use docker_vendor_common::{ assert_stage_markers, bash_prelude, json_assert_fns, run_in_image_network_none, - run_with_fixture, run_with_service, skip_if_no_image, stage_patch_fn, + run_with_fixture, run_with_service, stage_patch_fn, }; const IMAGE: &str = "socket-patch-test-nuget:latest"; diff --git a/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs b/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs index b599844d0..64f71627e 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_vendor_pypi_pm.rs @@ -50,14 +50,17 @@ #![cfg(feature = "docker-e2e")] +#[path = "docker_common/mod.rs"] +mod docker_common; #[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common; #[path = "vex_e2e_common/mod.rs"] mod vex_e2e_common; +use docker_common::skip_if_no_image; use docker_vendor_common::{ assert_stage_markers, bash_prelude, json_assert_fns, run_in_image_network_none, - run_with_fixture, run_with_service, skip_if_no_image, stage_patch_fn, + run_with_fixture, run_with_service, stage_patch_fn, }; const IMAGE: &str = "socket-patch-test-pypi:latest"; diff --git a/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs b/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs index 0bb6a2b94..7c9a1b04b 100644 --- a/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs +++ b/crates/socket-patch-cli/tests/docker_vendor_common/mod.rs @@ -16,12 +16,12 @@ //! So instead of a throwaway container filesystem, every stage runs with the //! same host tempdir bind-mounted at `/workspace`. The socket-patch binary //! itself is the one BAKED INTO the image at `/usr/local/bin/socket-patch` -//! by `tests/docker/Dockerfile.base` (optionally shadowed by the -//! coverage-instrumented binary via `cov_docker_args`, same hook as the -//! other docker suites). +//! by `tests/docker/Dockerfile.base` (optionally shadowed via +//! `docker_common::cov_docker_args`). //! //! Each test file pulls this in with -//! `#[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common;`. +//! `#[path = "docker_vendor_common/mod.rs"] mod docker_vendor_common;`, +//! beside `docker_common`, which it builds on. //! //! `#![allow(dead_code)]`: each suite uses a different subset. @@ -36,65 +36,6 @@ use std::process::Stdio; use std::path::Path; use std::process::{Command, Output}; -/// Coverage instrumentation hook — identical contract to -/// `docker_e2e_pypi.rs::cov_docker_args`. The CI coverage-docker job sets -/// `SOCKET_PATCH_COV_BIN` (host path to an llvm-cov-instrumented -/// socket-patch) + `SOCKET_PATCH_COV_PROFRAW_DIR` (host dir for in-container -/// *.profraw output); locally both are unset and this is empty. -pub fn cov_docker_args() -> Vec { - let Ok(bin) = std::env::var("SOCKET_PATCH_COV_BIN") else { - return Vec::new(); - }; - let Ok(dir) = std::env::var("SOCKET_PATCH_COV_PROFRAW_DIR") else { - return Vec::new(); - }; - vec![ - "-v".into(), - format!("{bin}:/usr/local/bin/socket-patch:ro"), - "-v".into(), - format!("{dir}:/coverage"), - "-e".into(), - "LLVM_PROFILE_FILE=/coverage/docker-e2e-%p-%14m.profraw".into(), - ] -} - -/// Returns `true` when the test should skip: `docker` missing from PATH or -/// the per-ecosystem image not built. Prints a skip notice — Rust -/// integration tests have no native "skipped" outcome, so the test then -/// reports `ok`. Build locally with -/// `docker build -f tests/docker/Dockerfile. -t .` -/// (after `Dockerfile.base` → `socket-patch-test-base:latest`). -/// -/// With `SOCKET_PATCH_DOCKER_E2E_REQUIRED=1` (a CI job that built the image -/// itself) a missing docker or image fails the test instead, so a tag -/// mismatch cannot pass the job with zero assertions. -#[must_use] -pub fn skip_if_no_image(image: &str) -> bool { - skip_or_require_image( - image, - std::env::var("SOCKET_PATCH_DOCKER_E2E_REQUIRED").is_ok_and(|v| v == "1"), - ) -} - -/// [`skip_if_no_image`] with the required switch passed in. -#[must_use] -pub fn skip_or_require_image(image: &str, required: bool) -> bool { - let reason = match Command::new("docker") - .args(["image", "inspect", image]) - .output() - { - Err(_) => "`docker` not on PATH".to_string(), - Ok(out) if !out.status.success() => format!("docker image `{image}` not present"), - Ok(_) => return false, - }; - assert!( - !required, - "{reason}; SOCKET_PATCH_DOCKER_E2E_REQUIRED=1 forbids skipping" - ); - eprintln!("skipping: {reason}"); - true -} - fn docker_command(image: &str, host_dir: &Path, script: &str, extra: &[&str]) -> Command { let mut cmd = Command::new("docker"); cmd.args(["run", "--rm", "-i"]); @@ -105,7 +46,7 @@ fn docker_command(image: &str, host_dir: &Path, script: &str, extra: &[&str]) -> "-w", "/workspace", ]); - cmd.args(cov_docker_args()); + cmd.args(crate::docker_common::cov_docker_args()); cmd.args([image, "bash", "-c", script]); cmd } @@ -197,14 +138,7 @@ pub fn run_with_service(image: &str, host_dir: &Path, script: &str, uri: &str) - ) } -/// Run `script` (bash) inside `image` with `host_dir` bind-mounted at -/// `/workspace` (the working dir). Network is the docker default — use this -/// for fixture-install stages that need the real registry. -pub fn run_in_image(image: &str, host_dir: &Path, script: &str) -> Output { - docker_run(image, host_dir, script, &[]) -} - -/// `run_in_image` + `--network none`: the cold-cache fresh-checkout install +/// A `--network none` run: the cold-cache fresh-checkout install /// stage. Any code path that still wants the registry fails loudly in here. pub fn run_in_image_network_none(image: &str, host_dir: &Path, script: &str) -> Output { docker_run(image, host_dir, script, &["--network", "none"]) diff --git a/crates/socket-patch-cli/tests/vendor/docker_vendor_common_selftest.rs b/crates/socket-patch-cli/tests/vendor/docker_vendor_common_selftest.rs index 4a2848b93..5477a9c16 100644 --- a/crates/socket-patch-cli/tests/vendor/docker_vendor_common_selftest.rs +++ b/crates/socket-patch-cli/tests/vendor/docker_vendor_common_selftest.rs @@ -17,7 +17,8 @@ use std::process::{Command, Output, Stdio}; use crate::docker_vendor_common; -use docker_vendor_common::{bash_prelude, skip_or_require_image, stage_patch_fn}; +use crate::docker_common::skip_or_require_image; +use docker_vendor_common::{bash_prelude, stage_patch_fn}; /// The docker images always have coreutils `sha256sum`; macOS dev hosts may /// only have perl `shasum`, so shim it for these local runs. diff --git a/crates/socket-patch-cli/tests/vendor/main.rs b/crates/socket-patch-cli/tests/vendor/main.rs index 9e3c81089..ce45e9362 100644 --- a/crates/socket-patch-cli/tests/vendor/main.rs +++ b/crates/socket-patch-cli/tests/vendor/main.rs @@ -9,6 +9,8 @@ mod rollback_json; mod bun_vex; #[path = "../common/mod.rs"] mod common; +#[path = "../docker_common/mod.rs"] +mod docker_common; #[path = "../docker_vendor_common/mod.rs"] mod docker_vendor_common;