Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(scan,get): skip vendored purls before download; warn on vendored…
…-uuid drift

scan --apply / --sync (JSON and interactive) now partitions vendor-owned
purls out of the selected set BEFORE download: the patch is consumed from
the committed artifact, and moving the manifest past the vendored uuid
would break VEX verification (vendor_uuid_mismatch) until a vendor run.
The skips surface in apply.patches[] as skipped/vendored (counted in
found/skipped, downloaded stays 0) and the newer uuid still rides
updates[] as the operator's signal to run scan --vendor. Wiremock test
pins the no-download contract via the request log.

get deliberately still honors an explicit fetch — both download paths
(by-package and --id/save_and_apply_patch) now emit a warnings[] entry +
stderr note when the manifest moves past the uuid the ledger wires,
naming both uuids and the vendor remedy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
  • Loading branch information
mikolalysenko and claude committed Jun 10, 2026
commit da2ebdb086392731fe8ee07b08e10864febd511a
88 changes: 77 additions & 11 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,42 @@ pub async fn download_and_apply_patches(
return (1, err_json);
}

// Vendored-uuid drift: an explicit `get` is allowed to move the
// manifest past the patch uuid the vendor ledger still wires (the user
// asked for that patch by name). Verification then fails closed
// (`vendor_uuid_mismatch`) until a `vendor` run re-vendors at the new
// uuid — tell the operator now instead of letting VEX surprise them
// later. (`scan` never hits this: it filters vendored purls before
// download.) The nested apply below skips the vendored purl either way.
if let Ok(vendor_state) = socket_patch_core::patch::vendor::load_state(&params.cwd).await {
if !vendor_state.entries.is_empty() {
for rec in &downloaded_patches {
let (Some(purl), Some(uuid)) = (rec["purl"].as_str(), rec["uuid"].as_str())
else {
continue;
};
if !matches!(rec["action"].as_str(), Some("added" | "updated")) {
continue;
}
let entry = vendor_state
.entries
.get(purl)
.or_else(|| vendor_state.entries.values().find(|e| e.base_purl == purl));
if let Some(entry) = entry.filter(|e| e.uuid != uuid) {
let w = format!(
"{purl} is vendored at patch {} but the manifest now records {uuid}; \
run `socket-patch vendor` to refresh the committed artifact",
entry.uuid
);
if !params.json && !params.silent {
eprintln!(" [note] {w}");
}
narrow_warnings.push(w);
}
}
}
}

if !params.json && !params.silent {
eprintln!("\nPatches saved to {}", manifest_path.display());
eprintln!(" Added: {patches_added}");
Expand Down Expand Up @@ -1533,6 +1569,35 @@ async fn save_and_apply_patch(
return 1;
}

// Vendored-uuid drift (mirrors `download_and_apply_patches`): the user
// explicitly fetched this uuid; if the vendor ledger still wires a
// different one, VEX verification fails closed (`vendor_uuid_mismatch`)
// until a `vendor` run refreshes the committed artifact.
let mut warnings: Vec<String> = Vec::new();
if added {
if let Ok(vendor_state) =
socket_patch_core::patch::vendor::load_state(&args.common.cwd).await
{
let entry = vendor_state.entries.get(&patch.purl).or_else(|| {
vendor_state
.entries
.values()
.find(|e| e.base_purl == patch.purl)
});
if let Some(entry) = entry.filter(|e| e.uuid != patch.uuid) {
let w = format!(
"{} is vendored at patch {} but the manifest now records {}; run \
`socket-patch vendor` to refresh the committed artifact",
patch.purl, entry.uuid, patch.uuid
);
if !args.common.json {
eprintln!(" [note] {w}");
}
warnings.push(w);
}
}
}

if !args.common.json {
println!("\nPatch saved to {}", manifest_path.display());
if added {
Expand Down Expand Up @@ -1591,17 +1656,18 @@ async fn save_and_apply_patch(
// record means the consumer already saw the metadata last time.
merge_metadata(&mut patch_record, patch_event_metadata(&patch));
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": status,
"found": 1,
"downloaded": if added { 1 } else { 0 },
"applied": if apply_succeeded { 1 } else { 0 },
"patches": [patch_record],
}))
.unwrap()
);
let mut result_json = serde_json::json!({
"status": status,
"found": 1,
"downloaded": if added { 1 } else { 0 },
"applied": if apply_succeeded { 1 } else { 0 },
"patches": [patch_record],
});
// Same contract as `download_and_apply_patches`: omitted when clean.
if !warnings.is_empty() {
result_json["warnings"] = serde_json::json!(warnings);
}
println!("{}", serde_json::to_string_pretty(&result_json).unwrap());
}

exit_code
Expand Down
66 changes: 59 additions & 7 deletions crates/socket-patch-cli/src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -812,14 +812,36 @@ pub async fn run(args: ScanArgs) -> i32 {
}
};

// Vendor-owned purls are skipped BEFORE download (any uuid):
// the patch is consumed from the committed artifact, and
// moving the manifest past the vendored uuid would break VEX
// verification (`vendor_uuid_mismatch`) until a vendor run.
// A newer patch still surfaces in `updates[]` — the
// operator's signal to run `scan --vendor` (or `vendor`).
let is_vendored = |p: &str| {
vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p))
};
let (vendored_selected, selected): (Vec<_>, Vec<_>) =
selected.into_iter().partition(|p| is_vendored(&p.purl));
let mut vendored_records: Vec<serde_json::Value> = vendored_selected
.iter()
.map(|p| {
serde_json::json!({
"purl": p.purl, "uuid": p.uuid,
"action": "skipped", "errorCode": "vendored",
})
})
.collect();
vendored_records.sort_by(|a, b| a["purl"].as_str().cmp(&b["purl"].as_str()));

let mut apply_code = 0i32;
if dry {
// Synthesize the per-patch outcome without touching disk.
// `decide_patch_action` consults the existing manifest,
// so it accurately reports what `--apply` *would* do.
let manifest_for_preview =
existing_manifest.clone().unwrap_or_else(PatchManifest::new);
let patches: Vec<serde_json::Value> = selected
let mut patches: Vec<serde_json::Value> = selected
.iter()
.map(|p| {
match super::get::decide_patch_action(
Expand All @@ -840,11 +862,12 @@ pub async fn run(args: ScanArgs) -> i32 {
}
})
.collect();
patches.extend(vendored_records.iter().cloned());
let added = patches.iter().filter(|p| p["action"] == "added").count();
let updated = patches.iter().filter(|p| p["action"] == "updated").count();
let skipped = patches.iter().filter(|p| p["action"] == "skipped").count();
result["apply"] = serde_json::json!({
"found": selected.len(),
"found": selected.len() + vendored_records.len(),
"downloaded": 0,
"skipped": skipped,
"failed": 0,
Expand All @@ -855,13 +878,16 @@ pub async fn run(args: ScanArgs) -> i32 {
"dryRun": true,
});
} else if selected.is_empty() {
// No patches selected (e.g. all paid for a free user, or
// no packages had patches). Emit empty `apply` so JSON
// shape is stable, then fall through to GC if requested.
// No patches left to download (e.g. all paid for a free
// user, no packages had patches, or everything selected is
// vendor-owned). Emit a stable-shape `apply` carrying any
// vendored skips, then fall through to GC if requested.
result["apply"] = serde_json::json!({
"found": 0, "downloaded": 0, "skipped": 0,
"found": vendored_records.len(),
"downloaded": 0,
"skipped": vendored_records.len(),
"failed": 0, "applied": 0, "updated": 0,
"patches": [],
"patches": vendored_records,
});
} else {
let params = DownloadParams {
Expand All @@ -882,6 +908,20 @@ pub async fn run(args: ScanArgs) -> i32 {
let mut apply_obj = apply_json;
if let Some(obj) = apply_obj.as_object_mut() {
obj.remove("status");
// Fold the pre-download vendored skips into the apply
// report: they were "found" by discovery and skipped
// here, never downloaded.
if !vendored_records.is_empty() {
let n = vendored_records.len() as u64;
for key in ["found", "skipped"] {
let bumped = obj.get(key).and_then(|v| v.as_u64()).unwrap_or(0) + n;
obj.insert(key.to_string(), serde_json::json!(bumped));
}
if let Some(patches) = obj.get_mut("patches").and_then(|p| p.as_array_mut())
{
patches.extend(vendored_records.iter().cloned());
}
}
}
result["apply"] = apply_obj;
if apply_code != 0 {
Expand Down Expand Up @@ -1131,6 +1171,18 @@ pub async fn run(args: ScanArgs) -> i32 {
Err(code) => return code,
};

// Vendor-owned purls never download/apply here (mirrors the JSON
// path): the committed artifact is the patch, and a manifest moved
// past the vendored uuid would break VEX verification until a vendor
// run refreshes the artifact.
let is_vendored =
|p: &str| vendored_purls.contains(p) || vendored_purls.contains(strip_purl_qualifiers(p));
let (vendored_selected, selected): (Vec<_>, Vec<_>) =
selected.into_iter().partition(|p| is_vendored(&p.purl));
for p in &vendored_selected {
println!(" [skip] {} (vendored — run scan --vendor to update)", p.purl);
}

if selected.is_empty() {
println!("No patches selected.");
return embed_vex_human(&args.common, &args.vex, &manifest_path, 0).await;
Expand Down
87 changes: 87 additions & 0 deletions crates/socket-patch-cli/tests/get_edge_cases_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,90 @@ fn get_help_lists_all_identifier_flags() {
);
}
}

#[tokio::test]
async fn get_on_vendored_purl_warns_about_uuid_drift() {
// An explicit `get --id <newer-uuid>` is allowed to move the manifest
// past the uuid the vendor ledger still wires — but it must SAY so:
// until a `vendor` run refreshes the artifact, VEX verification fails
// closed with `vendor_uuid_mismatch`. The warning rides the JSON
// `warnings` array (and stderr in human mode).
let mock = MockServer::start().await;
let purl = "pkg:npm/vendored-drift@1.0.0";

Mock::given(method("GET"))
.and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{UUID_B}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"uuid": UUID_B,
"purl": purl,
"publishedAt": "2024-02-01T00:00:00Z",
"files": {},
"vulnerabilities": {},
"description": "Newer patch",
"license": "MIT",
"tier": "free",
})))
.mount(&mock)
.await;

let tmp = tempfile::tempdir().unwrap();
// The vendor ledger wires the purl at UUID_A.
let vendor_dir = tmp.path().join(".socket/vendor");
std::fs::create_dir_all(&vendor_dir).unwrap();
std::fs::write(
vendor_dir.join("state.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"version": 1,
"entries": { purl: {
"ecosystem": "npm",
"basePurl": purl,
"uuid": UUID_A,
"artifact": {
"path": format!(".socket/vendor/npm/{UUID_A}/vendored-drift-1.0.0.tgz"),
},
"wiring": []
}}
}))
.unwrap(),
)
.unwrap();

let out = Command::new(binary())
.args([
"get",
UUID_B,
"--id",
"--save-only",
"--yes",
"--json",
"--api-url",
&mock.uri(),
"--api-token",
"fake",
"--org",
ORG_SLUG,
])
.current_dir(tmp.path())
.output()
.expect("run");
let code = out.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert_eq!(code, 0, "explicit get still succeeds; stdout={stdout}");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(v["status"], "success", "stdout={stdout}");
assert_eq!(v["patches"][0]["action"], "added", "stdout={stdout}");

let warnings = v["warnings"]
.as_array()
.unwrap_or_else(|| panic!("uuid drift must surface a warning; stdout={stdout}"));
assert_eq!(warnings.len(), 1, "stdout={stdout}");
let w = warnings[0].as_str().expect("warning string");
assert!(
w.contains("is vendored at patch") && w.contains(UUID_A) && w.contains(UUID_B),
"warning must name both uuids; got: {w}"
);
assert!(
w.contains("socket-patch vendor"),
"warning must point at the remedy; got: {w}"
);
}
Loading