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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,10 @@ pub async fn run(args: ApplyArgs) -> i32 {
.to_string(),
statements: summary.statements,
format: "openvex-0.2.0".to_string(),
// note_warning suppressed these on stderr under
// --json; the envelope copy is their only
// surviving channel.
warnings: summary.warnings.clone(),
});
}
Some(Err(e)) => {
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ pub async fn run(args: VendorArgs) -> i32 {
path: vex_path.display().to_string(),
statements: summary.statements,
format: "openvex-0.2.0".to_string(),
warnings: summary.warnings,
});
}
Err(e) => {
Expand Down
248 changes: 234 additions & 14 deletions crates/socket-patch-cli/src/commands/vex.rs

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions crates/socket-patch-cli/src/json_envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ pub struct VexSummary {
pub statements: usize,
/// Document format tag, e.g. `"openvex-0.2.0"`.
pub format: String,
/// Run-level advisories raised during VEX generation (e.g.
/// `product_not_iri`, `vendored_tree_out_of_sync`). Same [`RunWarning`]
/// shape as the top-level `warnings[]`, but scoped to the embedded VEX
/// side-effect — under `--json` stderr is silenced, so this field is
/// the only channel these advisories reach a machine consumer on.
/// Empty (and omitted from JSON) when generation had nothing to
/// advise, so existing consumers see byte-identical output (additive-
/// only envelope contract).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<RunWarning>,
}

impl Envelope {
Expand Down Expand Up @@ -760,11 +770,29 @@ mod tests {
path: "/tmp/openvex.json".into(),
statements: 3,
format: "openvex-0.2.0".into(),
warnings: Vec::new(),
});
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
assert_eq!(v["vex"]["path"], "/tmp/openvex.json");
assert_eq!(v["vex"]["statements"], 3);
assert_eq!(v["vex"]["format"], "openvex-0.2.0");
// `vex.warnings` is skip-if-empty: a warning-free generation keeps
// the pre-existing three-key shape byte-identical for consumers.
assert!(
!v["vex"].as_object().unwrap().contains_key("warnings"),
"empty vex.warnings must be omitted, got {:?}",
v["vex"]
);

// Once generation raised advisories, they ride inside `vex` with
// the same code/detail shape as the top-level `warnings[]`.
env.vex.as_mut().unwrap().warnings.push(RunWarning {
code: "product_not_iri".into(),
detail: "product is not an IRI".into(),
});
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
assert_eq!(v["vex"]["warnings"][0]["code"], "product_not_iri");
assert_eq!(v["vex"]["warnings"][0]["detail"], "product is not an IRI");
}

#[test]
Expand Down
33 changes: 31 additions & 2 deletions crates/socket-patch-cli/tests/cli_apply_silent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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().expect("create tempdir");

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:?}"
);
}
1 change: 1 addition & 0 deletions crates/socket-patch-cli/tests/cli_parse_vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
249 changes: 249 additions & 0 deletions crates/socket-patch-cli/tests/e2e_embedded_vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,239 @@ 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().expect("create tempdir");
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":[]}"#,
)
.expect("seed stale OpenVEX doc");

let out = cli()
.args([
"apply",
"--cwd",
cwd.to_str().expect("tempdir path is UTF-8"),
"--offline",
"--json",
"--vex",
vex_path.to_str().expect("vex path is UTF-8"),
])
.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().expect("create tempdir");
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).expect("seed precious non-OpenVEX file");

let out = cli()
.args([
"apply",
"--cwd",
cwd.to_str().expect("tempdir path is UTF-8"),
"--offline",
"--json",
"--vex",
precious.to_str().expect("precious path is UTF-8"),
])
.output()
.expect("invoke apply");
assert!(!out.status.success());
assert_eq!(
std::fs::read(&precious).expect("read precious file back"),
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().expect("create tempdir");
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().expect("tempdir path is UTF-8"),
"--offline",
"--json",
"--vex",
bad_path.to_str().expect("vex path is UTF-8"),
"--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()
.expect("error.message is a string");
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().expect("create tempdir");
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().expect("tempdir path is UTF-8"),
"--offline",
"--vex",
vex_path.to_str().expect("vex path is UTF-8"),
"--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).expect("read written VEX doc"))
.expect("parse written VEX doc");
assert_eq!(doc["statements"][0]["products"][0]["@id"], "my app");
}

/// Under `--json` the run-level VEX advisories are silenced on stderr
/// (`note_warning` skips the print), so the envelope's `vex.warnings` is
/// the ONLY channel they reach a consumer on. Regression guard: the
/// embedded hosts built their `vex` summary from `statements` alone, so a
/// non-IRI product produced a warning that was invisible on exactly the
/// machine channel — absent from the envelope AND absent from stderr.
#[test]
fn apply_json_vex_warnings_ride_in_envelope() {
let tmp = tempfile::tempdir().expect("create tempdir");
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().expect("tempdir path is UTF-8"),
"--offline",
"--json",
"--vex",
vex_path.to_str().expect("vex path is UTF-8"),
"--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 env: Value = serde_json::from_slice(&out.stdout).expect("apply envelope JSON");
assert_eq!(env["vex"]["statements"], 1);
let warnings = env["vex"]["warnings"]
.as_array()
.expect("vex.warnings must be present for a non-IRI product");
assert!(
warnings.iter().any(|w| w["code"] == "product_not_iri"
&& w["detail"]
.as_str()
.map(|d| d.contains("IRI"))
.unwrap_or(false)),
"vex.warnings must carry product_not_iri with a human detail, got {warnings:?}"
);
// --json silences the stderr copy — the envelope is the sole channel,
// so machine consumers must not need to also scrape stderr.
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("Warning:"),
"--json must not duplicate the warning on stderr; got {stderr:?}"
);

// Control: a clean PURL product raises no advisory, and skip-if-empty
// pins the `warnings` key ABSENT (not `[]`) so pre-existing consumers
// see a byte-identical three-key vex object. Re-running on the patched
// tree is still a successful apply ("already patched"), so VEX runs.
let clean = cli()
.args([
"apply",
"--cwd",
cwd.to_str().expect("tempdir path is UTF-8"),
"--offline",
"--json",
"--vex",
vex_path.to_str().expect("vex path is UTF-8"),
"--vex-product",
"pkg:npm/my-app@1.0.0",
])
.output()
.expect("invoke apply (clean product)");
assert!(clean.status.success());
let env: Value = serde_json::from_slice(&clean.stdout).expect("apply envelope JSON");
assert_eq!(env["vex"]["statements"], 1);
assert!(
!env["vex"]
.as_object()
.expect("vex summary object")
.contains_key("warnings"),
"a warning-free run must omit vex.warnings entirely, got {:?}",
env["vex"]
);
}

// ──────────────────────────────────────────────────────────────────────
// scan --vex (read-only; zero installed packages → no network)
// ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -534,6 +767,17 @@ fn scan_json_vex_no_verify_emits_summary() {
assert_eq!(result["vex"]["statements"], 1);
assert_eq!(result["vex"]["format"], "openvex-0.2.0");
assert_eq!(result["vex"]["path"], vex_path.to_str().unwrap());
// A clean PURL product raises no advisory — skip-if-empty must keep the
// additive `warnings` key absent so pre-existing consumers see the
// unchanged three-key vex object.
assert!(
!result["vex"]
.as_object()
.expect("vex summary object")
.contains_key("warnings"),
"a warning-free scan run must omit vex.warnings, got {:?}",
result["vex"]
);

let doc: Value = serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap();
assert_eq!(doc["@context"], "https://openvex.dev/ns/v0.2.0");
Expand Down Expand Up @@ -562,6 +806,11 @@ fn scan_json_vex_no_verify_emits_summary() {
"pkg:npm/my-app@1.0.0",
"pkg:npm/vuln-pkg@1.0.0",
);

// The scan host's warning-population arm (its raw-json! vex object)
// ships with the scan/vendor host changes and is pinned there
// (in_process_vendor.rs: vendor_json_vex_warnings_ride_in_envelope);
// this suite pins the apply host + the skip-if-empty control above.
}

#[test]
Expand Down
Loading
Loading