From 1a0e0203da7ca2bf0a09262e6da4f647d6e65ded Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 04:53:03 +0000 Subject: [PATCH 1/4] Start vex suite fold for #1089 Assisted-by: Claude Code:claude-opus-5-5 From 65dea68b9d1b8e744410dbb77928c1b5b5ba8ea2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:00:59 +0000 Subject: [PATCH 2/4] CI: fold vex tests into one suite on codes The vex command's coverage-gap tests and its terminal-output tests were two separate test binaries, and many checks matched English sentences on stderr. They now live in one `vex` suite binary: - vex_failure_paths.rs checks the --json contract (exit code, error.code, skipped-event errorCodes, warning codes, disk state) through common::envelope. - vex_go_patches.rs reads the go-patches synthesis guards from --json skipped events and the written document, not stderr lines. - vex_render.rs keeps the human output, one check per message family, with the duplicate human halves merged. - vex_fixture.rs holds one copy of the writers and runners. cli_parse_vex.rs stays its own binary because it mutates the process environment. No production change. Refs #1089. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/covgap_commands_vex.rs | 1258 ----------------- crates/socket-patch-cli/tests/vex/main.rs | 15 + .../tests/vex/vex_failure_paths.rs | 299 ++++ .../socket-patch-cli/tests/vex/vex_fixture.rs | 223 +++ .../tests/vex/vex_go_patches.rs | 426 ++++++ .../vex_render.rs} | 286 ++-- scripts/ci-test-durations.json | 3 +- 7 files changed, 1076 insertions(+), 1434 deletions(-) delete mode 100644 crates/socket-patch-cli/tests/covgap_commands_vex.rs create mode 100644 crates/socket-patch-cli/tests/vex/main.rs create mode 100644 crates/socket-patch-cli/tests/vex/vex_failure_paths.rs create mode 100644 crates/socket-patch-cli/tests/vex/vex_fixture.rs create mode 100644 crates/socket-patch-cli/tests/vex/vex_go_patches.rs rename crates/socket-patch-cli/tests/{vex_terminal_output.rs => vex/vex_render.rs} (60%) diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs deleted file mode 100644 index d00716687..000000000 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ /dev/null @@ -1,1258 +0,0 @@ -//! Coverage-gap tests for `commands/vex.rs`: the -//! corrupt-ledger/corrupt-manifest family (hard errors, plus the advisory -//! malformed pre-v5 redirect ledger), the multi-manifest -//! product auto-detect warning echo, and the four skip guards in the -//! go-patches `replace` synthesis (including the SECURITY fail-closed -//! coordinate guard on tamper-able `go.mod` lines). -//! -//! Fixture + runner shapes mirror `e2e_vex.rs` / `e2e_vex_vendor.rs` / -//! `e2e_vex_redirect.rs` (which this suite deliberately does not touch): a -//! self-contained tempdir project driven through the built binary with a -//! scrubbed child environment. No test mutates this process's environment. - -#[path = "common/mod.rs"] -mod common; -use common::{binary, hermetic_command}; - -use std::collections::HashMap; -use std::path::Path; -use std::process::Command; - -use serde_json::Value; -use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; -use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, -}; -use socket_patch_core::vendor::state::{ - VendorArtifact, VendorEntry, VendorState, WiringAction, WiringRecord, -}; - -/// Canonical-grammar patch UUID (the vendored-artifact verifier validates -/// the uuid path level, so fixtures must use the real shape). -const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; - -/// A prior successful run's minimal-but-recognizable OpenVEX document (the -/// `@context` names openvex.dev, which is what `remove_stale_vex_doc` keys -/// its deletion guard on). Mirrors the stale-doc fixture in -/// `e2e_vex_vendor.rs`. -const STALE_OPENVEX_DOC: &str = r#"{"@context":"https://openvex.dev/ns/v0.2.0","@id":"urn:uuid:stale","author":"Socket","timestamp":"2020-01-01T00:00:00Z","version":1,"statements":[]}"#; - -/// CLI invocation in the hermetic env: ambient `SOCKET_*` scrubbed (same -/// rationale as `e2e_vex.rs`: explicit flags must be the sole source of -/// truth; the parent env is never mutated so tests need no serialization) -/// and telemetry off (`vex` failures otherwise POST a real event to the -/// production proxy endpoint on every run). -fn cli() -> Command { - hermetic_command(&binary()) -} - -/// Write `manifest` to `/.socket/manifest.json`. -fn write_manifest(cwd: &Path, manifest: &PatchManifest) { - let dir = cwd.join(".socket"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("manifest.json"), - serde_json::to_string_pretty(manifest).unwrap(), - ) - .unwrap(); -} - -/// Patch record with one file (whose hashes you choose) and one -/// vulnerability. -fn make_record( - uuid: &str, - file_name: &str, - before_hash: &str, - after_hash: &str, - vuln_id: &str, - cves: &[&str], -) -> PatchRecord { - let mut files = HashMap::new(); - files.insert( - file_name.to_string(), - PatchFileInfo { - before_hash: before_hash.to_string(), - after_hash: after_hash.to_string(), - }, - ); - let mut vulns = HashMap::new(); - vulns.insert( - vuln_id.to_string(), - VulnerabilityInfo { - cves: cves.iter().map(|s| s.to_string()).collect(), - summary: "test summary".to_string(), - severity: "high".to_string(), - description: "test description".to_string(), - }, - ); - PatchRecord { - uuid: uuid.to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files, - vulnerabilities: vulns, - description: format!("Patch {uuid}"), - license: "MIT".to_string(), - tier: "free".to_string(), - } -} - -/// One-npm-patch manifest whose package is nowhere on disk — verify mode -/// then omits it as `package_not_found` and, with nothing else to attest, -/// the run ends `no_applicable_patches`. -fn write_ghost_npm_manifest(cwd: &Path, purl: &str) { - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - purl.to_string(), - make_record( - "11111111-1111-4111-8111-111111111111", - "package/index.js", - "a".repeat(64).as_str(), - "b".repeat(64).as_str(), - "GHSA-cg-ghost", - &["CVE-2026-100"], - ), - ); - write_manifest(cwd, &manifest); -} - -// ────────────────────────────────────────────────────────────────────── -// corrupt manifest → `manifest_invalid`, exit 2 (read_manifest Err arm; -// the shared manifest-load mapping, #931) -// -// A PRESENT-but-corrupt `.socket/manifest.json` is the hard exit-2 error -// (`read_manifest` → Err), distinct from the missing-manifest exit-2 -// (`manifest_not_found`, already covered in `e2e_vex.rs`) and the soft -// empty-manifest exit 1. -// ────────────────────────────────────────────────────────────────────── - -#[test] -fn corrupt_manifest_exits_2_with_parse_error_on_stderr() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - let dir = cwd.join(".socket"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("manifest.json"), "{not json").unwrap(); - - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex"); - assert_eq!( - out.status.code(), - Some(2), - "a present-but-corrupt manifest is a hard error. stderr:\n{}", - String::from_utf8_lossy(&out.stderr) - ); - assert!( - out.stdout.is_empty(), - "no document may be emitted for an unreadable manifest. got: {}", - String::from_utf8_lossy(&out.stdout) - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("Error:"), "got: {stderr}"); - assert!( - stderr.contains("Failed to parse manifest JSON"), - "the error must name the manifest parse failure, not some other \ - cause (a missing manifest says 'Manifest not found'). got: {stderr}" - ); - assert!( - !stderr.contains("Manifest not found"), - "corrupt must not be conflated with missing. got: {stderr}" - ); -} - -#[test] -fn corrupt_manifest_json_envelope_carries_code_and_removes_stale_doc() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - let dir = cwd.join(".socket"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("manifest.json"), "{not json").unwrap(); - - // A previous successful run's doc sits at --output; the failure - // contract says a run that ends in error leaves NO OpenVEX document - // there — including this stale one. - let vex_path = cwd.join("out.vex.json"); - std::fs::write(&vex_path, STALE_OPENVEX_DOC).unwrap(); - - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--json", - "--output", - vex_path.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex"); - assert_eq!( - out.status.code(), - Some(2), - "manifest_invalid is a hard error in --json mode too. stdout:\n{}", - String::from_utf8_lossy(&out.stdout) - ); - let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!(env["command"], "vex", "{env}"); - assert_eq!(env["status"], "error", "{env}"); - assert_eq!(env["error"]["code"], "manifest_invalid", "{env}"); - assert!( - env["error"]["message"] - .as_str() - .unwrap() - .contains("Failed to parse manifest JSON"), - "the envelope message must carry the parse failure: {env}" - ); - assert!( - !vex_path.exists(), - "a failed run must not leave a previous run's attestation at --output" - ); -} - -/// A failed run's stale-doc cleanup reads `--output` to check it is -/// OpenVEX before deleting it. A FIFO there must not block that read: the -/// run still fails promptly with its own error and leaves the FIFO alone. -#[cfg(unix)] -#[test] -fn failed_run_does_not_block_on_a_fifo_at_output() { - use std::os::unix::fs::FileTypeExt; - use std::time::{Duration, Instant}; - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - let dir = cwd.join(".socket"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("manifest.json"), "{not json").unwrap(); - let fifo = cwd.join("out.vex.json"); - assert!(Command::new("mkfifo") - .arg(&fifo) - .status() - .unwrap() - .success()); - - let mut child = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--json", - "--output", - fifo.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .expect("invoke vex"); - let started = Instant::now(); - while child.try_wait().unwrap().is_none() { - if started.elapsed() > Duration::from_secs(60) { - let _ = child.kill(); - panic!("vex blocked reading the FIFO at --output"); - } - std::thread::sleep(Duration::from_millis(25)); - } - let out = child.wait_with_output().unwrap(); - assert_eq!( - out.status.code(), - Some(2), - "stdout:\n{}", - String::from_utf8_lossy(&out.stdout) - ); - let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!(env["error"]["code"], "manifest_invalid", "{env}"); - assert!( - std::fs::symlink_metadata(&fifo) - .unwrap() - .file_type() - .is_fifo(), - "a FIFO is not an OpenVEX document and must not be removed" - ); -} - -// ────────────────────────────────────────────────────────────────────── -// corrupt pre-v5 redirect ledger → `redirect_ledger_corrupt` WARNING -// -// v5 hosted mode keeps no ledger: hosted references come from the -// lockfiles and their records from the API. A pre-v5 -// `.socket/vendor/redirect-state.json` is only an extra local record -// source, so a malformed one is an advisory — its records are simply not -// consulted, the run proceeds to its normal outcome, and (vex being a -// read-only consumer) the file is left byte-identical, never quarantined. -// ────────────────────────────────────────────────────────────────────── - -const CORRUPT_REDIRECT_LEDGER: &str = "{{{"; - -/// Plant a malformed pre-v5 redirect ledger at its canonical path. -fn write_corrupt_redirect_ledger(cwd: &Path) { - let dir = cwd.join(".socket/vendor"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("redirect-state.json"), CORRUPT_REDIRECT_LEDGER).unwrap(); -} - -fn assert_redirect_ledger_untouched(cwd: &Path) { - assert_eq!( - std::fs::read_to_string(cwd.join(".socket/vendor/redirect-state.json")).unwrap(), - CORRUPT_REDIRECT_LEDGER, - "vex must leave the malformed pre-v5 ledger byte-identical" - ); - assert!( - !cwd.join(".socket/vendor/redirect-state.json.corrupt") - .exists(), - "vex must not quarantine the pre-v5 ledger" - ); -} - -#[test] -fn corrupt_redirect_ledger_is_a_warning_in_human_mode_and_the_run_proceeds() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - // A manifest patch whose package is nowhere on disk: the run's own - // outcome is `no_applicable_patches` (exit 1) — reaching it proves the - // malformed ledger did not abort the run. - write_ghost_npm_manifest(cwd, "pkg:npm/leftpad@1.0.0"); - write_corrupt_redirect_ledger(cwd); - - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!( - out.status.code(), - Some(1), - "a malformed pre-v5 redirect ledger is advisory: the run reaches its own \ - no_applicable_patches outcome. stderr:\n{stderr}" - ); - assert!( - stderr.contains("Warning:") - && stderr.contains("pre-v5 redirect ledger") - && stderr.contains("malformed") - && stderr.contains("not consulted"), - "the redirect_ledger_corrupt warning must reach stderr. got: {stderr}" - ); - assert!( - !stderr.contains("Manifest not found"), - "the manifest was read and planned from. got: {stderr}" - ); - assert_redirect_ledger_untouched(cwd); -} - -#[test] -fn corrupt_redirect_ledger_json_envelope_carries_the_warning_and_preserves_ledger() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - write_ghost_npm_manifest(cwd, "pkg:npm/leftpad@1.0.0"); - write_corrupt_redirect_ledger(cwd); - - let vex_path = cwd.join("out.vex.json"); - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--json", - "--output", - vex_path.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex"); - let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!(out.status.code(), Some(1), "{env}"); - // The run's own outcome, not a ledger error. - assert_eq!(env["error"]["code"], "no_applicable_patches", "{env}"); - let skipped = env["events"] - .as_array() - .unwrap() - .iter() - .find(|e| e["action"] == "skipped" && e["purl"] == "pkg:npm/leftpad@1.0.0") - .unwrap_or_else(|| panic!("the manifest patch was evaluated: {env}")); - assert_eq!(skipped["errorCode"], "package_not_found", "{env}"); - let warning = env["warnings"] - .as_array() - .unwrap_or_else(|| panic!("warnings[] expected: {env}")) - .iter() - .find(|w| w["code"] == "redirect_ledger_corrupt") - .unwrap_or_else(|| panic!("redirect_ledger_corrupt must be in warnings[]: {env}")); - assert!( - warning["detail"] - .as_str() - .is_some_and(|m| m.contains("malformed") && m.contains("redirect-state.json")), - "{env}" - ); - // Under --json the warning travels in the envelope only. - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - !stderr.contains("pre-v5 redirect ledger"), - "--json must not echo the warning on stderr: {stderr}" - ); - assert_redirect_ledger_untouched(cwd); - assert!(!vex_path.exists(), "no document when nothing was attested"); -} - -#[test] -fn corrupt_redirect_ledger_alone_warns_and_is_still_manifest_not_found() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - write_corrupt_redirect_ledger(cwd); - - let vex_path = cwd.join("out.vex.json"); - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--json", - "--output", - vex_path.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex"); - let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - // A malformed ledger is no patch source: with nothing else on disk the - // run is the ordinary nothing-to-attest error. - assert_eq!(out.status.code(), Some(2), "{env}"); - assert_eq!(env["error"]["code"], "manifest_not_found", "{env}"); - assert!( - env["warnings"] - .as_array() - .is_some_and(|w| w.iter().any(|w| w["code"] == "redirect_ledger_corrupt")), - "{env}" - ); - assert_redirect_ledger_untouched(cwd); -} - -// ────────────────────────────────────────────────────────────────────── -// corrupt vendor ledger → `vendor_ledger_corrupt`, exit 2 -// -// A present-but-corrupt `.socket/vendor/state.json` is a hard error -// (unlike the advisory pre-v5 redirect ledger above): the vendor ledger is an -// attestation input in its own right — it carries embedded records and -// the entries whose wiring liveness gates them — so a run that cannot read -// it cannot tell which vendored patches it is dropping, and attesting from -// the remainder would publish a silently partial document. -// ────────────────────────────────────────────────────────────────────── - -#[test] -fn corrupt_vendor_ledger_is_a_hard_error_in_human_mode() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - let purl = "pkg:npm/leftpad@1.0.0"; - write_ghost_npm_manifest(cwd, purl); - // AFTER write_manifest (which creates .socket/): plant the corrupt ledger. - let dir = cwd.join(".socket/vendor"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("state.json"), "not json").unwrap(); - - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex"); - assert_eq!( - out.status.code(), - Some(2), - "a corrupt vendor ledger is a hard error, never a degrade. stderr:\n{}", - String::from_utf8_lossy(&out.stderr) - ); - assert!(out.stdout.is_empty(), "no document on a hard error"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("vendor ledger") && stderr.contains("state.json"), - "the error must name the unreadable ledger. got: {stderr}" - ); - assert!( - stderr.contains("corrupt"), - "the error must carry load_state's detail (corrupt ). got: {stderr}" - ); - // Nothing was verified, so no per-patch omission is reported. - assert!( - !stderr.contains("package_not_found") && !stderr.contains("omitting"), - "the ledger error fires before verification. got: {stderr}" - ); -} - -#[test] -fn corrupt_vendor_ledger_json_envelope_carries_code_and_preserves_ledger() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - let purl = "pkg:npm/leftpad@1.0.0"; - write_ghost_npm_manifest(cwd, purl); - let dir = cwd.join(".socket/vendor"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("state.json"), "not json").unwrap(); - - let vex_path = cwd.join("out.vex.json"); - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--json", - "--output", - vex_path.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex"); - assert_eq!( - out.status.code(), - Some(2), - "vendor_ledger_corrupt is a hard error in --json mode too. stdout:\n{}", - String::from_utf8_lossy(&out.stdout) - ); - let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!(env["status"], "error", "{env}"); - assert_eq!(env["error"]["code"], "vendor_ledger_corrupt", "{env}"); - assert!( - env["error"]["message"] - .as_str() - .unwrap() - .contains("corrupt"), - "the envelope must carry the load_state detail: {env}" - ); - assert!( - env["events"].as_array().is_none_or(|e| e.is_empty()), - "no per-patch events on a pre-verification hard error: {env}" - ); - // vex is a READ-ONLY ledger consumer: the file may hold the only revert - // data for the committed vendoring, so it stays exactly where it was. - assert_eq!( - std::fs::read_to_string(dir.join("state.json")).unwrap(), - "not json", - "vex must not touch the corrupt vendor ledger" - ); - assert!(!vex_path.exists(), "no document on a hard error"); -} - -// ────────────────────────────────────────────────────────────────────── -// multi-manifest auto-detect warning echo (resolve_product_id) -// -// `detect_product` warns when multiple project manifests coexist (e.g. -// package.json + Cargo.toml, no .git) and `resolve_product_id` must echo -// that warning to stderr in human mode — and suppress it under --silent. -// ────────────────────────────────────────────────────────────────────── - -/// Tempdir with package.json AND Cargo.toml (no .git), plus a one-patch -/// manifest. Auto-detect must pick package.json → `pkg:npm/app@1.0.0`. -fn scaffold_multi_manifest_project(cwd: &Path) { - std::fs::write( - cwd.join("package.json"), - r#"{"name":"app","version":"1.0.0"}"#, - ) - .unwrap(); - std::fs::write( - cwd.join("Cargo.toml"), - "[package]\nname = \"app\"\nversion = \"1.0.0\"\n", - ) - .unwrap(); - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - "pkg:npm/x@1.0.0".to_string(), - make_record( - "11111111-1111-4111-8111-111111111111", - "package/index.js", - "a".repeat(64).as_str(), - "b".repeat(64).as_str(), - "GHSA-cg-multi", - &["CVE-2026-200"], - ), - ); - write_manifest(cwd, &manifest); -} - -#[test] -fn auto_detect_multi_manifest_warns_on_stderr() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - scaffold_multi_manifest_project(cwd); - - let out_path = cwd.join("out.vex.json"); - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--no-verify", - "--output", - out_path.to_str().unwrap(), - ]) - .output() - .expect("invoke vex"); - assert!( - out.status.success(), - "multi-manifest detect is a warning, not a failure. stderr:\n{}", - String::from_utf8_lossy(&out.stderr) - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("Multiple project manifests detected"), - "detect_product's warning must be echoed to stderr. got: {stderr}" - ); - assert!( - stderr.contains("using package.json"), - "the warning must name the manifest actually used. got: {stderr}" - ); - // The detection itself resolved via package.json. - let doc: Value = serde_json::from_str(&std::fs::read_to_string(&out_path).unwrap()).unwrap(); - assert_eq!( - doc["statements"][0]["products"][0]["@id"], "pkg:npm/app@1.0.0", - "package.json wins the multi-manifest priority: {doc}" - ); -} - -#[test] -fn auto_detect_multi_manifest_warning_reaches_json_envelope() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - scaffold_multi_manifest_project(cwd); - - let out_path = cwd.join("out.vex.json"); - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--no-verify", - "--json", - "--output", - out_path.to_str().unwrap(), - ]) - .output() - .expect("invoke vex"); - assert!( - out.status.success(), - "{}", - String::from_utf8_lossy(&out.stderr) - ); - let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - let w = env["warnings"] - .as_array() - .and_then(|ws| { - ws.iter() - .find(|w| w["code"] == "product_multiple_manifests") - }) - .unwrap_or_else(|| panic!("product_multiple_manifests warning expected: {env}")); - assert!( - w["detail"] - .as_str() - .unwrap() - .contains("Multiple project manifests"), - "{w}" - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - !stderr.contains("Multiple project manifests"), - "--json keeps the advisory off stderr. got: {stderr}" - ); -} - -#[test] -fn auto_detect_multi_manifest_warning_suppressed_by_silent() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - scaffold_multi_manifest_project(cwd); - - let out_path = cwd.join("out.vex.json"); - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--no-verify", - "--silent", - "--output", - out_path.to_str().unwrap(), - ]) - .output() - .expect("invoke vex"); - assert!( - out.status.success(), - "--silent must not change the outcome. stderr:\n{}", - String::from_utf8_lossy(&out.stderr) - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - !stderr.contains("Multiple project manifests"), - "--silent must suppress the detect warning echo. got: {stderr}" - ); - assert!( - out.stdout.is_empty(), - "--silent with --output prints nothing to stdout. got: {}", - String::from_utf8_lossy(&out.stdout) - ); - // The document is still produced with the auto-detected product. - let doc: Value = serde_json::from_str(&std::fs::read_to_string(&out_path).unwrap()).unwrap(); - assert_eq!( - doc["statements"][0]["products"][0]["@id"], - "pkg:npm/app@1.0.0" - ); -} - -// ────────────────────────────────────────────────────────────────────── -// go-patches synthesis skip guards -// -// `synthesize_go_patches` walks the tamper-able `go.mod` replace set; -// owner classification is by target-path prefix (`.socket/go-patches/`), -// so hand-written lines reach every guard: -// (a) version-less directory replace → skipped -// (b) go-patches replace with no manifest purl → skipped -// (c) explicit (LIVE) vendor entry takes precedence → skipped -// (d) SECURITY: unsafe module coordinates → skipped — the -// only end-to-end proof that a tampered go.mod cannot key an -// out-of-tree path into VEX verification (the inline unit test only -// pins the predicate, not that the synthesis consults it). -// ────────────────────────────────────────────────────────────────────── - -const GOOD_UUID: &str = "22222222-2222-4222-8222-222222222222"; -const EVIL_UUID: &str = "33333333-3333-4333-8333-333333333333"; - -/// Non-detached golang vendor-ledger entry (the manifest record is the -/// verification oracle) whose dir-shaped artifact lives at `rel_path`, -/// recording its `go.mod` replace wiring — vex only honors a vendor entry -/// whose artifact some lockfile/config still references, and the ledger's -/// recorded wiring files are that proof for formats lockfile discovery does -/// not read. -fn write_golang_vendor_state(cwd: &Path, purl: &str, rel_path: &str) { - let mut state = VendorState::new(); - state.entries.insert( - purl.to_string(), - VendorEntry::new( - "golang".to_string(), - purl.to_string(), - UUID.to_string(), - VendorArtifact { - yarn_berry10c0: None, - path: rel_path.to_string(), - sha256: String::new(), - size: None, - platform_locked: None, - file_inventory: None, - }, - vec![WiringRecord { - file: "go.mod".to_string(), - kind: "go_replace".to_string(), - action: WiringAction::Added, - key: None, - original: None, - new: None, - }], - ), - ); - let dir = cwd.join(".socket/vendor"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("state.json"), - serde_json::to_string_pretty(&state).unwrap(), - ) - .unwrap(); -} - -#[test] -fn go_patches_synthesis_skips_tampered_and_stale_replaces() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - - // ── GOOD module: exactly the fixture shape of - // e2e_vex_vendor::golang_go_patches_redirect_attested_without_module_cache - // — the control proving the synthesis DID run on this go.mod. - let good_module = "github.com/foo/bar"; - let good_version = "v1.4.2"; - let good_purl = format!("pkg:golang/{good_module}@{good_version}"); - std::fs::write( - cwd.join("go.mod"), - format!("module example.com/app\n\ngo 1.21\n\nrequire {good_module} {good_version}\n"), - ) - .unwrap(); - tokio::runtime::Runtime::new() - .unwrap() - .block_on( - socket_patch_core::vendor::go_mod_edit::ensure_replace_entry( - cwd, - good_module, - good_version, - socket_patch_core::vendor::go_mod_edit::GO_PATCHES_DIR, - false, - ), - ) - .expect("write go.mod replace"); - let good_patched = b"package bar // patched\n"; - let good_after = compute_git_sha256_from_bytes(good_patched); - let good_copy = cwd.join(format!(".socket/go-patches/{good_module}@{good_version}")); - std::fs::create_dir_all(&good_copy).unwrap(); - std::fs::write(good_copy.join("bar.go"), good_patched).unwrap(); - - // ── (c) VENDORED module: manifest purl with BOTH a go-patches replace - // line AND an explicit vendor entry. The vendor artifact holds the - // hash-matching bytes; the go-patches copy dir holds DIFFERENT bytes — - // so if the vendor-entry precedence guard were dropped (synthesis wins), - // verification would hash the wrong bytes and the purl would vanish - // from the doc. The "(vendored)" marker pins the winning provenance. - let vend_module = "github.com/vendored/mod"; - let vend_version = "v1.0.0"; - let vend_purl = format!("pkg:golang/{vend_module}@{vend_version}"); - let vend_patched = b"package mod // vendored patched\n"; - let vend_after = compute_git_sha256_from_bytes(vend_patched); - let vend_rel = format!(".socket/vendor/golang/{UUID}/{vend_module}@{vend_version}"); - let vend_dir = cwd.join(&vend_rel); - std::fs::create_dir_all(&vend_dir).unwrap(); - std::fs::write(vend_dir.join("mod.go"), vend_patched).unwrap(); - write_golang_vendor_state(cwd, &vend_purl, &vend_rel); - let vend_decoy = cwd.join(format!(".socket/go-patches/{vend_module}@{vend_version}")); - std::fs::create_dir_all(&vend_decoy).unwrap(); - std::fs::write(vend_decoy.join("mod.go"), b"stale go-patches copy\n").unwrap(); - - // ── (d) TAMPERED module: unsafe traversal coordinates in a hand-written - // socket-owned replace, plus a manifest record. A hash-MATCHING decoy is - // planted at the location the traversal resolves to - // (.socket/go-patches/github.com/foo/../../../etc@v1.0.0 → .socket/etc@v1.0.0), - // so an implementation missing the are_safe_redirect_coords guard would - // byte-verify the decoy and falsely attest. - let evil_module = "github.com/foo/../../../etc"; - let evil_version = "v1.0.0"; - let evil_purl = format!("pkg:golang/{evil_module}@{evil_version}"); - let evil_patched = b"package etc // evil decoy\n"; - let evil_after = compute_git_sha256_from_bytes(evil_patched); - let evil_decoy_dir = cwd.join(".socket").join(format!("etc@{evil_version}")); - std::fs::create_dir_all(&evil_decoy_dir).unwrap(); - std::fs::write(evil_decoy_dir.join("evil.go"), evil_patched).unwrap(); - - // Hand-append the tampered/stale replace lines exactly as an attacker - // (or a stale tool) could commit them; the `.socket/go-patches/` target - // prefix classifies every one of them as socket-owned. (c) also keeps - // its LIVE vendor wiring — the vendor-over-go-patches takeover shape - // with the stale go-patches line left behind: without it the vendor - // entry wires nothing, and vex now omits such a leftover entry as - // `vendor_unwired` instead of letting it route verification at all. - let mut go_mod = std::fs::read_to_string(cwd.join("go.mod")).unwrap(); - go_mod.push_str(&format!( - "\nreplace github.com/noversion => ./.socket/go-patches/x\n\ - replace github.com/stale v1.0.0 => ./.socket/go-patches/github.com/stale@v1.0.0\n\ - replace {vend_module} {vend_version} => ./{vend_rel}\n\ - replace {vend_module} {vend_version} => ./.socket/go-patches/{vend_module}@{vend_version}\n\ - replace {evil_module} {evil_version} => ./.socket/go-patches/{evil_module}@{evil_version}\n" - )); - std::fs::write(cwd.join("go.mod"), go_mod).unwrap(); - // (b)'s stale copy dir exists with plausible bytes so only the missing - // manifest entry (not a missing dir) can explain its absence downstream. - let stale_copy = cwd.join(".socket/go-patches/github.com/stale@v1.0.0"); - std::fs::create_dir_all(&stale_copy).unwrap(); - std::fs::write(stale_copy.join("stale.go"), b"package stale\n").unwrap(); - - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - good_purl.clone(), - make_record( - GOOD_UUID, - "bar.go", - "a".repeat(64).as_str(), - &good_after, - "GHSA-cg-go-good", - &["CVE-2026-301"], - ), - ); - manifest.patches.insert( - vend_purl.clone(), - make_record( - UUID, - "mod.go", - "a".repeat(64).as_str(), - &vend_after, - "GHSA-cg-go-vend", - &["CVE-2026-302"], - ), - ); - manifest.patches.insert( - evil_purl.clone(), - make_record( - EVIL_UUID, - "evil.go", - "a".repeat(64).as_str(), - &evil_after, - "GHSA-cg-go-evil", - &["CVE-2026-303"], - ), - ); - write_manifest(cwd, &manifest); - - // Hermetic, EMPTY module cache: with the go-patches synthesis refusing - // the tampered coordinates there is nowhere left for that purl to - // verify — fail-closed omission is the only acceptable outcome. - let empty_cache = tmp.path().join("empty-gomodcache"); - std::fs::create_dir_all(&empty_cache).unwrap(); - - let out = cli() - .env("GOMODCACHE", &empty_cache) - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:golang/example.com/app@v0.0.1", - ]) - .output() - .expect("invoke vex"); - assert!( - out.status.success(), - "the good + vendored purls must still attest. stderr:\n{}", - String::from_utf8_lossy(&out.stderr) - ); - - let stdout = String::from_utf8(out.stdout).unwrap(); - let doc: Value = serde_json::from_str(&stdout).expect("VEX JSON on stdout"); - let stmts = doc["statements"].as_array().unwrap(); - assert_eq!( - stmts.len(), - 2, - "exactly the good redirect + the vendored purl attest; the \ - version-less, stale, and tampered replaces are all skipped. \ - doc:\n{stdout}" - ); - - // Control (the synthesis ran): the good module attests from the - // go-patches copy dir with the PLAIN (non-vendored) impact statement. - let good = stmts - .iter() - .find(|s| s["vulnerability"]["name"] == "GHSA-cg-go-good") - .unwrap_or_else(|| panic!("good redirect purl missing: {stdout}")); - assert_eq!(good["products"][0]["subcomponents"][0]["@id"], good_purl); - assert_eq!( - good["impact_statement"].as_str().unwrap(), - format!("Patched via Socket patch {GOOD_UUID}"), - "a go-patches redirect is applied, not vendored" - ); - - // (c) precedence: the vendored purl attests FROM THE VENDOR ARTIFACT — - // the (vendored) marker plus the deliberately-wrong go-patches decoy - // bytes make a flipped precedence fail this test in two ways. - let vend = stmts - .iter() - .find(|s| s["vulnerability"]["name"] == "GHSA-cg-go-vend") - .unwrap_or_else(|| panic!("vendored purl missing: {stdout}")); - assert_eq!(vend["products"][0]["subcomponents"][0]["@id"], vend_purl); - assert_eq!( - vend["impact_statement"].as_str().unwrap(), - format!("Patched via Socket patch {UUID} (vendored)"), - "the explicit vendor entry must win over the go-patches synthesis" - ); - - // (d) SECURITY: the tampered purl is omitted even though a decoy at the - // traversal target byte-matches its record — the coordinates guard must - // keep the out-of-tree path out of the verification map entirely. - assert!( - !stdout.contains("GHSA-cg-go-evil"), - "a tampered go.mod replace must never key an attestation:\n{stdout}" - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains(&evil_purl), - "the tampered purl must surface as an omission on stderr. got: {stderr}" - ); - - // (b) the stale replace (no manifest purl) leaks into nothing. - assert!( - !stdout.contains("github.com/stale"), - "a go-patches replace without a manifest record attests nothing:\n{stdout}" - ); - // (a) the version-less replace likewise attests nothing. - assert!( - !stdout.contains("github.com/noversion"), - "a version-less replace attests nothing:\n{stdout}" - ); -} - -/// #391 / #392: a go-patches replace pinned to a version the build list no -/// longer selects is inert — after `go get M@v1.4.3` (go >= 1.17 require), -/// or in a go 1.16 graph where go.sum shows the build selects another -/// version — so vex must omit the patch however intact its copy is. -#[test] -fn go_patches_replace_for_an_unselected_version_is_not_attested() { - let module = "github.com/foo/bar"; - let version = "v1.4.2"; - let purl = format!("pkg:golang/{module}@{version}"); - let replace = - format!("replace {module} {version} => ./.socket/go-patches/{module}@{version}\n"); - let selected_v117 = - format!("module example.com/app\n\ngo 1.21\n\nrequire {module} {version}\n\n{replace}"); - let bumped_v117 = - format!("module example.com/app\n\ngo 1.21\n\nrequire {module} v1.4.3\n\n{replace}"); - let go116 = - format!("module example.com/app\n\ngo 1.16\n\nrequire example.com/mid v1.0.0\n\n{replace}"); - let go116_sum = format!( - "{module} {version}/go.mod h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\ - {module} v1.5.0 h1:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\n" - ); - for (go_mod, go_sum, attests) in [ - (&selected_v117, None, true), - (&bumped_v117, None, false), - (&go116, Some(go116_sum.as_str()), false), - (&go116, None, true), - ] { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - std::fs::write(cwd.join("go.mod"), go_mod).unwrap(); - if let Some(sum) = go_sum { - std::fs::write(cwd.join("go.sum"), sum).unwrap(); - } - let patched = b"package bar // patched\n"; - let copy = cwd.join(format!(".socket/go-patches/{module}@{version}")); - std::fs::create_dir_all(©).unwrap(); - std::fs::write(copy.join("bar.go"), patched).unwrap(); - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - purl.clone(), - make_record( - GOOD_UUID, - "bar.go", - "a".repeat(64).as_str(), - &compute_git_sha256_from_bytes(patched), - "GHSA-go-unselected", - &["CVE-2026-391"], - ), - ); - write_manifest(cwd, &manifest); - let empty_cache = cwd.join("empty-gomodcache"); - std::fs::create_dir_all(&empty_cache).unwrap(); - - let out = cli() - .env("GOMODCACHE", &empty_cache) - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:golang/example.com/app@v0.0.1", - ]) - .output() - .expect("invoke vex"); - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - if attests { - assert!(out.status.success(), "{go_mod}\nstderr:\n{stderr}"); - assert!(stdout.contains("GHSA-go-unselected"), "{go_mod}\n{stdout}"); - } else { - assert_eq!(out.status.code(), Some(1), "{go_mod}\nstderr:\n{stderr}"); - assert!(!stdout.contains("not_affected"), "{go_mod}\n{stdout}"); - assert!( - stderr.contains("(go_version_not_selected)"), - "{go_mod}\nstderr:\n{stderr}" - ); - } - } -} - -/// #484: the patch API keys a `+incompatible` Go module as -/// `@v2.0.0%2Bincompatible`, while go.mod spells the replace's version with -/// a literal `+`. vex must match the two spellings: an applied go-patches -/// redirect attests (it used to be omitted as `not_applied`, exit 1), while -/// a go.work replace that overrides it, or a require that selects another -/// version, is still caught, never attested, and reported under the -/// record's own `%2B` spelling. -#[test] -fn go_patches_replace_of_an_incompatible_module_matches_the_encoded_key() { - let module = "example.com/legacy"; - let version = "v2.0.0+incompatible"; - // (omission reason, if any) - for omitted in [ - None, - Some("go_workspace_override"), - Some("go_version_not_selected"), - ] { - let overridden = omitted == Some("go_workspace_override"); - let required = if omitted == Some("go_version_not_selected") { - "v2.1.0+incompatible" - } else { - version - }; - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - std::fs::write( - cwd.join("go.mod"), - format!( - "module example.com/app\n\ngo 1.21\n\nrequire {module} {required}\n\n\ - replace {module} {version} => ./.socket/go-patches/{module}@{version}\n" - ), - ) - .unwrap(); - let go_work = cwd.join("go.work"); - if overridden { - std::fs::write( - &go_work, - format!("go 1.21\n\nuse .\n\nreplace {module} {version} => ./fork\n"), - ) - .unwrap(); - } - let patched = b"package legacy // patched\n"; - let copy = cwd.join(format!(".socket/go-patches/{module}@{version}")); - std::fs::create_dir_all(©).unwrap(); - std::fs::write(copy.join("lib.go"), patched).unwrap(); - let encoded = "pkg:golang/example.com/legacy@v2.0.0%2Bincompatible"; - let mut manifest = PatchManifest::new(); - manifest.patches.insert( - encoded.to_string(), - make_record( - GOOD_UUID, - "lib.go", - "a".repeat(64).as_str(), - &compute_git_sha256_from_bytes(patched), - "GHSA-go-incompatible", - &["CVE-2026-484"], - ), - ); - write_manifest(cwd, &manifest); - let empty_cache = cwd.join("empty-gomodcache"); - std::fs::create_dir_all(&empty_cache).unwrap(); - - let out = cli() - .env("GOMODCACHE", &empty_cache) - .env( - "GOWORK", - if overridden { - go_work.as_os_str() - } else { - "off".as_ref() - }, - ) - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:golang/example.com/app@v0.0.1", - ]) - .output() - .expect("invoke vex"); - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - if let Some(reason) = omitted { - assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}"); - assert!(!stdout.contains("not_affected"), "{stdout}"); - assert!( - stderr.contains(&format!("omitting {encoded} from VEX")) - && stderr.contains(&format!("({reason})")), - "stderr:\n{stderr}" - ); - } else { - assert!(out.status.success(), "stderr:\n{stderr}"); - let doc: Value = serde_json::from_str(&stdout).expect("VEX JSON on stdout"); - let stmts = doc["statements"].as_array().unwrap(); - assert_eq!(stmts.len(), 1, "{stdout}"); - assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-go-incompatible"); - assert_eq!(stmts[0]["status"], "not_affected"); - assert_eq!(stmts[0]["products"][0]["subcomponents"][0]["@id"], encoded); - } - } -} - -// ────────────────────────────────────────────────────────────────────── -// Corrupt vendor ledger on a MANIFEST-FREE project (the D2 vendored -// posture): the ledger was the only possible source of records, and it -// gates what attests — so the run fails `vendor_ledger_corrupt` (exit 2, -// the same hard error as with a manifest, see above) naming the unreadable -// ledger, instead of telling the operator a manifest they never had is -// missing. A read-only consumer never moves or rewrites the ledger, and -// `--silent` never mutes the error. -// ────────────────────────────────────────────────────────────────────── - -#[test] -fn corrupt_vendor_ledger_without_manifest_fails_naming_the_ledger() { - let tmp = tempfile::tempdir().unwrap(); - let cwd = tmp.path(); - let dir = cwd.join(".socket/vendor"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("state.json"), "not json").unwrap(); - - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}"); - assert!(out.stdout.is_empty(), "no document"); - assert!( - stderr.contains("vendor ledger") && stderr.contains("state.json"), - "the error must name the unreadable ledger: {stderr}" - ); - assert!( - stderr.contains("corrupt"), - "the error carries load_state's detail: {stderr}" - ); - assert!( - !stderr.contains("Manifest not found"), - "a missing manifest is not the problem here: {stderr}" - ); - assert_eq!( - std::fs::read(dir.join("state.json")).unwrap(), - b"not json", - "a read-only consumer never moves or rewrites the ledger" - ); - assert!(!dir.join("state.json.corrupt").exists()); - - let vex_path = cwd.join("out.vex.json"); - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--json", - "--output", - vex_path.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .output() - .expect("invoke vex --json"); - assert_eq!(out.status.code(), Some(2)); - let env: Value = serde_json::from_slice(&out.stdout).expect("envelope JSON on stdout"); - assert_eq!(env["error"]["code"], "vendor_ledger_corrupt", "{env}"); - assert!(!vex_path.exists(), "no document on a hard error"); - - let out = cli() - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - "--silent", - ]) - .output() - .expect("invoke vex --silent"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}"); - assert!( - stderr.contains("vendor ledger"), - "--silent never mutes the error: {stderr}" - ); -} diff --git a/crates/socket-patch-cli/tests/vex/main.rs b/crates/socket-patch-cli/tests/vex/main.rs new file mode 100644 index 000000000..2e301216e --- /dev/null +++ b/crates/socket-patch-cli/tests/vex/main.rs @@ -0,0 +1,15 @@ +//! `vex`: its `--json` failure contracts, the go-patches synthesis guards +//! and the human output (#1089). +//! +//! One test binary per command: `covgap_commands_vex.rs` and +//! `vex_terminal_output.rs` were each their own binary. +//! `cli_parse_vex.rs` stays separate because it mutates the process +//! environment under `serial_test`. + +#[path = "../common/mod.rs"] +mod common; + +mod vex_failure_paths; +mod vex_fixture; +mod vex_go_patches; +mod vex_render; diff --git a/crates/socket-patch-cli/tests/vex/vex_failure_paths.rs b/crates/socket-patch-cli/tests/vex/vex_failure_paths.rs new file mode 100644 index 000000000..e6ef99c16 --- /dev/null +++ b/crates/socket-patch-cli/tests/vex/vex_failure_paths.rs @@ -0,0 +1,299 @@ +//! `vex`'s `--json` contracts for its failure and advisory paths (#1089): +//! exit code, `status`, `error.code`, skipped events and their +//! `errorCode`s, `warnings[]` codes and the bytes on disk. A message is read +//! only for what it must name (a file). The human output of the same paths +//! is pinned in `vex_render.rs`. +//! +//! 1. a corrupt manifest (`manifest_invalid`), including the stale-doc +//! cleanup and a FIFO at `--output`; +//! 2. a malformed pre-v5 redirect ledger (`redirect_ledger_corrupt`, an +//! advisory); +//! 3. a corrupt vendor ledger (`vendor_ledger_corrupt`, a hard error), +//! with and without a manifest; +//! 4. the multi-manifest product auto-detect warning; +//! 5. dry runs, `-O -`, stale-document removal and omission order. + +use crate::vex_fixture::*; + +// ───────────────────── corrupt manifest ───────────────────── + +/// A present-but-corrupt manifest is the hard exit-2 `manifest_invalid` +/// (distinct from the missing-manifest `manifest_not_found`), and a run that +/// ends in error leaves no OpenVEX document at `--output`, not even a +/// previous run's. +#[test] +fn corrupt_manifest_is_manifest_invalid_and_removes_the_stale_doc() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_corrupt_manifest(cwd); + let out = cwd.join("out.vex.json"); + std::fs::write(&out, STALE_OPENVEX_DOC).unwrap(); + + let (o, env) = vex_json(cwd, &[]); + assert_eq!(o.status.code(), Some(2), "{env}"); + assert_eq!(env["status"], "error", "{env}"); + assert_eq!(envelope_error_code(&env), Some("manifest_invalid"), "{env}"); + assert!( + envelope_error_message(&env).is_some_and(|m| m.contains("manifest.json")), + "the error names the unreadable file: {env}" + ); + assert!(!out.exists(), "a failed run leaves no document at --output"); +} + +/// A failed run's stale-doc cleanup reads `--output` to check it is +/// OpenVEX before deleting it. A FIFO there must not block that read: the +/// run still fails promptly with its own error and leaves the FIFO alone. +#[cfg(unix)] +#[test] +fn failed_run_does_not_block_on_a_fifo_at_output() { + use std::os::unix::fs::FileTypeExt; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_corrupt_manifest(cwd); + let fifo = cwd.join("out.vex.json"); + assert!(Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success()); + + let mut child = vex_command(cwd, Some(PRODUCT)) + .args(["--json", "--output", fifo.to_str().unwrap()]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("invoke vex"); + let started = Instant::now(); + while child.try_wait().unwrap().is_none() { + if started.elapsed() > Duration::from_secs(60) { + let _ = child.kill(); + panic!("vex blocked reading the FIFO at --output"); + } + std::thread::sleep(Duration::from_millis(25)); + } + let o = child.wait_with_output().unwrap(); + let env = parse_json_envelope(&stdout(&o)); + assert_eq!(o.status.code(), Some(2), "{env}"); + assert_eq!(envelope_error_code(&env), Some("manifest_invalid"), "{env}"); + assert!( + std::fs::symlink_metadata(&fifo) + .unwrap() + .file_type() + .is_fifo(), + "a FIFO is not an OpenVEX document and must not be removed" + ); +} + +// ───────────────────── pre-v5 redirect ledger ───────────────────── + +/// v5 hosted mode keeps no ledger, so a malformed pre-v5 +/// `.socket/vendor/redirect-state.json` is only an advisory: its records are +/// not consulted and the run reaches its own outcome. With a manifest whose +/// package is not installed that is `no_applicable_patches` (exit 1); with +/// nothing else on disk it is `manifest_not_found` (exit 2). Either way the +/// warning travels in the envelope and the file is left alone. +#[test] +fn corrupt_redirect_ledger_is_an_advisory_and_the_run_reaches_its_own_outcome() { + let purl = "pkg:npm/leftpad@1.0.0"; + for (with_manifest, exit, code) in [ + (true, 1, "no_applicable_patches"), + (false, 2, "manifest_not_found"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + if with_manifest { + write_npm_manifest(cwd, &[purl]); + } + write_corrupt_redirect_ledger(cwd); + + let (o, env) = vex_json(cwd, &[]); + assert_eq!(o.status.code(), Some(exit), "{env}"); + assert_eq!(envelope_error_code(&env), Some(code), "{env}"); + if with_manifest { + assert!( + has_event(&env, "skipped", "package_not_found", purl), + "the manifest patch was evaluated: {env}" + ); + } + let warning = env["warnings"] + .as_array() + .into_iter() + .flatten() + .find(|w| w["code"] == "redirect_ledger_corrupt") + .unwrap_or_else(|| panic!("redirect_ledger_corrupt expected: {env}")); + assert!( + warning["detail"] + .as_str() + .is_some_and(|d| d.contains("redirect-state.json")), + "the warning names the ledger: {env}" + ); + assert!( + !stderr(&o).contains("redirect-state.json"), + "--json keeps the warning off stderr: {}", + stderr(&o) + ); + assert_redirect_ledger_untouched(cwd); + assert!(!cwd.join("out.vex.json").exists()); + } +} + +// ───────────────────── corrupt vendor ledger ───────────────────── + +/// Unlike the pre-v5 redirect ledger, the vendor ledger is an attestation +/// input (embedded records, and the wiring that gates them), so a corrupt +/// one fails closed with `vendor_ledger_corrupt` before any verification, +/// whether or not a manifest exists, instead of attesting from the rest or +/// blaming a manifest the project never had. +#[test] +fn corrupt_vendor_ledger_fails_closed_before_verification() { + for with_manifest in [true, false] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + if with_manifest { + write_npm_manifest(cwd, &["pkg:npm/leftpad@1.0.0"]); + } + write_corrupt_vendor_ledger(cwd); + + let (o, env) = vex_json(cwd, &[]); + assert_eq!(o.status.code(), Some(2), "{env}"); + assert_eq!(env["status"], "error", "{env}"); + assert_eq!( + envelope_error_code(&env), + Some("vendor_ledger_corrupt"), + "{env}" + ); + assert!( + envelope_error_message(&env).is_some_and(|m| m.contains("state.json")), + "the error names the unreadable ledger: {env}" + ); + assert!( + events(&env).is_empty(), + "no per-patch events on a pre-verification hard error: {env}" + ); + assert_vendor_ledger_untouched(cwd); + assert!(!cwd.join("out.vex.json").exists(), "no document"); + } +} + +// ───────────────────── product auto-detect ───────────────────── + +/// With package.json and Cargo.toml side by side, auto-detect picks +/// package.json and says so as a `product_multiple_manifests` warning, in +/// the envelope only. +#[test] +fn multi_manifest_auto_detect_warns_in_the_envelope() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_multi_manifest_project(cwd); + let out = cwd.join("out.vex.json"); + + let mut cmd = vex_command(cwd, None); + cmd.arg("--no-verify"); + let (o, env) = run_json(cmd, &out); + assert_eq!(o.status.code(), Some(0), "{env}"); + assert_eq!(warning_codes(&env), ["product_multiple_manifests"], "{env}"); + assert!( + env["warnings"][0]["detail"] + .as_str() + .is_some_and(|d| d.contains("package.json")), + "the warning names the manifest used: {env}" + ); + assert!(stderr(&o).is_empty(), "{}", stderr(&o)); + assert_eq!( + read_doc(&out)["statements"][0]["products"][0]["@id"], + PRODUCT + ); +} + +// ───────────────────── output contract ───────────────────── + +#[test] +fn json_dry_run_writes_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); + + let (o, env) = vex_json(cwd, &["--no-verify", "--dry-run"]); + assert_eq!(o.status.code(), Some(0), "{env}"); + assert_eq!(env["dryRun"], true, "{env}"); + assert!(!cwd.join("out.vex.json").exists()); +} + +/// Under `--json` the envelope owns stdout, so `-O -` is refused. +#[test] +fn json_with_output_dash_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); + + let o = vex(cwd, &["--no-verify", "--json", "-O", "-"]); + let env = parse_json_envelope(&stdout(&o)); + assert_eq!(o.status.code(), Some(2), "{env}"); + assert_eq!( + envelope_error_code(&env), + Some("json_requires_output"), + "{env}" + ); + assert!(!cwd.join("-").exists()); +} + +#[test] +fn failed_run_reports_stale_doc_removal_as_a_warning_code() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); + let out = cwd.join("out.vex.json"); + std::fs::write(&out, STALE_OPENVEX_DOC).unwrap(); + + let (o, env) = vex_json(cwd, &[]); + assert_eq!(o.status.code(), Some(1), "{env}"); + assert!( + warning_codes(&env).contains(&"vex_stale_doc_removed".to_string()), + "{env}" + ); + assert!(!out.exists()); + assert!(stderr(&o).is_empty(), "{}", stderr(&o)); +} + +/// Omitted patches come out as `skipped` events sorted by purl, whatever +/// the manifest order. +#[test] +fn omitted_patches_are_sorted_skipped_events() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_npm_manifest( + cwd, + &[ + "pkg:npm/zeta@1.0.0", + "pkg:npm/alpha@1.0.0", + "pkg:npm/mid@1.0.0", + ], + ); + + let (o, env) = vex_json(cwd, &[]); + assert_eq!(o.status.code(), Some(1), "{env}"); + assert_eq!( + envelope_error_code(&env), + Some("no_applicable_patches"), + "{env}" + ); + assert_eq!( + event_purls(&env, "skipped"), + [ + "pkg:npm/alpha@1.0.0", + "pkg:npm/mid@1.0.0", + "pkg:npm/zeta@1.0.0" + ], + "{env}" + ); + assert!( + events(&env) + .iter() + .all(|e| e["errorCode"] == "package_not_found" && e["reason"].is_string()), + "{env}" + ); +} diff --git a/crates/socket-patch-cli/tests/vex/vex_fixture.rs b/crates/socket-patch-cli/tests/vex/vex_fixture.rs new file mode 100644 index 000000000..7e6d3c6e3 --- /dev/null +++ b/crates/socket-patch-cli/tests/vex/vex_fixture.rs @@ -0,0 +1,223 @@ +//! Shared fixture for the `vex` suite (#1089): the manifest and ledger +//! writers and the runners `vex_failure_paths.rs` (the `--json` contracts), +//! `vex_go_patches.rs` (the go-patches synthesis guards) and `vex_render.rs` +//! (the human output) build on. +//! +//! Every run drives the built binary in a self-contained tempdir project +//! with the hermetic `SOCKET_*` environment; the parent env is never +//! mutated, so no test needs serialization. + +// Each suite uses a different subset, and some only from `cfg(unix)` tests. +#![allow(dead_code, unused_imports)] + +use std::collections::HashMap; +use std::path::Path; +use std::process::{Command, Output}; + +pub(crate) use serde_json::Value; +use socket_patch_core::manifest::schema::{ + PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, +}; + +use crate::common; +pub(crate) use crate::common::envelope::{ + assert_envelope_invariants, envelope_error_code, envelope_error_message, event_purls, events, + has_event, parse_json_envelope, warning_codes, +}; + +/// The product every run names unless it tests auto-detection. +pub(crate) const PRODUCT: &str = "pkg:npm/app@1.0.0"; + +/// A prior successful run's minimal-but-recognizable OpenVEX document (the +/// `@context` names openvex.dev, which is what `remove_stale_vex_doc` keys +/// its deletion guard on). +pub(crate) const STALE_OPENVEX_DOC: &str = r#"{"@context":"https://openvex.dev/ns/v0.2.0","@id":"urn:uuid:stale","author":"Socket","timestamp":"2020-01-01T00:00:00Z","version":1,"statements":[]}"#; + +/// The bytes of a malformed pre-v5 redirect ledger. +pub(crate) const CORRUPT_REDIRECT_LEDGER: &str = "{{{"; + +/// The bytes of a corrupt vendor ledger. +pub(crate) const CORRUPT_VENDOR_LEDGER: &str = "not json"; + +/// Patch record with one file (whose hashes you choose) and one +/// vulnerability. +pub(crate) fn make_record( + uuid: &str, + file_name: &str, + before_hash: &str, + after_hash: &str, + vuln_id: &str, + cves: &[&str], +) -> PatchRecord { + let mut files = HashMap::new(); + files.insert( + file_name.to_string(), + PatchFileInfo { + before_hash: before_hash.to_string(), + after_hash: after_hash.to_string(), + }, + ); + let mut vulns = HashMap::new(); + vulns.insert( + vuln_id.to_string(), + VulnerabilityInfo { + cves: cves.iter().map(|s| s.to_string()).collect(), + summary: "test summary".to_string(), + severity: "high".to_string(), + description: "test description".to_string(), + }, + ); + PatchRecord { + uuid: uuid.to_string(), + exported_at: "2024-01-01T00:00:00Z".to_string(), + files, + vulnerabilities: vulns, + description: format!("Patch {uuid}"), + license: "MIT".to_string(), + tier: "free".to_string(), + } +} + +/// Write `manifest` to `/.socket/manifest.json`. +pub(crate) fn write_manifest(cwd: &Path, manifest: &PatchManifest) { + let dir = cwd.join(".socket"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("manifest.json"), + serde_json::to_string_pretty(manifest).unwrap(), + ) + .unwrap(); +} + +/// A manifest with one npm patch per purl (each with a distinct GHSA, +/// `GHSA-test-`), none of them on disk: verify mode omits each as +/// `package_not_found`, and `--no-verify` attests them all. +pub(crate) fn write_npm_manifest(cwd: &Path, purls: &[&str]) { + let mut manifest = PatchManifest::new(); + for (i, purl) in purls.iter().enumerate() { + manifest.patches.insert( + purl.to_string(), + make_record( + &format!("{:08}-1111-4111-8111-111111111111", i + 1), + "package/index.js", + &"a".repeat(64), + &"b".repeat(64), + &format!("GHSA-test-{i}"), + &["CVE-2026-0001"], + ), + ); + } + write_manifest(cwd, &manifest); +} + +/// A present-but-unparseable `.socket/manifest.json`. +pub(crate) fn write_corrupt_manifest(cwd: &Path) { + std::fs::create_dir_all(cwd.join(".socket")).unwrap(); + std::fs::write(cwd.join(".socket/manifest.json"), "{not json").unwrap(); +} + +/// A malformed pre-v5 redirect ledger at its canonical path. +pub(crate) fn write_corrupt_redirect_ledger(cwd: &Path) { + let dir = cwd.join(".socket/vendor"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("redirect-state.json"), CORRUPT_REDIRECT_LEDGER).unwrap(); +} + +/// vex is a read-only consumer: the malformed pre-v5 ledger stays +/// byte-identical and is never quarantined. +pub(crate) fn assert_redirect_ledger_untouched(cwd: &Path) { + let ledger = cwd.join(".socket/vendor/redirect-state.json"); + assert_eq!( + std::fs::read_to_string(&ledger).unwrap(), + CORRUPT_REDIRECT_LEDGER + ); + assert!(!cwd + .join(".socket/vendor/redirect-state.json.corrupt") + .exists()); +} + +/// A corrupt vendor ledger at `.socket/vendor/state.json`. +pub(crate) fn write_corrupt_vendor_ledger(cwd: &Path) { + let dir = cwd.join(".socket/vendor"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("state.json"), CORRUPT_VENDOR_LEDGER).unwrap(); +} + +/// The vendor ledger may hold the only revert data for committed vendoring, +/// so vex never moves or rewrites it. +pub(crate) fn assert_vendor_ledger_untouched(cwd: &Path) { + let dir = cwd.join(".socket/vendor"); + assert_eq!( + std::fs::read_to_string(dir.join("state.json")).unwrap(), + CORRUPT_VENDOR_LEDGER + ); + assert!(!dir.join("state.json.corrupt").exists()); +} + +/// package.json AND Cargo.toml (no .git) plus a one-patch manifest: +/// auto-detect must pick package.json, so the product is `PRODUCT`. +pub(crate) fn write_multi_manifest_project(cwd: &Path) { + std::fs::write( + cwd.join("package.json"), + r#"{"name":"app","version":"1.0.0"}"#, + ) + .unwrap(); + std::fs::write( + cwd.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"1.0.0\"\n", + ) + .unwrap(); + write_npm_manifest(cwd, &["pkg:npm/x@1.0.0"]); +} + +/// `vex --cwd ` (plus `--product` when given) in the hermetic env, +/// run from `cwd`. +pub(crate) fn vex_command(cwd: &Path, product: Option<&str>) -> Command { + let mut cmd = common::hermetic_command(&common::binary()); + cmd.current_dir(cwd) + .args(["vex", "--cwd", cwd.to_str().unwrap()]); + if let Some(product) = product { + cmd.args(["--product", product]); + } + cmd +} + +/// `vex` for `PRODUCT` with `extra` flags. +pub(crate) fn vex(cwd: &Path, extra: &[&str]) -> Output { + vex_command(cwd, Some(PRODUCT)) + .args(extra) + .output() + .expect("invoke vex") +} + +/// `cmd --json --output `: the output and its parsed envelope, checked +/// against the shared envelope invariants. +pub(crate) fn run_json(mut cmd: Command, out: &Path) -> (Output, Value) { + let o = cmd + .args(["--json", "--output", out.to_str().unwrap()]) + .output() + .expect("invoke vex --json"); + let env = parse_json_envelope(&stdout(&o)); + assert_envelope_invariants(&env, "vex"); + (o, env) +} + +/// `vex --json --output /out.vex.json` for `PRODUCT` with `extra`. +pub(crate) fn vex_json(cwd: &Path, extra: &[&str]) -> (Output, Value) { + let mut cmd = vex_command(cwd, Some(PRODUCT)); + cmd.args(extra); + run_json(cmd, &cwd.join("out.vex.json")) +} + +/// The OpenVEX document a successful run wrote to `path`. +pub(crate) fn read_doc(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +pub(crate) fn stderr(o: &Output) -> String { + String::from_utf8_lossy(&o.stderr).into_owned() +} + +pub(crate) fn stdout(o: &Output) -> String { + String::from_utf8_lossy(&o.stdout).into_owned() +} diff --git a/crates/socket-patch-cli/tests/vex/vex_go_patches.rs b/crates/socket-patch-cli/tests/vex/vex_go_patches.rs new file mode 100644 index 000000000..39032d882 --- /dev/null +++ b/crates/socket-patch-cli/tests/vex/vex_go_patches.rs @@ -0,0 +1,426 @@ +//! The guards `synthesize_go_patches` applies to the tamper-able `go.mod` +//! replace set, read through `vex --json` (#1089): which purls attest in the +//! document at `--output`, and which come out as `skipped` events with which +//! `errorCode`. +//! +//! Owner classification is by target-path prefix (`.socket/go-patches/`), +//! so hand-written lines reach every guard: +//! (a) version-less directory replace → skipped +//! (b) go-patches replace with no manifest purl → skipped +//! (c) explicit (LIVE) vendor entry takes precedence → skipped +//! (d) SECURITY: unsafe module coordinates → skipped — the +//! only end-to-end proof that a tampered go.mod cannot key an +//! out-of-tree path into VEX verification (the inline unit test only +//! pins the predicate, not that the synthesis consults it). + +use std::ffi::OsStr; +use std::path::Path; + +use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; +use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::vendor::state::{ + VendorArtifact, VendorEntry, VendorState, WiringAction, WiringRecord, +}; + +use crate::vex_fixture::*; + +/// Canonical-grammar patch UUID (the vendored-artifact verifier validates +/// the uuid path level, so fixtures must use the real shape). +const UUID: &str = "9f6b2c4e-1d3a-4f6b-8c2d-7e5a9b1c3d5f"; +const GOOD_UUID: &str = "22222222-2222-4222-8222-222222222222"; +const EVIL_UUID: &str = "33333333-3333-4333-8333-333333333333"; +const GO_PRODUCT: &str = "pkg:golang/example.com/app@v0.0.1"; + +/// Non-detached golang vendor-ledger entry (the manifest record is the +/// verification oracle) whose dir-shaped artifact lives at `rel_path`, +/// recording its `go.mod` replace wiring — vex only honors a vendor entry +/// whose artifact some lockfile/config still references, and the ledger's +/// recorded wiring files are that proof for formats lockfile discovery does +/// not read. +fn write_golang_vendor_state(cwd: &Path, purl: &str, rel_path: &str) { + let mut state = VendorState::new(); + state.entries.insert( + purl.to_string(), + VendorEntry::new( + "golang".to_string(), + purl.to_string(), + UUID.to_string(), + VendorArtifact { + yarn_berry10c0: None, + path: rel_path.to_string(), + sha256: String::new(), + size: None, + platform_locked: None, + file_inventory: None, + }, + vec![WiringRecord { + file: "go.mod".to_string(), + kind: "go_replace".to_string(), + action: WiringAction::Added, + key: None, + original: None, + new: None, + }], + ), + ); + let dir = cwd.join(".socket/vendor"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .unwrap(); +} + +/// `vex --json` for the Go product against an EMPTY module cache (so a purl +/// the synthesis refuses has nowhere else to verify), with extra env. +fn vex_go(cwd: &Path, env: &[(&str, &OsStr)]) -> (std::process::Output, Value) { + let cache = cwd.join("empty-gomodcache"); + std::fs::create_dir_all(&cache).unwrap(); + let mut cmd = vex_command(cwd, Some(GO_PRODUCT)); + cmd.env("GOMODCACHE", &cache); + for (k, v) in env { + cmd.env(k, v); + } + run_json(cmd, &cwd.join("out.vex.json")) +} + +#[test] +fn go_patches_synthesis_skips_tampered_and_stale_replaces() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + // ── GOOD module: exactly the fixture shape of + // e2e_vex_vendor::golang_go_patches_redirect_attested_without_module_cache + // — the control proving the synthesis DID run on this go.mod. + let good_module = "github.com/foo/bar"; + let good_version = "v1.4.2"; + let good_purl = format!("pkg:golang/{good_module}@{good_version}"); + std::fs::write( + cwd.join("go.mod"), + format!("module example.com/app\n\ngo 1.21\n\nrequire {good_module} {good_version}\n"), + ) + .unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on( + socket_patch_core::vendor::go_mod_edit::ensure_replace_entry( + cwd, + good_module, + good_version, + socket_patch_core::vendor::go_mod_edit::GO_PATCHES_DIR, + false, + ), + ) + .expect("write go.mod replace"); + let good_patched = b"package bar // patched\n"; + let good_after = compute_git_sha256_from_bytes(good_patched); + let good_copy = cwd.join(format!(".socket/go-patches/{good_module}@{good_version}")); + std::fs::create_dir_all(&good_copy).unwrap(); + std::fs::write(good_copy.join("bar.go"), good_patched).unwrap(); + + // ── (c) VENDORED module: manifest purl with BOTH a go-patches replace + // line AND an explicit vendor entry. The vendor artifact holds the + // hash-matching bytes; the go-patches copy dir holds DIFFERENT bytes — + // so if the vendor-entry precedence guard were dropped (synthesis wins), + // verification would hash the wrong bytes and the purl would vanish + // from the doc. The "(vendored)" marker pins the winning provenance. + let vend_module = "github.com/vendored/mod"; + let vend_version = "v1.0.0"; + let vend_purl = format!("pkg:golang/{vend_module}@{vend_version}"); + let vend_patched = b"package mod // vendored patched\n"; + let vend_after = compute_git_sha256_from_bytes(vend_patched); + let vend_rel = format!(".socket/vendor/golang/{UUID}/{vend_module}@{vend_version}"); + let vend_dir = cwd.join(&vend_rel); + std::fs::create_dir_all(&vend_dir).unwrap(); + std::fs::write(vend_dir.join("mod.go"), vend_patched).unwrap(); + write_golang_vendor_state(cwd, &vend_purl, &vend_rel); + let vend_decoy = cwd.join(format!(".socket/go-patches/{vend_module}@{vend_version}")); + std::fs::create_dir_all(&vend_decoy).unwrap(); + std::fs::write(vend_decoy.join("mod.go"), b"stale go-patches copy\n").unwrap(); + + // ── (d) TAMPERED module: unsafe traversal coordinates in a hand-written + // socket-owned replace, plus a manifest record. A hash-MATCHING decoy is + // planted at the location the traversal resolves to + // (.socket/go-patches/github.com/foo/../../../etc@v1.0.0 → .socket/etc@v1.0.0), + // so an implementation missing the are_safe_redirect_coords guard would + // byte-verify the decoy and falsely attest. + let evil_module = "github.com/foo/../../../etc"; + let evil_version = "v1.0.0"; + let evil_purl = format!("pkg:golang/{evil_module}@{evil_version}"); + let evil_patched = b"package etc // evil decoy\n"; + let evil_after = compute_git_sha256_from_bytes(evil_patched); + let evil_decoy_dir = cwd.join(".socket").join(format!("etc@{evil_version}")); + std::fs::create_dir_all(&evil_decoy_dir).unwrap(); + std::fs::write(evil_decoy_dir.join("evil.go"), evil_patched).unwrap(); + + // Hand-append the tampered/stale replace lines exactly as an attacker + // (or a stale tool) could commit them; the `.socket/go-patches/` target + // prefix classifies every one of them as socket-owned. (c) also keeps + // its LIVE vendor wiring — the vendor-over-go-patches takeover shape + // with the stale go-patches line left behind: without it the vendor + // entry wires nothing, and vex now omits such a leftover entry as + // `vendor_unwired` instead of letting it route verification at all. + let mut go_mod = std::fs::read_to_string(cwd.join("go.mod")).unwrap(); + go_mod.push_str(&format!( + "\nreplace github.com/noversion => ./.socket/go-patches/x\n\ + replace github.com/stale v1.0.0 => ./.socket/go-patches/github.com/stale@v1.0.0\n\ + replace {vend_module} {vend_version} => ./{vend_rel}\n\ + replace {vend_module} {vend_version} => ./.socket/go-patches/{vend_module}@{vend_version}\n\ + replace {evil_module} {evil_version} => ./.socket/go-patches/{evil_module}@{evil_version}\n" + )); + std::fs::write(cwd.join("go.mod"), go_mod).unwrap(); + // (b)'s stale copy dir exists with plausible bytes so only the missing + // manifest entry (not a missing dir) can explain its absence downstream. + let stale_copy = cwd.join(".socket/go-patches/github.com/stale@v1.0.0"); + std::fs::create_dir_all(&stale_copy).unwrap(); + std::fs::write(stale_copy.join("stale.go"), b"package stale\n").unwrap(); + + let mut manifest = PatchManifest::new(); + for (purl, uuid, file, after, ghsa, cve) in [ + ( + &good_purl, + GOOD_UUID, + "bar.go", + &good_after, + "GHSA-cg-go-good", + "CVE-2026-301", + ), + ( + &vend_purl, + UUID, + "mod.go", + &vend_after, + "GHSA-cg-go-vend", + "CVE-2026-302", + ), + ( + &evil_purl, + EVIL_UUID, + "evil.go", + &evil_after, + "GHSA-cg-go-evil", + "CVE-2026-303", + ), + ] { + manifest.patches.insert( + purl.clone(), + make_record(uuid, file, &"a".repeat(64), after, ghsa, &[cve]), + ); + } + write_manifest(cwd, &manifest); + + let (o, env) = vex_go(cwd, &[]); + assert_eq!( + o.status.code(), + Some(0), + "the good + vendored purls must still attest: {env}" + ); + let doc = read_doc(&cwd.join("out.vex.json")); + let text = doc.to_string(); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!( + stmts.len(), + 2, + "exactly the good redirect + the vendored purl attest; the \ + version-less, stale, and tampered replaces are all skipped. doc: {doc:#}" + ); + + // Control (the synthesis ran): the good module attests from the + // go-patches copy dir with the PLAIN (non-vendored) impact statement. + let good = stmts + .iter() + .find(|s| s["vulnerability"]["name"] == "GHSA-cg-go-good") + .unwrap_or_else(|| panic!("good redirect purl missing: {doc:#}")); + assert_eq!(good["products"][0]["subcomponents"][0]["@id"], good_purl); + assert_eq!( + good["impact_statement"], + format!("Patched via Socket patch {GOOD_UUID}"), + "a go-patches redirect is applied, not vendored" + ); + + // (c) precedence: the vendored purl attests FROM THE VENDOR ARTIFACT — + // the (vendored) marker plus the deliberately-wrong go-patches decoy + // bytes make a flipped precedence fail this test in two ways. + let vend = stmts + .iter() + .find(|s| s["vulnerability"]["name"] == "GHSA-cg-go-vend") + .unwrap_or_else(|| panic!("vendored purl missing: {doc:#}")); + assert_eq!(vend["products"][0]["subcomponents"][0]["@id"], vend_purl); + assert_eq!( + vend["impact_statement"], + format!("Patched via Socket patch {UUID} (vendored)"), + "the explicit vendor entry must win over the go-patches synthesis" + ); + + // (d) SECURITY: the tampered purl is omitted even though a decoy at the + // traversal target byte-matches its record — the coordinates guard must + // keep the out-of-tree path out of the verification map entirely. + assert!( + !text.contains("GHSA-cg-go-evil"), + "a tampered go.mod replace must never key an attestation: {doc:#}" + ); + assert_eq!( + event_purls(&env, "skipped"), + [evil_purl.as_str()], + "the tampered purl is the one omission: {env}" + ); + + // (a) and (b): the version-less replace and the stale replace (no + // manifest purl) leak into nothing. + for leaked in ["github.com/stale", "github.com/noversion"] { + assert!(!text.contains(leaked), "{leaked} attests nothing: {doc:#}"); + assert!(!env.to_string().contains(leaked), "{leaked}: {env}"); + } +} + +/// #391 / #392: a go-patches replace pinned to a version the build list no +/// longer selects is inert — after `go get M@v1.4.3` (go >= 1.17 require), +/// or in a go 1.16 graph where go.sum shows the build selects another +/// version — so vex must omit the patch however intact its copy is. +#[test] +fn go_patches_replace_for_an_unselected_version_is_not_attested() { + let module = "github.com/foo/bar"; + let version = "v1.4.2"; + let purl = format!("pkg:golang/{module}@{version}"); + let replace = + format!("replace {module} {version} => ./.socket/go-patches/{module}@{version}\n"); + let selected_v117 = + format!("module example.com/app\n\ngo 1.21\n\nrequire {module} {version}\n\n{replace}"); + let bumped_v117 = + format!("module example.com/app\n\ngo 1.21\n\nrequire {module} v1.4.3\n\n{replace}"); + let go116 = + format!("module example.com/app\n\ngo 1.16\n\nrequire example.com/mid v1.0.0\n\n{replace}"); + let go116_sum = format!( + "{module} {version}/go.mod h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\ + {module} v1.5.0 h1:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\n" + ); + for (go_mod, go_sum, attests) in [ + (&selected_v117, None, true), + (&bumped_v117, None, false), + (&go116, Some(go116_sum.as_str()), false), + (&go116, None, true), + ] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::write(cwd.join("go.mod"), go_mod).unwrap(); + if let Some(sum) = go_sum { + std::fs::write(cwd.join("go.sum"), sum).unwrap(); + } + let patched = b"package bar // patched\n"; + let copy = cwd.join(format!(".socket/go-patches/{module}@{version}")); + std::fs::create_dir_all(©).unwrap(); + std::fs::write(copy.join("bar.go"), patched).unwrap(); + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + purl.clone(), + make_record( + GOOD_UUID, + "bar.go", + &"a".repeat(64), + &compute_git_sha256_from_bytes(patched), + "GHSA-go-unselected", + &["CVE-2026-391"], + ), + ); + write_manifest(cwd, &manifest); + + let (o, env) = vex_go(cwd, &[]); + if attests { + assert_eq!(o.status.code(), Some(0), "{go_mod}\n{env}"); + let doc = read_doc(&cwd.join("out.vex.json")); + assert_eq!( + doc["statements"][0]["vulnerability"]["name"], "GHSA-go-unselected", + "{go_mod}\n{doc:#}" + ); + } else { + assert_eq!(o.status.code(), Some(1), "{go_mod}\n{env}"); + assert!( + has_event(&env, "skipped", "go_version_not_selected", &purl), + "{go_mod}\n{env}" + ); + assert!(!cwd.join("out.vex.json").exists(), "{go_mod}"); + } + } +} + +/// #484: the patch API keys a `+incompatible` Go module as +/// `@v2.0.0%2Bincompatible`, while go.mod spells the replace's version with +/// a literal `+`. vex must match the two spellings: an applied go-patches +/// redirect attests (it used to be omitted as `not_applied`, exit 1), while +/// a go.work replace that overrides it, or a require that selects another +/// version, is still caught, never attested, and reported under the +/// record's own `%2B` spelling. +#[test] +fn go_patches_replace_of_an_incompatible_module_matches_the_encoded_key() { + let module = "example.com/legacy"; + let version = "v2.0.0+incompatible"; + let encoded = "pkg:golang/example.com/legacy@v2.0.0%2Bincompatible"; + // (omission reason, if any) + for omitted in [ + None, + Some("go_workspace_override"), + Some("go_version_not_selected"), + ] { + let overridden = omitted == Some("go_workspace_override"); + let required = if omitted == Some("go_version_not_selected") { + "v2.1.0+incompatible" + } else { + version + }; + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::write( + cwd.join("go.mod"), + format!( + "module example.com/app\n\ngo 1.21\n\nrequire {module} {required}\n\n\ + replace {module} {version} => ./.socket/go-patches/{module}@{version}\n" + ), + ) + .unwrap(); + let go_work = cwd.join("go.work"); + if overridden { + std::fs::write( + &go_work, + format!("go 1.21\n\nuse .\n\nreplace {module} {version} => ./fork\n"), + ) + .unwrap(); + } + let patched = b"package legacy // patched\n"; + let copy = cwd.join(format!(".socket/go-patches/{module}@{version}")); + std::fs::create_dir_all(©).unwrap(); + std::fs::write(copy.join("lib.go"), patched).unwrap(); + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + encoded.to_string(), + make_record( + GOOD_UUID, + "lib.go", + &"a".repeat(64), + &compute_git_sha256_from_bytes(patched), + "GHSA-go-incompatible", + &["CVE-2026-484"], + ), + ); + write_manifest(cwd, &manifest); + + let gowork: &OsStr = if overridden { + go_work.as_os_str() + } else { + OsStr::new("off") + }; + let (o, env) = vex_go(cwd, &[("GOWORK", gowork)]); + if let Some(reason) = omitted { + assert_eq!(o.status.code(), Some(1), "{env}"); + assert!(has_event(&env, "skipped", reason, encoded), "{env}"); + assert!(!cwd.join("out.vex.json").exists()); + } else { + assert_eq!(o.status.code(), Some(0), "{env}"); + let doc = read_doc(&cwd.join("out.vex.json")); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1, "{doc:#}"); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-go-incompatible"); + assert_eq!(stmts[0]["status"], "not_affected"); + assert_eq!(stmts[0]["products"][0]["subcomponents"][0]["@id"], encoded); + } + } +} diff --git a/crates/socket-patch-cli/tests/vex_terminal_output.rs b/crates/socket-patch-cli/tests/vex/vex_render.rs similarity index 60% rename from crates/socket-patch-cli/tests/vex_terminal_output.rs rename to crates/socket-patch-cli/tests/vex/vex_render.rs index 1f2ce0b50..6a1449905 100644 --- a/crates/socket-patch-cli/tests/vex_terminal_output.rs +++ b/crates/socket-patch-cli/tests/vex/vex_render.rs @@ -1,106 +1,16 @@ -//! Terminal-output contract for the standalone `vex` command: what a human -//! (and a `--json` consumer) sees for dry runs, `-O -`, stale-document -//! cleanup, omission reporting and flag typos. Fixtures are self-contained -//! tempdir projects driven through the built binary with the hermetic -//! `SOCKET_*` environment (the parent env is never mutated). +//! What a human sees from the standalone `vex` command (#1089): dry runs, +//! `-O -`, the written-document summary, stale-document cleanup, omission +//! reporting, flag typos and the error and warning lines of the failure +//! paths whose `--json` contracts live in `vex_failure_paths.rs`. One check +//! per message family. -#[path = "common/hermetic.rs"] -mod hermetic; - -use std::collections::HashMap; -use std::path::Path; -use std::process::{Command, Output}; - -use serde_json::Value; -use socket_patch_core::manifest::schema::{ - PatchFileInfo, PatchManifest, PatchRecord, VulnerabilityInfo, -}; - -const STALE_OPENVEX_DOC: &str = r#"{"@context":"https://openvex.dev/ns/v0.2.0","@id":"urn:uuid:stale","author":"Socket","timestamp":"2020-01-01T00:00:00Z","version":1,"statements":[]}"#; - -fn cli() -> Command { - hermetic::binary_command() -} - -fn record(uuid: &str, ghsa: &str) -> PatchRecord { - let mut files = HashMap::new(); - files.insert( - "package/index.js".to_string(), - PatchFileInfo { - before_hash: "a".repeat(64), - after_hash: "b".repeat(64), - }, - ); - let mut vulns = HashMap::new(); - vulns.insert( - ghsa.to_string(), - VulnerabilityInfo { - cves: vec!["CVE-2026-0001".to_string()], - summary: "s".to_string(), - severity: "high".to_string(), - description: "d".to_string(), - }, - ); - PatchRecord { - uuid: uuid.to_string(), - exported_at: "2024-01-01T00:00:00Z".to_string(), - files, - vulnerabilities: vulns, - description: "p".to_string(), - license: "MIT".to_string(), - tier: "free".to_string(), - } -} - -/// A manifest with `purls` (each with a distinct GHSA). -fn write_manifest(cwd: &Path, purls: &[&str]) { - let mut m = PatchManifest::new(); - for (i, purl) in purls.iter().enumerate() { - m.patches.insert( - purl.to_string(), - record( - &format!("{:08}-1111-4111-8111-111111111111", i + 1), - &format!("GHSA-test-{i}"), - ), - ); - } - let dir = cwd.join(".socket"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join("manifest.json"), - serde_json::to_string_pretty(&m).unwrap(), - ) - .unwrap(); -} - -fn vex(cwd: &Path, extra: &[&str]) -> Output { - cli() - .current_dir(cwd) - .args([ - "vex", - "--cwd", - cwd.to_str().unwrap(), - "--product", - "pkg:npm/app@1.0.0", - ]) - .args(extra) - .output() - .expect("invoke vex") -} - -fn stderr(o: &Output) -> String { - String::from_utf8_lossy(&o.stderr).into_owned() -} - -fn stdout(o: &Output) -> String { - String::from_utf8_lossy(&o.stdout).into_owned() -} +use crate::vex_fixture::*; #[test] fn dry_run_with_output_writes_nothing_and_says_so() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"]); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); let out_path = cwd.join("d.json"); let o = vex( @@ -116,21 +26,6 @@ fn dry_run_with_output_writes_nothing_and_says_so() { out_path.display() ) ); - - let o = vex( - cwd, - &[ - "--no-verify", - "--dry-run", - "--json", - "-O", - out_path.to_str().unwrap(), - ], - ); - assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); - let env: Value = serde_json::from_slice(&o.stdout).expect("envelope"); - assert_eq!(env["dryRun"], true, "{env}"); - assert!(!out_path.exists()); } #[test] @@ -138,7 +33,7 @@ fn dry_run_failure_keeps_previous_document() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); // Verify mode with nothing installed: nothing attests. - write_manifest(cwd, &["pkg:npm/a@1.0.0"]); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); let out_path = cwd.join("keep.json"); std::fs::write(&out_path, STALE_OPENVEX_DOC).unwrap(); @@ -152,7 +47,7 @@ fn dry_run_failure_keeps_previous_document() { fn output_dash_means_stdout() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"]); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); let o = vex(cwd, &["--no-verify", "-O", "-"]); assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); @@ -160,19 +55,13 @@ fn output_dash_means_stdout() { assert_eq!(doc["statements"].as_array().unwrap().len(), 1); assert!(!cwd.join("-").exists(), "no file literally named `-`"); assert_eq!(stderr(&o).trim_end(), "Emitted 1 VEX statement"); - - // --json still needs a real file: the envelope owns stdout. - let o = vex(cwd, &["--no-verify", "--json", "-O", "-"]); - assert_eq!(o.status.code(), Some(2)); - let env: Value = serde_json::from_slice(&o.stdout).expect("envelope"); - assert_eq!(env["error"]["code"], "json_requires_output", "{env}"); } #[test] fn written_document_ends_with_newline_and_summary_is_singular() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"]); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); let out_path = cwd.join("out.json"); let o = vex(cwd, &["--no-verify", "-O", out_path.to_str().unwrap()]); @@ -196,7 +85,7 @@ fn written_document_ends_with_newline_and_summary_is_singular() { fn failed_run_reports_stale_document_removal() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"]); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); let out_path = cwd.join("keep.json"); std::fs::write(&out_path, STALE_OPENVEX_DOC).unwrap(); @@ -217,32 +106,20 @@ fn failed_run_reports_stale_document_removal() { .ends_with("Error: No applied patches with vulnerability metadata to attest."), "{err}" ); - - std::fs::write(&out_path, STALE_OPENVEX_DOC).unwrap(); - let o = vex(cwd, &["--json", "-O", out_path.to_str().unwrap()]); - let env: Value = serde_json::from_slice(&o.stdout).expect("envelope"); - assert!( - env["warnings"] - .as_array() - .is_some_and(|w| w.iter().any(|w| w["code"] == "vex_stale_doc_removed")), - "{env}" - ); - assert!( - !stderr(&o).contains("Removed the previous"), - "--json keeps stderr clean" - ); } #[test] fn omissions_are_sorted_and_listed_once() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - let purls = [ - "pkg:npm/zeta@1.0.0", - "pkg:npm/alpha@1.0.0", - "pkg:npm/mid@1.0.0", - ]; - write_manifest(cwd, &purls); + write_npm_manifest( + cwd, + &[ + "pkg:npm/zeta@1.0.0", + "pkg:npm/alpha@1.0.0", + "pkg:npm/mid@1.0.0", + ], + ); let o = vex(cwd, &[]); assert_eq!(o.status.code(), Some(1), "{}", stderr(&o)); @@ -270,29 +147,6 @@ fn omissions_are_sorted_and_listed_once() { " omitted: pkg:npm/zeta@1.0.0 (package_not_found)", ] ); - - // The JSON skipped events come out in the same order. - let out_path = cwd.join("o.json"); - let o = vex(cwd, &["--json", "-O", out_path.to_str().unwrap()]); - let env: Value = serde_json::from_slice(&o.stdout).expect("envelope"); - let order: Vec<&str> = env["events"] - .as_array() - .unwrap() - .iter() - .map(|e| e["purl"].as_str().unwrap()) - .collect(); - assert_eq!( - order, - vec![ - "pkg:npm/alpha@1.0.0", - "pkg:npm/mid@1.0.0", - "pkg:npm/zeta@1.0.0" - ] - ); - assert_eq!( - env["events"][0]["reason"], - "patch omitted from VEX: the package is not installed" - ); } #[test] @@ -301,7 +155,7 @@ fn trusted_patch_attests_without_setup_config() { let cwd = tmp.path(); // No manifest `setup` section and no install hook: the trusted patch // still attests, with no omission warning. - write_manifest(cwd, &["pkg:npm/a@1.0.0"]); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); let o = vex(cwd, &["--no-verify"]); let err = stderr(&o); @@ -315,7 +169,7 @@ fn trusted_patch_attests_without_setup_config() { fn org_that_looks_like_a_file_warns() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"]); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); let o = vex(cwd, &["--no-verify", "-o", "out.json"]); assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); @@ -361,14 +215,16 @@ fn missing_custom_manifest_does_not_suggest_manifest_path() { assert_eq!(stderr(&o), expected); } +/// A corrupt manifest is not a missing one: the error names the parse +/// failure and the file, and no document reaches stdout. #[test] fn corrupt_manifest_error_names_the_file() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - std::fs::create_dir_all(cwd.join(".socket")).unwrap(); - std::fs::write(cwd.join(".socket/manifest.json"), "{not json").unwrap(); + write_corrupt_manifest(cwd); let o = vex(cwd, &[]); assert_eq!(o.status.code(), Some(2)); + assert!(o.stdout.is_empty(), "{}", stdout(&o)); let err = stderr(&o); assert!( err.starts_with("Error: Failed to parse manifest JSON: "), @@ -381,12 +237,9 @@ fn corrupt_manifest_error_names_the_file() { fn product_undetected_names_the_unusable_manifest() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - write_manifest(cwd, &["pkg:npm/a@1.0.0"]); + write_npm_manifest(cwd, &["pkg:npm/a@1.0.0"]); std::fs::write(cwd.join("package.json"), r#"{"name":"app"}"#).unwrap(); - let o = cli() - .args(["vex", "--no-verify", "--cwd", cwd.to_str().unwrap()]) - .output() - .unwrap(); + let o = vex_command(cwd, None).arg("--no-verify").output().unwrap(); assert_eq!(o.status.code(), Some(2), "{}", stderr(&o)); assert!( stderr(&o).contains("(package.json was found but has no usable name and version)"), @@ -394,3 +247,88 @@ fn product_undetected_names_the_unusable_manifest() { stderr(&o) ); } + +/// The `redirect_ledger_corrupt` advisory is a stderr warning line, ahead +/// of the run's own outcome. +#[test] +fn corrupt_redirect_ledger_prints_a_warning_line() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_npm_manifest(cwd, &["pkg:npm/leftpad@1.0.0"]); + write_corrupt_redirect_ledger(cwd); + + let o = vex(cwd, &[]); + let err = stderr(&o); + assert_eq!(o.status.code(), Some(1), "{err}"); + assert!( + err.lines().any(|l| l.starts_with("Warning:") + && l.contains("pre-v5 redirect ledger") + && l.contains("redirect-state.json")), + "{err}" + ); + assert!(err.contains("(package_not_found)"), "{err}"); + assert_redirect_ledger_untouched(cwd); +} + +/// The `vendor_ledger_corrupt` error names the ledger, with or without a +/// manifest (never "Manifest not found"), and `--silent` never mutes it. +#[test] +fn corrupt_vendor_ledger_error_names_the_ledger() { + for (with_manifest, silent) in [(true, false), (false, false), (false, true)] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + if with_manifest { + write_npm_manifest(cwd, &["pkg:npm/leftpad@1.0.0"]); + } + write_corrupt_vendor_ledger(cwd); + + let o = vex(cwd, if silent { &["--silent"] } else { &[] }); + let err = stderr(&o); + assert_eq!(o.status.code(), Some(2), "{err}"); + assert!(o.stdout.is_empty(), "no document: {}", stdout(&o)); + assert!( + err.lines().any(|l| l.starts_with("Error:") + && l.contains("vendor ledger") + && l.contains("state.json")), + "with_manifest={with_manifest} silent={silent}: {err}" + ); + assert!(!err.contains("Manifest not found"), "{err}"); + assert!( + !err.contains("omitting"), + "the ledger error fires before verification: {err}" + ); + assert_vendor_ledger_untouched(cwd); + } +} + +/// The multi-manifest auto-detect warning names the manifest it used, and +/// `--silent` suppresses it without changing the document. +#[test] +fn multi_manifest_auto_detect_warning_and_silent() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_multi_manifest_project(cwd); + let out_path = cwd.join("out.vex.json"); + + for silent in [false, true] { + let mut cmd = vex_command(cwd, None); + cmd.args(["--no-verify", "--output", out_path.to_str().unwrap()]); + if silent { + cmd.arg("--silent"); + } + let o = cmd.output().expect("invoke vex"); + let err = stderr(&o); + assert_eq!(o.status.code(), Some(0), "{err}"); + let warned = err + .lines() + .any(|l| l.contains("Multiple project manifests") && l.contains("using package.json")); + assert_eq!(warned, !silent, "silent={silent}: {err}"); + if silent { + assert!(o.stdout.is_empty(), "{}", stdout(&o)); + } + assert_eq!( + read_doc(&out_path)["statements"][0]["products"][0]["@id"], + PRODUCT + ); + } +} diff --git a/scripts/ci-test-durations.json b/scripts/ci-test-durations.json index 8343924d9..a733afb08 100644 --- a/scripts/ci-test-durations.json +++ b/scripts/ci-test-durations.json @@ -39,7 +39,6 @@ "coverage_fix_scan_hosted_dryrun_vendored": 1.61, "covgap_api_blob_fetcher": 4.04, "covgap_commands_scan_mod": 1.81, - "covgap_commands_vex": 0.2, "covgap_crawlers_composer_crawler": 0.01, "covgap_crawlers_npm_crawler": 0.04, "covgap_patch_apply": 0.06, @@ -240,7 +239,7 @@ "vendor_jvm_cli": 6.72, "vendor_ledger_schema_e2e": 3.13, "vendor_partial_staging_e2e": 0.56, - "vex_terminal_output": 0.18, + "vex": 0.38, "vlt_locks": 16.39 } } From d4555257814201ede7adcef86310f1d63828910a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:03:51 +0000 Subject: [PATCH 3/4] CI: keep the vex checks the fold weakened Review of the vex suite fold found checks it had loosened: - the --silent auto-detect case now deletes the first run's document first, so it proves --silent still writes one; - the corrupt vendor ledger error must carry its cause ("corrupt") and, under --silent too, print no per-patch omission; - the redirect ledger warning must still say the file is malformed and its records are not consulted; - skipped events need a non-empty reason, and an error envelope may leave out its empty events array; - the runner uses the shared hermetic binary_command(). Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/tests/vex/vex_failure_paths.rs | 10 ++++++---- crates/socket-patch-cli/tests/vex/vex_fixture.rs | 2 +- crates/socket-patch-cli/tests/vex/vex_render.rs | 7 +++++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/tests/vex/vex_failure_paths.rs b/crates/socket-patch-cli/tests/vex/vex_failure_paths.rs index e6ef99c16..d6888352a 100644 --- a/crates/socket-patch-cli/tests/vex/vex_failure_paths.rs +++ b/crates/socket-patch-cli/tests/vex/vex_failure_paths.rs @@ -167,11 +167,12 @@ fn corrupt_vendor_ledger_fails_closed_before_verification() { "{env}" ); assert!( - envelope_error_message(&env).is_some_and(|m| m.contains("state.json")), - "the error names the unreadable ledger: {env}" + envelope_error_message(&env) + .is_some_and(|m| m.contains("state.json") && m.contains("corrupt")), + "the error names the unreadable ledger and why: {env}" ); assert!( - events(&env).is_empty(), + env["events"].as_array().is_none_or(|e| e.is_empty()), "no per-patch events on a pre-verification hard error: {env}" ); assert_vendor_ledger_untouched(cwd); @@ -293,7 +294,8 @@ fn omitted_patches_are_sorted_skipped_events() { assert!( events(&env) .iter() - .all(|e| e["errorCode"] == "package_not_found" && e["reason"].is_string()), + .all(|e| e["errorCode"] == "package_not_found" + && e["reason"].as_str().is_some_and(|r| !r.is_empty())), "{env}" ); } diff --git a/crates/socket-patch-cli/tests/vex/vex_fixture.rs b/crates/socket-patch-cli/tests/vex/vex_fixture.rs index 7e6d3c6e3..cbc99b943 100644 --- a/crates/socket-patch-cli/tests/vex/vex_fixture.rs +++ b/crates/socket-patch-cli/tests/vex/vex_fixture.rs @@ -173,7 +173,7 @@ pub(crate) fn write_multi_manifest_project(cwd: &Path) { /// `vex --cwd ` (plus `--product` when given) in the hermetic env, /// run from `cwd`. pub(crate) fn vex_command(cwd: &Path, product: Option<&str>) -> Command { - let mut cmd = common::hermetic_command(&common::binary()); + let mut cmd = common::hermetic::binary_command(); cmd.current_dir(cwd) .args(["vex", "--cwd", cwd.to_str().unwrap()]); if let Some(product) = product { diff --git a/crates/socket-patch-cli/tests/vex/vex_render.rs b/crates/socket-patch-cli/tests/vex/vex_render.rs index 6a1449905..b2d4c2e40 100644 --- a/crates/socket-patch-cli/tests/vex/vex_render.rs +++ b/crates/socket-patch-cli/tests/vex/vex_render.rs @@ -263,7 +263,9 @@ fn corrupt_redirect_ledger_prints_a_warning_line() { assert!( err.lines().any(|l| l.starts_with("Warning:") && l.contains("pre-v5 redirect ledger") - && l.contains("redirect-state.json")), + && l.contains("redirect-state.json") + && l.contains("malformed") + && l.contains("not consulted")), "{err}" ); assert!(err.contains("(package_not_found)"), "{err}"); @@ -294,7 +296,7 @@ fn corrupt_vendor_ledger_error_names_the_ledger() { ); assert!(!err.contains("Manifest not found"), "{err}"); assert!( - !err.contains("omitting"), + !err.contains("omitting") && !err.contains("package_not_found"), "the ledger error fires before verification: {err}" ); assert_vendor_ledger_untouched(cwd); @@ -311,6 +313,7 @@ fn multi_manifest_auto_detect_warning_and_silent() { let out_path = cwd.join("out.vex.json"); for silent in [false, true] { + let _ = std::fs::remove_file(&out_path); let mut cmd = vex_command(cwd, None); cmd.args(["--no-verify", "--output", out_path.to_str().unwrap()]); if silent { From 143dac068f5b591b90d8ede4fcfeb564617273de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 08:03:55 +0000 Subject: [PATCH 4/4] Keep vex --silent test failing on any multi-manifest warning The old suppressed-by-silent test failed if 'Multiple project manifests' appeared anywhere on stderr. The merged loop only matched a line that also said 'using package.json', so a reworded warning leaking past --silent would have passed. Assert the phrase is absent under --silent again. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/tests/vex/vex_render.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/socket-patch-cli/tests/vex/vex_render.rs b/crates/socket-patch-cli/tests/vex/vex_render.rs index b2d4c2e40..c2a7a6021 100644 --- a/crates/socket-patch-cli/tests/vex/vex_render.rs +++ b/crates/socket-patch-cli/tests/vex/vex_render.rs @@ -327,6 +327,9 @@ fn multi_manifest_auto_detect_warning_and_silent() { .any(|l| l.contains("Multiple project manifests") && l.contains("using package.json")); assert_eq!(warned, !silent, "silent={silent}: {err}"); if silent { + // Any wording of the warning must stay suppressed, not just the + // line `warned` matches. + assert!(!err.contains("Multiple project manifests"), "{err}"); assert!(o.stdout.is_empty(), "{}", stdout(&o)); } assert_eq!(