diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 29041ed5..beda9b55 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -28,7 +28,16 @@ use socket_patch_core::vex::{ use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; use crate::ecosystem_dispatch::find_manifest_package_paths; -use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent}; +use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning}; + +/// Routing tag for a patch omitted from VEX by the property-7 ecosystem +/// filter alone: the patch IS applied (byte-verified, or trusted under +/// `--no-verify`) and carries vulnerability metadata, but its ecosystem has +/// no install hook set up and is not declared `manual`. Distinct from the +/// verification tags (`hash_mismatch`, `package_not_found`, …) so a JSON +/// consumer can tell "not patched" from "patched but not persisted by any +/// hook" — before this tag existed the drop was machine-invisible. +const ECOSYSTEM_NOT_SETUP: &str = "ecosystem_not_setup"; #[derive(Args)] pub struct VexArgs { @@ -180,6 +189,12 @@ pub(crate) struct VexWriteSummary { /// The built document — returned so the standalone `vex` command can /// emit its per-subcomponent envelope without rebuilding. pub doc: Document, + /// Run-level advisories (non-IRI product override, vendored artifacts + /// whose live installed tree is out of sync). Already printed to stderr + /// in human mode by [`generate_vex`]; the standalone `vex --json` path + /// folds them into the envelope's `warnings[]` (which is the only + /// channel `--json` has — it silences stderr). + pub warnings: Vec, } /// Failure from [`generate_vex`], carrying a stable code + message the @@ -224,7 +239,7 @@ pub async fn run(args: VexArgs) -> i32 { match generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path).await { Ok(summary) => { if args.common.json { - emit_envelope_success(&summary.doc, &summary.failed); + emit_envelope_success(&summary); } else if let Some(path) = &args.output { if !args.common.silent { println!( @@ -306,6 +321,34 @@ async fn generate_vex( Err(reason) => return Err(fail(common, "product_undetected", reason).await), }; + let mut warnings: Vec = Vec::new(); + + // The help text promises "PURL/identifier", so an arbitrary string is + // accepted — but the OpenVEX spec types the product `@id` as an IRI, and + // strict consumers (vexctl et al.) may reject or mis-key a bare name. + // Warn (never hard-reject) when the EXPLICIT override carries no scheme; + // auto-detected products are always `pkg:` PURLs and need no check. + if let Some(p) = params + .product + .as_deref() + .map(str::trim) + .filter(|p| !p.is_empty()) + { + if !has_iri_scheme(p) { + note_warning( + &mut warnings, + common, + "product_not_iri", + format!( + "product override {p:?} (--product / --vex-product) is neither a PURL \ + (pkg:...) nor an absolute IRI; it is emitted verbatim as the OpenVEX \ + product @id, which the spec requires to be an IRI — strict consumers may \ + reject the document. Prefer pkg:/@." + ), + ); + } + } + // Partition manifest into applied / failed. let mut outcome = if params.no_verify { // Trust-the-manifest mode still needs the vendored classification: @@ -364,6 +407,25 @@ async fn generate_vex( } } + // Vendored disclosure: the committed artifact verified (the attestation + // stands — the committables are what the lockfile consumes) but the LIVE + // installed tree is present and running different bytes. Say so — a + // build that bypasses the vendor wiring is unpatched until the next + // package-manager install. + for purl in &outcome.vendored_out_of_sync { + note_warning( + &mut warnings, + common, + "vendored_tree_out_of_sync", + format!( + "{purl}: the installed tree does not match its vendored artifact; the \ + attestation is based on the committed .socket/vendor artifact (the lockfile \ + consumes it), but the live tree carries different bytes — re-run your \ + package manager's install to resync it." + ), + ); + } + // Property 7: attest a patch only for an ecosystem that is actually set up — // or explicitly declared `manual` in the manifest. Patches for an ecosystem // that is neither are dropped regardless of verification mode (so even @@ -387,20 +449,35 @@ async fn generate_vex( } } } - let before = outcome.applied.len(); + let mut setup_filtered: Vec = Vec::new(); outcome.applied.retain(|purl| { - vendored_set.contains(purl) + let keep = vendored_set.contains(purl) || redirected_set.contains(purl.as_str()) || Ecosystem::from_purl(purl) .map(|e| allowed.contains(&e)) - .unwrap_or(false) + .unwrap_or(false); + if !keep { + setup_filtered.push(purl.clone()); + } + keep }); - if outcome.applied.len() != before && !common.silent && !common.json { + if !setup_filtered.is_empty() && !common.silent && !common.json { eprintln!( "Note: omitting patches for ecosystems that are not set up (and not declared `manual` \ in .socket/manifest.json's `setup.manual`) from VEX." ); } + // The filter drops join the omission channel (`failed`) with their own + // routing tag so they surface as per-purl `skipped` events in the + // envelope — success and error paths alike. Before this they existed + // only as the human-mode note above, leaving `--json` consumers unable + // to distinguish "patched but no persistence hook" from "not patched". + outcome + .failed + .extend(setup_filtered.into_iter().map(|purl| FailedPatch { + purl, + reason: ECOSYSTEM_NOT_SETUP.to_string(), + })); if !outcome.failed.is_empty() && !common.silent && !common.json { for f in &outcome.failed { @@ -437,9 +514,33 @@ async fn generate_vex( None => { let (token, org) = crate::commands::list::telemetry_credentials(common); track_vex_failed("no_applicable_patches", token.as_deref(), org.as_deref()).await; + // When nothing attested and EVERY omission was the property-7 + // filter, say so: those patches ARE applied with vulnerability + // metadata, and the generic message below would read as "not + // patched" to a human. The code stays `no_applicable_patches` — + // it is the documented exit-1 routing tag consumers already + // branch on; the per-event `ecosystem_not_setup` errorCode is + // the machine-readable discriminator. + let all_setup_drops = outcome.applied.is_empty() + && !outcome.failed.is_empty() + && outcome + .failed + .iter() + .all(|f| f.reason == ECOSYSTEM_NOT_SETUP); + let message = if all_setup_drops { + format!( + "{} applied patch(es) with vulnerability metadata were omitted from VEX \ + because their ecosystems are not set up (no install hook) and not declared \ + `manual` in .socket/manifest.json's `setup.manual`. Run `socket-patch \ + setup`, or add the ecosystem to `setup.manual`, then re-run.", + outcome.failed.len() + ) + } else { + "No applied patches with vulnerability metadata to attest.".to_string() + }; return Err(VexGenError { code: "no_applicable_patches", - message: "No applied patches with vulnerability metadata to attest.".to_string(), + message, failed: outcome.failed, }); } @@ -459,7 +560,15 @@ async fn generate_vex( let wrote_to_file = match ¶ms.output { Some(path) => { if let Err(e) = tokio::fs::write(path, &serialized).await { - return Err(fail(common, "write_failed", e.to_string()).await); + // The raw io::Error ("No such file or directory (os error + // 2)") names neither the file nor the operation — useless + // in a CI log. Say what was being written and where. + return Err(fail( + common, + "write_failed", + format!("failed to write VEX document to {}: {e}", path.display()), + ) + .await); } true } @@ -483,17 +592,90 @@ async fn generate_vex( statements: doc.statements.len(), failed: outcome.failed, doc, + warnings, }) } +/// Record a run-level advisory the way `update`/`vendor` do: stderr +/// (`Warning: `) in human mode, and into `warnings` so the `--json` +/// envelope — which silences stderr — carries it in `warnings[]` instead. +/// Under `--silent` only the envelope copy survives (warnings are not +/// errors). +fn note_warning(warnings: &mut Vec, common: &GlobalArgs, code: &str, detail: String) { + if !common.silent && !common.json { + eprintln!("Warning: {detail}"); + } + warnings.push(RunWarning { + code: code.to_string(), + detail, + }); +} + +/// True when `s` opens with an RFC 3986/3987 scheme +/// (`ALPHA *(ALPHA / DIGIT / "+" / "-" / ".") ":"`). A purl passes the same +/// test (`pkg:` is a scheme), so one check covers both halves of the help +/// text's "PURL/identifier" promise. Deliberately shallow — the goal is to +/// catch bare names like `my-app`, not to validate full IRIs. +fn has_iri_scheme(s: &str) -> bool { + let Some((scheme, _)) = s.split_once(':') else { + return false; + }; + let mut chars = scheme.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic()) + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) +} + /// Read the manifest at `manifest_path`, then [`generate_vex`]. Manifest /// read failures are wrapped as [`VexGenError`] so embedded callers /// (`apply`/`scan`) get a single error channel. Used by the embedded /// `--vex` paths, which always write to a file. +/// +/// Failure contract: a run that ends in error leaves NO OpenVEX document at +/// the output path — including a stale one from a previous run. Attestation +/// semantics demand it: a pipeline reusing one `--output`/`--vex` path must +/// not ship yesterday's `not_affected` document for a tree this run could +/// no longer attest. See [`remove_stale_vex_doc`] for the deletion guard. pub(crate) async fn generate_vex_from_manifest_path( common: &GlobalArgs, params: &VexBuildParams, manifest_path: &Path, +) -> Result { + let result = generate_vex_from_manifest_path_inner(common, params, manifest_path).await; + if result.is_err() { + remove_stale_vex_doc(params.output.as_deref()).await; + } + result +} + +/// Delete a PRIOR run's OpenVEX document at `output` after a failed run. +/// Only a file that is recognizably OpenVEX (JSON whose `@context` names +/// openvex.dev) is removed — the guard keeps a mistyped `--output` pointing +/// at an unrelated file from being destroyed by an unrelated failure. +/// Removal errors are swallowed: the non-zero exit is the contract, the +/// deletion is hygiene. +async fn remove_stale_vex_doc(output: Option<&Path>) { + let Some(path) = output else { return }; + let Ok(bytes) = tokio::fs::read(path).await else { + return; + }; + let is_openvex = serde_json::from_slice::(&bytes) + .ok() + .and_then(|v| { + v.get("@context") + .and_then(|c| c.as_str()) + .map(|c| c.contains("openvex.dev")) + }) + .unwrap_or(false); + if is_openvex { + let _ = tokio::fs::remove_file(path).await; + } +} + +/// [`generate_vex_from_manifest_path`] without the failure-cleanup wrapper. +async fn generate_vex_from_manifest_path_inner( + common: &GlobalArgs, + params: &VexBuildParams, + manifest_path: &Path, ) -> Result { let manifest_file = match read_manifest(manifest_path).await { Ok(m) => m, @@ -726,7 +908,7 @@ fn emit_envelope_error(args: &VexArgs, code: &str, message: &str, failures: &[Fa for f in failures { env.record( PatchEvent::new(PatchAction::Skipped, f.purl.clone()) - .with_reason(f.reason.clone(), "patch omitted from VEX"), + .with_reason(f.reason.clone(), omission_reason_message(&f.reason)), ); } env.mark_error(EnvelopeError::new(code, message.to_string())); @@ -739,9 +921,21 @@ fn emit_envelope_error(args: &VexArgs, code: &str, message: &str, failures: &[Fa } } -fn emit_envelope_success(doc: &Document, failures: &[FailedPatch]) { +/// Human `reason` string for an omission event; the routing tag rides +/// `errorCode`. The property-7 drop gets its own phrasing — that patch IS +/// applied and verified, which the generic "omitted" alone doesn't convey. +fn omission_reason_message(reason: &str) -> &'static str { + if reason == ECOSYSTEM_NOT_SETUP { + "applied patch omitted from VEX: its ecosystem has no install hook set up and is not \ + declared `manual` in setup.manual" + } else { + "patch omitted from VEX" + } +} + +fn emit_envelope_success(summary: &VexWriteSummary) { let mut env = Envelope::new(Command::Vex); - for st in &doc.statements { + for st in &summary.doc.statements { for prod in &st.products { for sub in &prod.subcomponents { env.record( @@ -756,15 +950,16 @@ fn emit_envelope_success(doc: &Document, failures: &[FailedPatch]) { } } } - for f in failures { + for f in &summary.failed { env.record( PatchEvent::new(PatchAction::Skipped, f.purl.clone()) - .with_reason(f.reason.clone(), "patch omitted from VEX"), + .with_reason(f.reason.clone(), omission_reason_message(&f.reason)), ); } - if !failures.is_empty() { + if !summary.failed.is_empty() { env.mark_partial_failure(); } + env.warnings = summary.warnings.clone(); println!("{}", env.to_pretty_json()); } @@ -851,6 +1046,31 @@ mod tests { assert!(!are_safe_redirect_coords("github.com/foo/bar", "")); } + /// The `--product` advisory keys off [`has_iri_scheme`]: PURLs and + /// anything scheme-shaped sail through silently; bare names (what the + /// probe fed in) warn. Pin the accept/reject sets so the check can't + /// drift into rejecting legal identifiers (a hard reject is explicitly + /// out of contract — help text says "PURL/identifier"). + #[test] + fn iri_scheme_check_accepts_purls_and_iris_rejects_bare_names() { + // Accepted (no warning): PURLs, URLs, URNs, exotic-but-legal schemes. + assert!(has_iri_scheme("pkg:npm/my-app@1.0.0")); + assert!(has_iri_scheme("pkg:golang/github.com/foo/bar@v1.2.3")); + assert!(has_iri_scheme("https://example.com/products/app")); + assert!(has_iri_scheme( + "urn:uuid:0f9be22a-4a56-4b74-8c9d-6d70c67a4b32" + )); + assert!(has_iri_scheme("git+ssh://git@github.com/foo/bar")); + // Rejected (warn): bare names, empty scheme, non-alpha scheme start, + // spaces before the colon. + assert!(!has_iri_scheme("my-app")); + assert!(!has_iri_scheme("my app 1.0")); + assert!(!has_iri_scheme("")); + assert!(!has_iri_scheme(":no-scheme")); + assert!(!has_iri_scheme("1pkg:starts-with-digit")); + assert!(!has_iri_scheme("bad scheme:rest")); + } + #[derive(Parser)] struct Wrap { #[command(subcommand)] diff --git a/crates/socket-patch-cli/tests/cli_apply_silent.rs b/crates/socket-patch-cli/tests/cli_apply_silent.rs index 0a92d820..b792ffac 100644 --- a/crates/socket-patch-cli/tests/cli_apply_silent.rs +++ b/crates/socket-patch-cli/tests/cli_apply_silent.rs @@ -19,8 +19,10 @@ //! (all three modes), and apply's own yarn-PnP refusal. //! //! Stderr assertions ignore the "No SOCKET_API_TOKEN set" client warning: -//! it's printed unconditionally by `get_api_client_with_overrides` in core -//! for every command and is out of scope for `apply`'s `--silent` gating. +//! it's printed by `get_api_client_with_overrides` in core for every ONLINE +//! command (offline runs suppress it — see +//! `apply_offline_suppresses_public_proxy_notice`) and is out of scope for +//! `apply`'s `--silent` gating. use std::path::{Path, PathBuf}; use std::process::Command; @@ -169,3 +171,30 @@ fn apply_check_silent_drift_keeps_error_output() { "--check --silent must keep the drift error output; stderr was: {stderr:?}" ); } + +/// `--offline` promises "never contact the network" (CLI_CONTRACT.md strict +/// airgap), but the tokenless client-construction advisory claims the run is +/// "using the public patch API proxy" — implied network use that misleads +/// airgapped operators. Offline runs must suppress it; online tokenless runs +/// must keep it (anti-vacuous half). +#[test] +fn apply_offline_suppresses_public_proxy_notice() { + // No .socket dir at all: apply exits 0 ("nothing to apply") either way, + // so the only stderr difference is the advisory under test. + let tmp = tempfile::tempdir().unwrap(); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &["--offline"]); + assert_eq!(code, 0, "no-manifest apply is a clean no-op: {stderr}"); + assert!( + !stderr.contains("public patch API proxy"), + "--offline must not claim proxy (network) use; stderr was: {stderr:?}" + ); + + let (code, _stdout, stderr) = run_apply(tmp.path(), &[]); + assert_eq!(code, 0, "no-manifest apply is a clean no-op: {stderr}"); + assert!( + stderr.contains("public patch API proxy"), + "anti-vacuous: the same tokenless run WITHOUT --offline must keep the \ + advisory; stderr was: {stderr:?}" + ); +} diff --git a/crates/socket-patch-cli/tests/cli_parse_vex.rs b/crates/socket-patch-cli/tests/cli_parse_vex.rs index 6e8328d7..951ae2fd 100644 --- a/crates/socket-patch-cli/tests/cli_parse_vex.rs +++ b/crates/socket-patch-cli/tests/cli_parse_vex.rs @@ -57,6 +57,7 @@ const SOCKET_ENV_VARS: &[&str] = &[ "SOCKET_LOCK_TIMEOUT", "SOCKET_DEBUG", "SOCKET_TELEMETRY_DISABLED", + "SOCKET_NO_TRUST_LOCKFILE_CONFIG", // VexArgs / VexEmbedArgs "SOCKET_VEX", "SOCKET_VEX_OUTPUT", diff --git a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs index 80857f0c..0ac54519 100644 --- a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs @@ -481,6 +481,150 @@ fn apply_silent_vex_failure_keeps_error_output() { ); } +/// Failed-run hygiene: a `--vex` failure must remove a PRIOR run's OpenVEX +/// document parked at the output path. Attestation semantics demand it — a +/// pipeline reusing one path (`apply --vex out.json` on every CI run) must +/// not ship yesterday's `not_affected` doc for a tree this run could no +/// longer attest. Same `product_undetected` fixture as +/// `apply_vex_failure_flips_exit_code`, plus a pre-seeded stale doc. +#[test] +fn apply_vex_failure_removes_stale_openvex_doc() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + seed_offline_apply(cwd); + let vex_path = cwd.join("apply.vex.json"); + std::fs::write( + &vex_path, + r#"{"@context":"https://openvex.dev/ns/v0.2.0","@id":"urn:uuid:stale","author":"Socket","timestamp":"2020-01-01T00:00:00Z","version":1,"statements":[]}"#, + ) + .unwrap(); + + let out = cli() + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--json", + "--vex", + vex_path.to_str().unwrap(), + ]) + .output() + .expect("invoke apply"); + assert!(!out.status.success(), "VEX failure must flip the exit code"); + let env: Value = serde_json::from_slice(&out.stdout).expect("apply envelope JSON"); + assert_eq!(env["error"]["code"], "product_undetected"); + assert!( + !vex_path.exists(), + "a failed run must remove the stale prior OpenVEX doc at --vex" + ); +} + +/// The stale-doc removal is guarded: only a file that is recognizably an +/// OpenVEX document is deleted. A mistyped `--vex` pointing at an unrelated +/// file must survive the failed run byte-identical — the cleanup exists to +/// prevent stale attestations, not to destroy user data. +#[test] +fn apply_vex_failure_preserves_non_openvex_file() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + seed_offline_apply(cwd); + let precious = cwd.join("precious.txt"); + let original = b"not an openvex document {".to_vec(); + std::fs::write(&precious, &original).unwrap(); + + let out = cli() + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--json", + "--vex", + precious.to_str().unwrap(), + ]) + .output() + .expect("invoke apply"); + assert!(!out.status.success()); + assert_eq!( + std::fs::read(&precious).unwrap(), + original, + "a non-OpenVEX file at the output path must survive a failed run" + ); +} + +/// An unwritable `--vex` path fails with `write_failed`, and the message +/// must name the path and the operation — the bare io::Error ("No such file +/// or directory (os error 2)") diagnosed nothing in a CI log. +#[test] +fn apply_vex_write_failure_names_path() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + seed_offline_apply(cwd); + let bad_path = cwd.join("no-such-dir/apply.vex.json"); + + let out = cli() + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--json", + "--vex", + bad_path.to_str().unwrap(), + "--vex-product", + "pkg:npm/my-app@1.0.0", + ]) + .output() + .expect("invoke apply"); + assert!(!out.status.success(), "write failure must flip the exit"); + let env: Value = serde_json::from_slice(&out.stdout).expect("apply envelope JSON"); + assert_eq!(env["error"]["code"], "write_failed", "{env}"); + let msg = env["error"]["message"].as_str().unwrap(); + assert!( + msg.contains("failed to write VEX document") && msg.contains("no-such-dir"), + "the error must name the operation and the path; got {msg:?}" + ); +} + +/// A non-IRI `--vex-product` is honored verbatim (help text: "PURL / +/// identifier") but must warn on stderr in human mode — the OpenVEX product +/// @id is spec-typed as an IRI and strict consumers may reject a bare name. +#[test] +fn apply_vex_product_non_iri_warns_in_human_mode() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + seed_offline_apply(cwd); + let vex_path = cwd.join("apply.vex.json"); + + let out = cli() + .args([ + "apply", + "--cwd", + cwd.to_str().unwrap(), + "--offline", + "--vex", + vex_path.to_str().unwrap(), + "--vex-product", + "my app", + ]) + .output() + .expect("invoke apply"); + assert!( + out.status.success(), + "a non-IRI product is a warning, never a hard reject. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Warning:") && stderr.contains("IRI"), + "human mode must warn about the non-IRI product; got {stderr:?}" + ); + // Honored verbatim in the written doc. + let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + assert_eq!(doc["statements"][0]["products"][0]["@id"], "my app"); +} + // ────────────────────────────────────────────────────────────────────── // scan --vex (read-only; zero installed packages → no network) // ────────────────────────────────────────────────────────────────────── diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 5548053e..dff0b06d 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -1032,3 +1032,486 @@ fn detached_vendor_matrix_attests_every_vendor_ecosystem() { ); } } + +// ────────────────────────────────────────────────────────────────────── +// 7. live-tree disclosure — a vendored attestation with the INSTALLED tree +// present-but-unpatched must keep the attestation (the committed artifact + +// lock wiring is the product) while warning that the live tree is out of +// sync. Verified against real pnpm projects 2026-08-18: the attestation was +// silently thin-disclosed before this warning existed. +// ────────────────────────────────────────────────────────────────────── + +/// Vendored npm entry (detached ledger shape so the crawler-visible +/// installed tree is the only other evidence): the tgz artifact is healthy, +/// `node_modules` holds the UN-patched original. The doc must attest +/// `(vendored)`, exit 0, and the `--json` envelope must carry a +/// `vendored_tree_out_of_sync` warning naming the purl. +#[test] +fn vendored_live_tree_out_of_sync_warns_but_attests() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purl = "pkg:npm/lodash@4.17.21"; + let uuid = "0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a"; + + let patched = b"patched npm bytes\n"; + let after_hash = compute_git_sha256_from_bytes(patched); + let rel = format!(".socket/vendor/npm/{uuid}/lodash-4.17.21.tgz"); + let sha256 = sha256_hex(&write_member_tgz( + &cwd.join(&rel), + "package/index.js", + patched, + )); + let record = make_record( + uuid, + "package/index.js", + &after_hash, + "GHSA-sync-aaaa", + &["CVE-2026-10"], + ); + let mut state = VendorState::new(); + state.entries.insert( + purl.to_string(), + detached_matrix_entry("npm", purl, uuid, &rel, sha256, record), + ); + 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(); + + // Installed tree: present and PRISTINE-UNPATCHED (the drift being + // disclosed — e.g. a fresh `pnpm install` that bypassed the wiring). + let nm = cwd.join("node_modules/lodash"); + std::fs::create_dir_all(&nm).unwrap(); + std::fs::write( + nm.join("package.json"), + r#"{"name":"lodash","version":"4.17.21"}"#, + ) + .unwrap(); + std::fs::write(nm.join("index.js"), b"original unpatched bytes\n").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!( + out.status.success(), + "an out-of-sync LIVE tree must not block the vendored attestation. 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"], "success", "{env}"); + let warnings = env["warnings"] + .as_array() + .unwrap_or_else(|| panic!("envelope must carry warnings[]: {env}")); + let w = warnings + .iter() + .find(|w| w["code"] == "vendored_tree_out_of_sync") + .unwrap_or_else(|| panic!("expected a vendored_tree_out_of_sync warning: {env}")); + assert!( + w["detail"].as_str().unwrap().contains(purl), + "the warning must name the drifted purl: {w}" + ); + + // The doc still attests, with the (vendored) provenance marker intact. + let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1, "{doc}"); + assert_eq!(stmts[0]["status"], "not_affected"); + assert_eq!( + stmts[0]["impact_statement"].as_str().unwrap(), + format!("Patched via Socket patch {uuid} (vendored)") + ); + + // Human-mode control: the same drift prints a stderr `Warning:` with + // the resync advice (the `--json` run above suppresses stderr chrome). + let human = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--product", + "pkg:npm/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + assert!(human.status.success()); + let stderr = String::from_utf8_lossy(&human.stderr); + assert!( + stderr.contains("Warning:") && stderr.contains("vendored artifact"), + "human mode must disclose the out-of-sync tree; got {stderr:?}" + ); + + // Absent-tree control: remove node_modules — the expected post-vendor + // state — and the warning must disappear while the attestation stays. + std::fs::remove_dir_all(cwd.join("node_modules")).unwrap(); + let absent = 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!(absent.status.success()); + let env: Value = serde_json::from_slice(&absent.stdout).unwrap(); + assert!( + env["warnings"].is_null(), + "an absent installed tree is the expected post-vendor state — no warning: {env}" + ); +} + +// ────────────────────────────────────────────────────────────────────── +// 8. property-7 filter drops are machine-visible — a byte-verified applied +// patch omitted ONLY by the ecosystem-setup filter must surface as a +// per-purl skipped event (errorCode `ecosystem_not_setup`), and an all- +// drops failure must say so in the top-level error message instead of the +// generic (and factually wrong) "No applied patches ... to attest." +// Confirmed against real pnpm projects 2026-08-18. +// ────────────────────────────────────────────────────────────────────── + +/// All-drops case: the ONLY patch is applied + byte-verified but its +/// ecosystem is neither set up nor `manual`. Exit stays 1 with code +/// `no_applicable_patches`, but the envelope must carry the skipped event +/// and the message must name the setup filter. A stale OpenVEX doc parked +/// at `--output` from a previous run must also be removed — a failed run +/// leaves no attestation behind. +#[test] +fn setup_filter_drop_surfaces_skipped_event_and_removes_stale_doc() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purl = "pkg:npm/applied-pkg@1.0.0"; + + // Applied + verifiable in node_modules; no root package.json → no npm + // hook configured; manifest carries NO setup section. + let nm = cwd.join("node_modules/applied-pkg"); + std::fs::create_dir_all(&nm).unwrap(); + std::fs::write( + nm.join("package.json"), + r#"{"name":"applied-pkg","version":"1.0.0"}"#, + ) + .unwrap(); + let patched = b"patched npm index"; + let after = compute_git_sha256_from_bytes(patched); + std::fs::write(nm.join("index.js"), patched).unwrap(); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + purl.to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "package/index.js", + &after, + "GHSA-drop-aaaa", + &["CVE-2026-20"], + ), + ); + write_manifest(cwd, &manifest, false); + + // A previous successful run's doc sits at --output. + let vex_path = cwd.join("out.vex.json"); + std::fs::write( + &vex_path, + r#"{"@context":"https://openvex.dev/ns/v0.2.0","@id":"urn:uuid:stale","author":"Socket","timestamp":"2020-01-01T00:00:00Z","version":1,"statements":[]}"#, + ) + .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(1), + "all patches filtered ⇒ soft exit 1. 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"], "no_applicable_patches", "{env}"); + // The message must name the ACTUAL cause — the patch IS applied with + // vulnerability metadata; only the setup filter dropped it. + let msg = env["error"]["message"].as_str().unwrap(); + assert!( + msg.contains("not set up") && msg.contains("setup.manual"), + "an all-drops failure must name the setup filter, got {msg:?}" + ); + // Machine-visible per-purl drop. + let events = env["events"].as_array().unwrap(); + let skipped = events + .iter() + .find(|e| e["action"] == "skipped" && e["purl"] == purl) + .unwrap_or_else(|| panic!("expected a skipped event for the filtered purl: {env}")); + assert_eq!( + skipped["errorCode"], "ecosystem_not_setup", + "the filter drop must carry its routing tag: {skipped}" + ); + // Failed-run hygiene: the stale prior doc must be gone. + assert!( + !vex_path.exists(), + "a failed run must not leave a previous run's attestation at --output" + ); +} + +/// Partial case: a vendored patch attests while an npm patch is filter- +/// dropped. Exit 0 (a doc was produced), envelope `partialFailure`, and the +/// drop is a skipped event alongside the vendored purl's verified event. +#[test] +fn setup_filter_drop_alongside_success_is_partial_failure_event() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let vendored_purl = "pkg:cargo/serde@1.0.0"; + let dropped_purl = "pkg:npm/applied-pkg@1.0.0"; + + let patched = b"patched vendored source\n"; + let after_hash = compute_git_sha256_from_bytes(patched); + let rel = write_vendored_dir(cwd, patched); + write_vendor_state(cwd, vendored_purl, &rel); + + let nm = cwd.join("node_modules/applied-pkg"); + std::fs::create_dir_all(&nm).unwrap(); + std::fs::write( + nm.join("package.json"), + r#"{"name":"applied-pkg","version":"1.0.0"}"#, + ) + .unwrap(); + let npm_patched = b"patched npm index"; + let npm_after = compute_git_sha256_from_bytes(npm_patched); + std::fs::write(nm.join("index.js"), npm_patched).unwrap(); + + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + vendored_purl.to_string(), + make_record( + UUID, + "src/lib.rs", + &after_hash, + "GHSA-keep-aaaa", + &["CVE-2026-21"], + ), + ); + manifest.patches.insert( + dropped_purl.to_string(), + make_record( + "11111111-1111-4111-8111-111111111111", + "package/index.js", + &npm_after, + "GHSA-drop-bbbb", + &["CVE-2026-22"], + ), + ); + write_manifest(cwd, &manifest, false); + + 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:cargo/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "a produced doc keeps exit 0 even with filter drops. 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"], "partialFailure", + "an omission alongside a success is partialFailure: {env}" + ); + let events = env["events"].as_array().unwrap(); + assert!( + events + .iter() + .any(|e| e["action"] == "verified" && e["purl"] == vendored_purl), + "the vendored purl must attest: {env}" + ); + let skipped = events + .iter() + .find(|e| e["action"] == "skipped" && e["purl"] == dropped_purl) + .unwrap_or_else(|| panic!("expected a skipped event for the filtered purl: {env}")); + assert_eq!(skipped["errorCode"], "ecosystem_not_setup", "{skipped}"); + + // The doc holds exactly the vendored statement. + let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + assert_eq!(doc["statements"].as_array().unwrap().len(), 1, "{doc}"); +} + +// ────────────────────────────────────────────────────────────────────── +// 9. `--product` / `--output` UX pins (shared vex pipeline; exercised here +// against the vendored fixture) +// ────────────────────────────────────────────────────────────────────── + +/// A non-IRI `--product` override is accepted verbatim (help text promises +/// "PURL/identifier") but must warn — envelope `warnings[]` in `--json` +/// mode — since the OpenVEX product @id is spec-typed as an IRI. A PURL +/// override must stay silent. +#[test] +fn product_override_non_iri_warns_in_envelope() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purl = "pkg:cargo/serde@1.0.0"; + + let patched = b"patched vendored source\n"; + let after_hash = compute_git_sha256_from_bytes(patched); + let rel = write_vendored_dir(cwd, patched); + write_vendor_state(cwd, purl, &rel); + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + purl.to_string(), + make_record( + UUID, + "src/lib.rs", + &after_hash, + "GHSA-iri-aaaa", + &["CVE-2026-30"], + ), + ); + write_manifest(cwd, &manifest, true); + + 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", + "internal app name", + ]) + .output() + .expect("invoke vex"); + assert!( + out.status.success(), + "a non-IRI product is a warning, never a hard reject. stdout:\n{}", + String::from_utf8_lossy(&out.stdout) + ); + let env: Value = serde_json::from_slice(&out.stdout).unwrap(); + let warnings = env["warnings"] + .as_array() + .unwrap_or_else(|| panic!("expected warnings[]: {env}")); + assert!( + warnings.iter().any(|w| w["code"] == "product_not_iri"), + "non-IRI --product must warn: {env}" + ); + // Emitted verbatim — the override is honored, only advised against. + let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap(); + assert_eq!( + doc["statements"][0]["products"][0]["@id"], "internal app name", + "{doc}" + ); + + // Control: a PURL override must produce no product warning. + let ok = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--json", + "--output", + vex_path.to_str().unwrap(), + "--product", + "pkg:cargo/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + assert!(ok.status.success()); + let env: Value = serde_json::from_slice(&ok.stdout).unwrap(); + assert!( + env["warnings"] + .as_array() + .map(|ws| ws.iter().all(|w| w["code"] != "product_not_iri")) + .unwrap_or(true), + "a PURL --product must not warn: {env}" + ); +} + +/// `--output` into a nonexistent directory must exit 2 with an error that +/// names the path and the operation — the bare io::Error ("No such file or +/// directory (os error 2)") diagnosed nothing. +#[test] +fn standalone_output_write_failure_names_path_and_exits_2() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let purl = "pkg:cargo/serde@1.0.0"; + + let patched = b"patched vendored source\n"; + let after_hash = compute_git_sha256_from_bytes(patched); + let rel = write_vendored_dir(cwd, patched); + write_vendor_state(cwd, purl, &rel); + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + purl.to_string(), + make_record( + UUID, + "src/lib.rs", + &after_hash, + "GHSA-wrt-aaaa", + &["CVE-2026-31"], + ), + ); + write_manifest(cwd, &manifest, true); + + let bad_path = cwd.join("no-such-dir/out.vex.json"); + let out = cli() + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--output", + bad_path.to_str().unwrap(), + "--product", + "pkg:cargo/app@1.0.0", + ]) + .output() + .expect("invoke vex"); + assert_eq!( + out.status.code(), + Some(2), + "write failure is a hard error. stderr:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("failed to write VEX document") && stderr.contains("no-such-dir"), + "the error must name the operation and the path; got {stderr:?}" + ); +} diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index ce756974..8cd2ea9a 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -11,7 +11,7 @@ use crate::api::ranking::severity_order as get_severity_order; use crate::api::ranking::{cmp_batch_infos, cmp_search_results}; use crate::api::types::*; use crate::constants::USER_AGENT as USER_AGENT_VALUE; -use crate::utils::env_compat::{is_debug_enabled, proxy_url_from_env}; +use crate::utils::env_compat::{is_debug_enabled, is_offline_env, proxy_url_from_env}; use crate::utils::socket_cli_config; /// Log debug messages when debug mode is enabled. @@ -1118,11 +1118,20 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> .proxy_url .filter(|u| !u.is_empty()) .unwrap_or_else(proxy_url_from_env); - eprintln!( - "No SOCKET_API_TOKEN set (and no socket-cli login found) — using the \ - public patch API proxy (free patches only). Run `socket login` or set \ - SOCKET_API_TOKEN to access org patches." - ); + // Offline runs still construct this client (commands build it up + // front for telemetry/staging plumbing) but never contact it — under + // the strict-airgap contract "using the public patch API proxy" + // would falsely claim network use, so the advisory is suppressed. + // Same gate as telemetry's airgap kill-switch; `--offline` is + // mirrored into SOCKET_OFFLINE (normalized to "1") before any + // client is built. + if !is_offline_env() { + eprintln!( + "No SOCKET_API_TOKEN set (and no socket-cli login found) — using the \ + public patch API proxy (free patches only). Run `socket login` or set \ + SOCKET_API_TOKEN to access org patches." + ); + } let client = ApiClient::new(ApiClientOptions { api_url: proxy_url, api_token: None, @@ -1150,10 +1159,7 @@ pub async fn get_api_client_with_overrides(overrides: ApiClientEnvOverrides) -> // Auto-resolve org slug if not provided let final_org_slug = if resolved_org_slug.is_some() { resolved_org_slug - } else if matches!( - std::env::var("SOCKET_OFFLINE").unwrap_or_default().as_str(), - "1" | "true" - ) { + } else if is_offline_env() { // Strict airgap: `--offline` (mirrored into `SOCKET_OFFLINE` by the // CLI before any client is built — same vocabulary the telemetry // kill-switch matches) means zero network contact, so the org-slug diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index 57bfb60c..ce1a3696 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -4,7 +4,9 @@ use once_cell::sync::Lazy; use uuid::Uuid; use crate::constants::USER_AGENT; -use crate::utils::env_compat::{is_debug_enabled, proxy_url_from_env, read_env_with_legacy}; +use crate::utils::env_compat::{ + is_debug_enabled, is_offline_env, proxy_url_from_env, read_env_with_legacy, +}; use crate::utils::fs::home_dir; use crate::vex::time::unix_to_ymdhms; @@ -135,11 +137,7 @@ pub fn is_telemetry_disabled() -> bool { .unwrap_or_default(); let disabled_via_env = matches!(env_value.as_str(), "1" | "true"); let vitest = std::env::var("VITEST").unwrap_or_default() == "true"; - let offline = matches!( - std::env::var("SOCKET_OFFLINE").unwrap_or_default().as_str(), - "1" | "true" - ); - disabled_via_env || vitest || offline + disabled_via_env || vitest || is_offline_env() } /// Log debug messages when debug mode is enabled. diff --git a/crates/socket-patch-core/src/utils/env_compat.rs b/crates/socket-patch-core/src/utils/env_compat.rs index 59dcc71a..3e328210 100644 --- a/crates/socket-patch-core/src/utils/env_compat.rs +++ b/crates/socket-patch-core/src/utils/env_compat.rs @@ -74,6 +74,19 @@ pub(crate) fn is_debug_enabled() -> bool { ) } +/// Strict-airgap gate: `SOCKET_OFFLINE` is `"1"` or `"true"`. No legacy +/// name — it lives here as the single definition of the vocabulary shared +/// by every offline gate (telemetry kill-switch, API-client advisory and +/// org-slug auto-resolution). The CLI mirrors `--offline` (whose clap-side +/// bool parse accepts a wider vocabulary) into `SOCKET_OFFLINE=1` before +/// any of those gates run, so the env read alone is authoritative. +pub(crate) fn is_offline_env() -> bool { + matches!( + std::env::var("SOCKET_OFFLINE").unwrap_or_default().as_str(), + "1" | "true" + ) +} + /// The public patch-API proxy base URL: `SOCKET_PROXY_URL` (with the legacy /// `SOCKET_PATCH_PROXY_URL` shim), defaulting to /// [`DEFAULT_PATCH_API_PROXY_URL`](crate::constants::DEFAULT_PATCH_API_PROXY_URL). diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 57be60ba..576c49d1 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -40,6 +40,15 @@ pub struct VerifyOutcome { /// vendor artifact (`.socket/vendor/…`) rather than the installed /// tree. Every member is also present in `applied`. pub vendored: Vec, + /// The subset of `vendored` whose INSTALLED tree is present on disk + /// but does NOT hash to the record's `afterHash` (pristine-unpatched, + /// tampered, or a missing file). The attestation itself is unaffected — + /// the committed artifact + lock wiring is the product the lockfile + /// consumes — but callers must disclose the drift: a build that + /// bypasses the vendor wiring runs unpatched code until the package + /// manager re-installs. An ABSENT installed tree is the expected + /// post-vendor state and is never flagged. + pub vendored_out_of_sync: Vec, } /// Vendored-patch context for [`applied_patches_with_vendor`]. @@ -116,6 +125,18 @@ pub async fn applied_patches_with_vendor( out.applied.push(purl.clone()); if vendor_entry.is_some() { out.vendored.push(purl.clone()); + // Disclosure probe: with the vendor artifact healthy, + // also check whether the LIVE installed tree (when the + // crawler found one) carries the patch. Any mismatch is + // recorded in `vendored_out_of_sync` for the caller to + // warn about — it never changes the verdict, because + // there is deliberately no installed-tree fallback in + // either direction (see the precedence note above). + if let Some(pkg_path) = package_paths.get(purl) { + if verify_patch_record(pkg_path, record).await.is_err() { + out.vendored_out_of_sync.push(purl.clone()); + } + } } } Err(reason) => out.failed.push(FailedPatch { @@ -324,6 +345,8 @@ mod tests { let o = VerifyOutcome::default(); assert!(o.applied.is_empty()); assert!(o.failed.is_empty()); + assert!(o.vendored.is_empty()); + assert!(o.vendored_out_of_sync.is_empty()); } /// `FailedPatch` equality + clone for downstream consumers @@ -999,6 +1022,10 @@ mod tests { assert_eq!(out.applied, vec![purl.to_string()]); assert_eq!(out.vendored, vec![purl.to_string()]); assert!(out.failed.is_empty()); + assert!( + out.vendored_out_of_sync.is_empty(), + "an ABSENT installed tree is the expected post-vendor state — no drift flag" + ); } /// A manifest PURL matches a vendor entry recorded under a different map @@ -1104,6 +1131,63 @@ mod tests { ); assert_eq!(out.vendored, vec![purl.to_string()]); assert!(out.failed.is_empty()); + // Disclosure: the live tree is present and pristine-unpatched — the + // attestation stands (committed artifact is the product) but the + // drift must be reported so the CLI can advise a re-install. + assert_eq!(out.vendored_out_of_sync, vec![purl.to_string()]); + } + + /// Disclosure probe, tampered direction: the vendor artifact is healthy + /// (attest + vendored) while the installed tree is present with bytes + /// matching NEITHER `beforeHash` nor `afterHash`. The verdict stands but + /// the purl is flagged `vendored_out_of_sync` — exactly like the + /// pristine-unpatched case, since either way the live tree is running + /// different bytes than the attested artifact. + #[tokio::test] + async fn tampered_installed_tree_flagged_out_of_sync_but_attested() { + let root = tempfile::tempdir().unwrap(); + let purl = "pkg:cargo/serde@1.0.0"; + let rel = format!(".socket/vendor/cargo/{VUUID}/serde-1.0.0"); + let patched = b"patched-content"; + let hash = compute_git_sha256_from_bytes(patched); + + // Vendored copy: healthy. + let vdir = root.path().join(&rel); + tokio::fs::create_dir_all(&vdir).await.unwrap(); + tokio::fs::write(vdir.join("index.js"), patched) + .await + .unwrap(); + // Installed tree: tampered (neither before nor after content). + let installed = root.path().join("installed"); + tokio::fs::create_dir_all(&installed).await.unwrap(); + tokio::fs::write(installed.join("index.js"), b"tampered live bytes") + .await + .unwrap(); + + let mut rec = record_with_one_file(&hash); + rec.uuid = VUUID.to_string(); + let mut manifest = PatchManifest::new(); + manifest.patches.insert(purl.to_string(), rec); + + let mut entries = HashMap::new(); + entries.insert(purl.to_string(), vendor_entry(purl, &rel)); + let ctx = VendorContext { + project_root: root.path().to_path_buf(), + entries, + go_patches: HashMap::new(), + }; + let mut paths = HashMap::new(); + paths.insert(purl.to_string(), installed); + + let out = applied_patches_with_vendor(&manifest, &paths, Some(&ctx)).await; + assert_eq!( + out.applied, + vec![purl.to_string()], + "a tampered LIVE tree must not block the committed-artifact attestation" + ); + assert_eq!(out.vendored, vec![purl.to_string()]); + assert!(out.failed.is_empty()); + assert_eq!(out.vendored_out_of_sync, vec![purl.to_string()]); } /// Precedence, fail-closed direction: a TAMPERED vendor artifact fails