From ef1586fa3b694eb6e7bda83178759d7ed66c5660 Mon Sep 17 00:00:00 2001 From: wenxin-jiang Date: Sat, 14 Nov 2026 17:00:20 -0500 Subject: [PATCH 1/7] Carry opt-in notes through CLI output Preserve optional activation metadata in manifest records and render it in get, list, apply, and OpenVEX output. Absent fields stay absent when serializing, so existing responses and manifests keep their current shape. Assisted-by: Claude Code:kimi-k3p1-pro --- .../src/commands/agent_download.rs | 6 ++ crates/socket-patch-cli/src/commands/apply.rs | 60 +++++++++++++ .../src/commands/fetch_stage.rs | 2 + crates/socket-patch-cli/src/commands/get.rs | 52 +++++++++++ crates/socket-patch-cli/src/commands/list.rs | 20 ++++- .../socket-patch-cli/src/commands/remove.rs | 2 + .../socket-patch-cli/src/commands/rollback.rs | 2 + .../src/commands/scan/discovery.rs | 16 ++++ .../src/commands/scan/hosted.rs | 2 + .../src/commands/scan/hosted/python.rs | 2 + .../socket-patch-cli/src/commands/scan/mod.rs | 6 ++ .../src/commands/scan/render.rs | 25 ++++++ .../src/commands/scan/rollout.rs | 4 + .../src/commands/scan/vendor_flow.rs | 7 ++ .../socket-patch-cli/src/commands/vendor.rs | 12 +++ crates/socket-patch-cli/src/commands/vex.rs | 2 + .../src/commands/vex_sources.rs | 2 + crates/socket-patch-cli/src/json_envelope.rs | 86 ++++++++++++++++++- crates/socket-patch-cli/src/ui/mod.rs | 2 +- crates/socket-patch-cli/src/ui/text.rs | 7 ++ .../tests/apply/apply_render.rs | 43 ++++++++++ .../socket-patch-cli/tests/cli/list_render.rs | 40 ++++++++- .../socket-patch-cli/tests/cli_parse_list.rs | 6 ++ .../tests/cli_parse_remove.rs | 2 + .../tests/e2e_embedded_vex.rs | 2 + crates/socket-patch-cli/tests/e2e_vex.rs | 2 + .../tests/e2e_vex_lockfile/bun.rs | 2 + .../tests/e2e_vex_lockfile/cargo.rs | 2 + .../tests/e2e_vex_lockfile/composer.rs | 2 + .../tests/e2e_vex_lockfile/deno.rs | 2 + .../tests/e2e_vex_lockfile/golang.rs | 2 + .../tests/e2e_vex_lockfile/maven.rs | 2 + .../tests/e2e_vex_lockfile/nuget.rs | 2 + .../tests/e2e_vex_lockfile/poetry.rs | 2 + .../tests/e2e_vex_lockfile/uv.rs | 2 + .../tests/e2e_vex_lockfile/yarn.rs | 2 + .../tests/e2e_vex_redirect.rs | 2 + .../socket-patch-cli/tests/e2e_vex_vendor.rs | 2 + .../socket-patch-cli/tests/get/get_fixture.rs | 2 + .../socket-patch-cli/tests/get/get_render.rs | 85 ++++++++++++++++++ .../tests/in_process_get_update_count.rs | 2 + .../tests/in_process_redirect.rs | 2 + .../tests/in_process_rollback_hosted.rs | 2 + .../socket-patch-cli/tests/vex/vex_fixture.rs | 2 + .../socket-patch-cli/tests/vex/vex_render.rs | 30 +++++++ .../socket-patch-core/src/api/blob_fetcher.rs | 2 + crates/socket-patch-core/src/api/client.rs | 13 +++ crates/socket-patch-core/src/api/ranking.rs | 4 + crates/socket-patch-core/src/api/types.rs | 27 +++++- .../src/hosted/memory/discover.rs | 2 + crates/socket-patch-core/src/ledgers.rs | 2 + .../src/manifest/cleanup_blobs.rs | 2 + .../src/manifest/operations.rs | 4 + .../socket-patch-core/src/manifest/records.rs | 2 + .../socket-patch-core/src/manifest/schema.rs | 31 ++++++- .../src/patch/redirect/golang_local.rs | 4 + .../src/patch/redirect/state.rs | 2 + crates/socket-patch-core/src/policy/filter.rs | 2 + crates/socket-patch-core/src/policy/tests.rs | 2 + crates/socket-patch-core/src/rollout/stage.rs | 4 + .../src/utils/purl_key_nuget_vendor_tests.rs | 2 + .../socket-patch-core/src/vendor/bun_lock.rs | 2 + crates/socket-patch-core/src/vendor/cargo.rs | 2 + .../src/vendor/composer_lock.rs | 2 + crates/socket-patch-core/src/vendor/gem.rs | 6 ++ .../src/vendor/go_native_vendor.rs | 2 + crates/socket-patch-core/src/vendor/golang.rs | 2 + .../src/vendor/maven_repo.rs | 4 + crates/socket-patch-core/src/vendor/mod.rs | 4 + .../src/vendor/npm_common.rs | 2 + .../src/vendor/npm_flavor.rs | 6 ++ .../socket-patch-core/src/vendor/npm_lock.rs | 2 + .../src/vendor/nuget_feed.rs | 2 + .../socket-patch-core/src/vendor/pnpm_lock.rs | 2 + .../src/vendor/pnpm_lock_legacy.rs | 2 + crates/socket-patch-core/src/vendor/pypi.rs | 4 + crates/socket-patch-core/src/vendor/reuse.rs | 2 + .../src/vendor/service_fetch.rs | 2 + crates/socket-patch-core/src/vendor/state.rs | 2 + crates/socket-patch-core/src/vendor/verify.rs | 2 + .../socket-patch-core/src/vendor/vlt_lock.rs | 2 + .../src/vendor/yarn_berry_lock.rs | 2 + .../src/vendor/yarn_classic_lock.rs | 2 + .../src/vendor/yarn_layering_tests.rs | 2 + crates/socket-patch-core/src/vex/build.rs | 8 ++ .../src/vex/conformance_tests.rs | 2 + crates/socket-patch-core/src/vex/verify.rs | 24 ++++++ .../tests/blob_fetcher_edges_e2e.rs | 2 + crates/socket-patch-core/tests/vlt_locks.rs | 2 + 89 files changed, 755 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index dab40dc10..baf934074 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -148,6 +148,12 @@ pub(crate) fn patch_event_metadata(patch: &PatchResponse) -> serde_json::Value { serde_json::Value::String(patch.license.clone()), ); meta.insert("tier".into(), serde_json::Value::String(patch.tier.clone())); + if let Some(requires_opt_in) = patch.requires_opt_in { + meta.insert("requiresOptIn".into(), serde_json::json!(requires_opt_in)); + } + if let Some(opt_in_note) = &patch.opt_in_note { + meta.insert("optInNote".into(), serde_json::json!(opt_in_note)); + } meta.insert( "exportedAt".into(), serde_json::Value::String(patch.published_at.clone()), diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 197ec080e..b4d4d8166 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -1501,6 +1501,23 @@ fn collect_apply_outcomes( (applied, skipped) } +type OptInPatches = Vec<(String, String, Option, Option)>; + +fn opt_in_patches(manifest: &PatchManifest) -> OptInPatches { + manifest + .patches + .iter() + .map(|(purl, patch)| { + ( + purl.clone(), + patch.uuid.clone(), + patch.requires_opt_in, + patch.opt_in_note.clone(), + ) + }) + .collect() +} + /// The locked half of `apply`: everything from the manifest read on — the /// package-manager layout gate, the apply loop, embedded VEX, output and /// telemetry — over a `lock` the caller already holds and the caller's @@ -1557,6 +1574,7 @@ pub(crate) async fn run_locked( // Packages the project patched itself sit this run out (warned); every // other record still applies. The read-only `--check` verifies them all. + let opt_ins = opt_in_patches(&manifest); let mut manifest = manifest; let (user_patch_warnings, user_patch_skips) = if args.check { Default::default() @@ -1830,6 +1848,15 @@ pub(crate) async fn run_locked( for line in block { println!("{line}"); } + if args.nested.is_none() { + let notes = format_opt_in_notes(&opt_ins, &results, &unmatched); + if !notes.is_empty() { + println!("\nOpt-in required:"); + for note in notes { + println!("{note}"); + } + } + } if args.common.verbose && !results.is_empty() { print_verbose_verification(&results); } @@ -1872,6 +1899,9 @@ pub(crate) async fn run_locked( if args.common.json { let mut env = Envelope::new(Command::Apply); env.dry_run = args.common.dry_run; + for (purl, uuid, requires_opt_in, opt_in_note) in &opt_ins { + env.register_opt_in(purl, uuid, *requires_opt_in, opt_in_note.as_deref()); + } for skip in &user_patch_skips { env.record(skip.clone()); } @@ -2367,6 +2397,36 @@ fn format_dry_run_summary(tally: &ApplyTally, not_found: usize) -> Vec { lines } +fn format_opt_in_notes( + opt_ins: &OptInPatches, + results: &[ApplyResult], + unmatched: &[String], +) -> Vec { + let shown: HashSet<&str> = results + .iter() + .map(|result| result.package_key.as_str()) + .chain(unmatched.iter().map(String::as_str)) + .collect(); + let mut notes: Vec<(&str, &str)> = opt_ins + .iter() + .filter_map(|(purl, _, requires_opt_in, opt_in_note)| { + if requires_opt_in != &Some(true) || !shown.contains(purl.as_str()) { + return None; + } + let note = opt_in_note.as_deref()?; + (!note.trim().is_empty()).then_some((purl.as_str(), note)) + }) + .collect(); + notes.sort_unstable(); + notes + .into_iter() + .map(|(purl, note)| { + let note = crate::ui::terminal_text(note).replace('\n', "\n "); + format!(" {}: {note}", normalize_purl(purl)) + }) + .collect() +} + /// One `Patched packages:` line. `copy` names the physical copy when a /// package has more than one (otherwise the lines are indistinguishable). fn format_patched_line(purl: &str, copy: Option<&str>, detail: &str) -> String { diff --git a/crates/socket-patch-cli/src/commands/fetch_stage.rs b/crates/socket-patch-cli/src/commands/fetch_stage.rs index e4d9f0f39..1527bfa20 100644 --- a/crates/socket-patch-cli/src/commands/fetch_stage.rs +++ b/crates/socket-patch-cli/src/commands/fetch_stage.rs @@ -284,6 +284,8 @@ mod tests { description: String::new(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); manifest diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e0902f63a..30e0d79b2 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -367,6 +367,12 @@ fn format_search_results( if !desc.is_empty() { out.push_str(&format!(" Description: {desc}\n")); } + if patch.requires_opt_in == Some(true) { + if let Some(note) = &patch.opt_in_note { + let note = crate::ui::terminal_text(note).replace('\n', "\n "); + out.push_str(&format!(" Opt-in required: {note}\n")); + } + } let mut fixes: Vec<(String, String)> = patch .vulnerabilities .iter() @@ -1284,6 +1290,15 @@ pub async fn run(args: GetArgs) -> i32 { crate::ui::stderr_color(), ) ); + if patch.requires_opt_in == Some(true) { + if let Some(note) = &patch.opt_in_note { + let note = crate::ui::terminal_text(note).replace('\n', "\n "); + println!( + "Opt-in required for {}: {note}", + normalize_purl(&patch.purl) + ); + } + } } // Record the fetch BEFORE the save+apply step so the @@ -1802,6 +1817,7 @@ pub async fn run(args: GetArgs) -> i32 { verbose: args.common.verbose, }; let mut env = new_envelope(&args.common); + register_opt_ins(&mut env, &selected); // The narrowing skips came first (before any download). fold_narrowing_into_result(&mut env, &narrow_skips, &narrow_warnings); // The engine records into `env` and never prints it: exactly one JSON @@ -1904,6 +1920,7 @@ async fn agent_dry_run( if args.common.json { let mut env = new_envelope(&args.common); env.dry_run = true; + register_opt_ins(&mut env, selected); fold_narrowing_into_result(&mut env, narrow_skips, narrow_warnings); for event in events { env.record(event); @@ -2159,6 +2176,12 @@ async fn save_and_apply_patch( if args.common.json { let mut env = new_envelope(&args.common); + env.register_opt_in( + &patch.purl, + &patch.uuid, + patch.requires_opt_in, + patch.opt_in_note.as_deref(), + ); fold_narrowing_into_result(&mut env, &[], run_warnings); // The metadata rides only a changed record — an `already_in_manifest` // skip means the consumer already saw it last time. @@ -2206,6 +2229,19 @@ fn search_result_from_response(patch: &PatchResponse) -> PatchSearchResult { license: patch.license.clone(), tier: patch.tier.clone(), vulnerabilities: patch.vulnerabilities.clone(), + requires_opt_in: patch.requires_opt_in, + opt_in_note: patch.opt_in_note.clone(), + } +} + +fn register_opt_ins(env: &mut Envelope, patches: &[PatchSearchResult]) { + for patch in patches { + env.register_opt_in( + &patch.purl, + &patch.uuid, + patch.requires_opt_in, + patch.opt_in_note.as_deref(), + ); } } @@ -2254,6 +2290,7 @@ async fn run_get_hosted( // common.json (see run_redirect_selected's doc). let scan_result = args.common.json.then(|| { let mut env = new_envelope(&args.common); + register_opt_ins(&mut env, selected); fold_narrowing_into_result(&mut env, narrow_skips, narrow_warnings); env }); @@ -2305,6 +2342,7 @@ async fn run_get_vendored( telemetry: &TelemetryAuth, ) -> i32 { let mut env = new_envelope(&args.common); + register_opt_ins(&mut env, selected); fold_narrowing_into_result(&mut env, narrow_skips, narrow_warnings); // Dry run: ledger-classification preview only (scan's posture) — no // download, no vendor step, no writes. @@ -2568,6 +2606,8 @@ mod tests { license: "MIT".into(), tier: tier.into(), vulnerabilities: HashMap::::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -2899,6 +2939,8 @@ mod tests { description: String::new(), license: String::new(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); m @@ -3080,6 +3122,8 @@ mod tests { description: "desc".into(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, }; let meta = patch_event_metadata(&patch); assert!(meta.as_object().unwrap().get("severity").is_none()); @@ -3110,6 +3154,8 @@ mod tests { description: "Fixes prototype pollution in minimist".into(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, }; let meta = patch_event_metadata(&patch); assert_eq!(meta["description"], "Fixes prototype pollution in minimist"); @@ -3151,6 +3197,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; let meta = patch_event_metadata(&patch); let ids: Vec<&str> = meta["vulnerabilities"] @@ -3173,6 +3221,8 @@ mod tests { description: "desc".into(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, }; let meta = patch_event_metadata(&patch); // `severity` is intentionally omitted (not null) when there @@ -3748,6 +3798,8 @@ mod tests { description: "desc".into(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index b0c9c2961..56560fc98 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -185,6 +185,12 @@ fn build_list_envelope(entries: &[ListEntry<'_>]) -> Envelope { details["lockfiles"] = serde_json::json!(entry.lockfiles); } + env.register_opt_in( + entry.purl, + &patch.uuid, + patch.requires_opt_in, + patch.opt_in_note.as_deref(), + ); env.record( PatchEvent::new(PatchAction::Discovered, entry.purl.to_string()) .with_uuid(patch.uuid.clone()) @@ -246,10 +252,7 @@ fn failed_listing( /// control characters that would rewrite the terminal (ESC, a stray /// `\r`), keeping newlines and tabs, and normalize `\r\n`. fn sanitize(s: &str) -> String { - s.replace("\r\n", "\n") - .chars() - .filter(|&c| c == '\n' || c == '\t' || !c.is_control()) - .collect() + crate::ui::terminal_text(s) } /// `"{indent}{label}: {value}"`, or `None` when the value is blank (no @@ -290,6 +293,11 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { lines.extend(field(" ", "License", &patch.license)); lines.extend(field(" ", "Exported", &patch.exported_at)); lines.extend(field(" ", "Description", &patch.description)); + if patch.requires_opt_in == Some(true) { + if let Some(note) = &patch.opt_in_note { + lines.extend(field(" ", "Opt-in required", note)); + } + } let mut vuln_entries: Vec<_> = patch.vulnerabilities.iter().collect(); vuln_entries.sort_by(|a, b| a.0.cmp(b.0)); @@ -667,6 +675,8 @@ mod tests { description: "Fixes prototype pollution".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -712,6 +722,8 @@ mod tests { description: "desc".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/commands/remove.rs b/crates/socket-patch-cli/src/commands/remove.rs index 01e49f4cd..28c0a8b85 100644 --- a/crates/socket-patch-cli/src/commands/remove.rs +++ b/crates/socket-patch-cli/src/commands/remove.rs @@ -1892,6 +1892,8 @@ mod tests { description: "test".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs index bccb843f8..e476bd606 100644 --- a/crates/socket-patch-cli/src/commands/rollback.rs +++ b/crates/socket-patch-cli/src/commands/rollback.rs @@ -4305,6 +4305,8 @@ mod tests { description: "test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index eef505c0f..f1e571219 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -936,6 +936,8 @@ mod tests { severity: None, title: String::new(), published_at: None, + requires_opt_in: None, + opt_in_note: None, }) .collect(), } @@ -958,6 +960,8 @@ mod tests { severity: Some((*severity).to_string()), title: String::new(), published_at: Some((*published).to_string()), + requires_opt_in: None, + opt_in_note: None, }) .collect(), } @@ -1768,6 +1772,8 @@ mod tests { severity: None, title: String::new(), published_at: None, + requires_opt_in: None, + opt_in_note: None, }], } } @@ -1786,6 +1792,8 @@ mod tests { severity: None, title: String::new(), published_at: None, + requires_opt_in: None, + opt_in_note: None, } } @@ -1811,6 +1819,8 @@ mod tests { ((*id).to_string(), vuln) }) .collect(), + requires_opt_in: None, + opt_in_note: None, } } @@ -2082,6 +2092,8 @@ mod tests { license: String::new(), tier: "free".to_string(), vulnerabilities: std::collections::HashMap::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -2412,6 +2424,8 @@ mod tests { description: String::new(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, }; let entry = |uuid: &str, detached: bool| VendorEntry { detached, @@ -2576,6 +2590,8 @@ mod tests { description: String::new(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, }), ..VendorEntry::new( "npm".into(), diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index fc2e87476..6f3880cf9 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -3466,6 +3466,8 @@ mod tests { description: String::new(), license: String::new(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted/python.rs b/crates/socket-patch-cli/src/commands/scan/hosted/python.rs index 22488ffee..23f9a7fa6 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted/python.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted/python.rs @@ -331,6 +331,8 @@ mod tests { description: String::new(), license: String::new(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 0d8db20b4..6eddb7aec 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -3995,6 +3995,8 @@ mod tests { description: String::new(), license: String::new(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); } @@ -4144,6 +4146,8 @@ mod tests { description: String::new(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } @@ -4410,6 +4414,8 @@ mod tests { license: "MIT".to_string(), tier: "free".to_string(), vulnerabilities: HashMap::new(), + requires_opt_in: None, + opt_in_note: None, }; let mut state = VendorState::new(); let entry: socket_patch_core::vendor::VendorEntry = serde_json::from_value( diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index c51db1c93..e14fbf582 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -684,6 +684,14 @@ pub(super) fn patch_block(b: &PatchBlock) -> Vec { if !desc.is_empty() { lines.push(format!(" {desc}")); } + if p.requires_opt_in == Some(true) { + if let Some(note) = &p.opt_in_note { + let note = ui::terminal_text(note).replace('\n', "\n "); + if !note.trim().is_empty() { + lines.push(format!(" Opt-in required: {note}")); + } + } + } lines.push(String::new()); lines } @@ -745,6 +753,8 @@ mod tests { .iter() .map(|(k, v)| (k.to_string(), v.clone())) .collect(), + requires_opt_in: None, + opt_in_note: None, } } @@ -1278,6 +1288,21 @@ mod tests { ); } + #[test] + fn patch_block_renders_sanitized_opt_in_note() { + let mut p = patch(&[], ""); + p.requires_opt_in = Some(true); + p.opt_in_note = Some( + "Set `safeMode` to `true`.\x1b[2J\x1b]0;bad\x07\r\nDefaults remain vulnerable." + .to_string(), + ); + let out = patch_block(&block(&p, false)).join("\n"); + assert!(out.contains( + " Opt-in required: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." + )); + assert!(!out.contains('\x1b') && !out.contains('\x07') && !out.contains('\r')); + } + #[test] fn patch_block_is_deterministic_across_hash_orders() { // Build the same map many times: HashMap iteration order varies diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index f27f81b1f..6c42a50f5 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -275,6 +275,8 @@ mod tests { ) }) .collect(), + requires_opt_in: None, + opt_in_note: None, } } @@ -291,6 +293,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); } diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 3c85c233a..a5cc9aa76 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -421,6 +421,7 @@ pub(crate) fn merge_vendor_envelope(env: &mut Envelope, venv: Envelope) { .into_iter() .map(|e| tag_mode(e, Some("vendored"))), ); + env.add_registered_opt_in_to_events(); env.warnings.extend(venv.warnings); env.sidecars.extend(venv.sidecars); // A hard engine error (an unreadable vendor ledger, a refused group @@ -1201,6 +1202,8 @@ mod migration_tests { description: "fixture".into(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, } } @@ -1588,6 +1591,8 @@ mod preview_tests { license: "MIT".into(), tier: "free".into(), vulnerabilities: HashMap::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -2216,6 +2221,8 @@ mod npm_lock_entry_refusal_tests { license: "MIT".into(), tier: "free".into(), vulnerabilities: HashMap::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 0d4f131ab..f0c8f3e41 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -5332,6 +5332,8 @@ mod dispatch_tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; let sources = PatchSources { blobs_path: tmp.path(), @@ -5392,6 +5394,8 @@ mod plan_gate_tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -5828,6 +5832,8 @@ mod variant_probe_tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -6314,6 +6320,8 @@ mod gc_tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); let manifest_path = socket.join("manifest.json"); @@ -6759,6 +6767,8 @@ mod gc_tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); let manifest_path = socket.join("manifest.json"); @@ -7575,6 +7585,8 @@ mod persist_tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 2231d8378..c29240c60 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -2839,6 +2839,8 @@ mod npm_prior_tests { description: "p".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; let mut state = RedirectState::new(); state diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 79e73c88a..12ccfd981 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -1411,6 +1411,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 37c6ef4f6..1ea3dc05a 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -27,9 +27,16 @@ use serde::Serialize; use socket_patch_core::manifest::cleanup_blobs::CleanupResult; +use std::collections::HashMap; use socket_patch_core::patch::sidecars::SidecarRecord; +#[derive(Debug, Clone)] +struct OptInMetadata { + requires_opt_in: Option, + opt_in_note: Option, +} + /// Top-level JSON envelope (see the module doc for which commands emit it). #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -97,6 +104,12 @@ pub struct Envelope { /// enforces that. #[serde(flatten)] pub extra: serde_json::Map, + #[serde(skip)] + opt_in_by_patch: HashMap<(String, String), OptInMetadata>, + #[serde(skip)] + opt_in_by_purl: HashMap, + #[serde(skip)] + opt_in_by_uuid: HashMap, } /// The keys every envelope owns. [`Envelope::set_extra`] refuses them so a @@ -187,7 +200,77 @@ impl Envelope { vex: None, gc: None, extra: serde_json::Map::new(), + opt_in_by_patch: HashMap::new(), + opt_in_by_purl: HashMap::new(), + opt_in_by_uuid: HashMap::new(), + } + } + + pub(crate) fn register_opt_in( + &mut self, + purl: &str, + uuid: &str, + requires_opt_in: Option, + opt_in_note: Option<&str>, + ) { + if requires_opt_in.is_none() && opt_in_note.is_none() { + return; + } + let metadata = OptInMetadata { + requires_opt_in, + opt_in_note: opt_in_note.map(str::to_string), + }; + self.opt_in_by_patch + .insert((purl.to_string(), uuid.to_string()), metadata.clone()); + self.opt_in_by_purl + .insert(purl.to_string(), metadata.clone()); + self.opt_in_by_uuid.insert(uuid.to_string(), metadata); + } + + fn add_registered_opt_in(&self, event: &mut PatchEvent) { + let metadata = event + .purl + .as_deref() + .zip(event.uuid.as_deref()) + .and_then(|(purl, uuid)| { + self.opt_in_by_patch + .get(&(purl.to_string(), uuid.to_string())) + }) + .or_else(|| { + event + .uuid + .as_deref() + .and_then(|uuid| self.opt_in_by_uuid.get(uuid)) + }) + .or_else(|| { + event + .purl + .as_deref() + .and_then(|purl| self.opt_in_by_purl.get(purl)) + }); + let Some(metadata) = metadata else { + return; + }; + let mut details = event + .details + .take() + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::json!({})); + if let Some(requires_opt_in) = metadata.requires_opt_in { + details["requiresOptIn"] = serde_json::json!(requires_opt_in); + } + if let Some(opt_in_note) = &metadata.opt_in_note { + details["optInNote"] = serde_json::json!(opt_in_note); + } + event.details = Some(details); + } + + pub(crate) fn add_registered_opt_in_to_events(&mut self) { + let mut events = std::mem::take(&mut self.events); + for event in &mut events { + self.add_registered_opt_in(event); } + self.events = events; } /// Set a command-specific top-level key (see [`Envelope::extra`]). @@ -229,7 +312,8 @@ impl Envelope { /// relying on every command to remember a follow-up /// `mark_partial_failure` call. A run can never end up reporting /// `Success` while carrying a `Failed` event. - pub fn record(&mut self, event: PatchEvent) { + pub fn record(&mut self, mut event: PatchEvent) { + self.add_registered_opt_in(&mut event); self.summary.bump(event.action); if matches!(event.action, PatchAction::Failed) { self.mark_partial_failure(); diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index f4a1b8bab..8d604dd24 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -25,7 +25,7 @@ pub use prompt::{select_one, SelectError}; pub(crate) use status::StatusLine; pub(crate) use text::{ agent_next_steps, manifest_error_message, next_steps, next_steps_installed, plural, - sentence_case, shell_word, short_uuid, sweep_failure, truncate, + sentence_case, shell_word, short_uuid, sweep_failure, terminal_text, truncate, }; /// [`text::no_patches_message`] for this run: the manifest path is named diff --git a/crates/socket-patch-cli/src/ui/text.rs b/crates/socket-patch-cli/src/ui/text.rs index 9453fa173..95734ed03 100644 --- a/crates/socket-patch-cli/src/ui/text.rs +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -248,6 +248,13 @@ pub fn truncate(s: &str, max: usize) -> String { format!("{}{ELLIPSIS}", head.trim_end()) } +pub(crate) fn terminal_text(s: &str) -> String { + s.replace("\r\n", "\n") + .chars() + .filter(|&c| c == '\n' || c == '\t' || !c.is_control()) + .collect() +} + /// `word` as one argument a user can paste into their shell: bare when it /// holds only characters no shell treats specially, otherwise quoted (POSIX /// single quotes; double quotes on Windows, which cmd and PowerShell both diff --git a/crates/socket-patch-cli/tests/apply/apply_render.rs b/crates/socket-patch-cli/tests/apply/apply_render.rs index 7c6b4c171..9c0046720 100644 --- a/crates/socket-patch-cli/tests/apply/apply_render.rs +++ b/crates/socket-patch-cli/tests/apply/apply_render.rs @@ -180,6 +180,49 @@ fn verbose_dry_run_prints_per_file_labels_and_hashes() { } } +#[test] +fn opt_in_note_renders_in_human_and_json_apply_output() { + const NAME: &str = "opt-in-note"; + const PURL: &str = "pkg:npm/opt-in-note@1.0.0"; + const UUID: &str = "75757575-7575-4757-8757-757575757575"; + const NOTE: &str = + "Set `safeMode` to `true`.\x1b[2J\x1b]0;bad\x07\r\nDefaults remain vulnerable."; + let tmp = tempfile::tempdir().unwrap(); + write_root_package_json(tmp.path()); + let installed = install_npm_pkg(tmp.path(), NAME, "1.0.0", Some(MM_BEFORE)); + let mut record = patch_record(UUID, one_file("package/index.js", MM_BEFORE, MM_AFTER)); + record["requiresOptIn"] = json!(true); + record["optInNote"] = json!(NOTE); + write_manifest(tmp.path(), json!({ PURL: record })); + stage_blob(tmp.path(), &git_sha256(MM_AFTER), MM_AFTER); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline"], &[]); + assert_eq!(code, 0, "stderr={stderr}"); + assert_eq!(std::fs::read(installed).unwrap(), MM_AFTER); + assert!( + stdout.contains(&format!( + "Opt-in required:\n {PURL}: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." + )), + "stdout={stdout}" + ); + assert!( + !stdout.contains('\x1b') && !stdout.contains('\x07') && !stdout.contains('\r'), + "terminal controls must be stripped: {stdout:?}" + ); + + let (code, stdout, stderr) = run_apply(tmp.path(), &["--offline", "--json"], &[]); + assert_eq!(code, 0, "stderr={stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let event = envelope["events"] + .as_array() + .unwrap() + .iter() + .find(|event| event["purl"] == PURL) + .unwrap(); + assert_eq!(event["details"]["requiresOptIn"], true); + assert_eq!(event["details"]["optInNote"], NOTE); +} + // ═══════════ layout notes and refusals ═══════════ /// A pnpm store layout gets the informational stderr note. diff --git a/crates/socket-patch-cli/tests/cli/list_render.rs b/crates/socket-patch-cli/tests/cli/list_render.rs index c2d101c9a..c516b97a8 100644 --- a/crates/socket-patch-cli/tests/cli/list_render.rs +++ b/crates/socket-patch-cli/tests/cli/list_render.rs @@ -208,7 +208,9 @@ fn write_sparse_manifest(root: &Path) { }}, "description": "Fixes an advisory with no CVE id", "license": "MIT", - "tier": "free" + "tier": "free", + "requiresOptIn": true, + "optInNote": "Set `safeMode` to `true`; defaults remain vulnerable." }}, "pkg:npm/bare@1.0.0": {{ "uuid": "22222222-2222-4222-8222-222222222222", @@ -300,6 +302,42 @@ fn list_non_json_minimal_record_omits_empty_sections() { ); } +#[test] +fn list_renders_opt_in_note_in_human_and_json_output() { + const PURL: &str = "pkg:npm/ghsa-only@1.0.0"; + const NOTE: &str = "Set `safeMode` to `true`; defaults remain vulnerable."; + let tmp = tempfile::tempdir().unwrap(); + write_sparse_manifest(tmp.path()); + + let (code, stdout, stderr) = + common::run_with_env(tmp.path(), &["list"], &[("SOCKET_TELEMETRY_DISABLED", "1")]); + assert_eq!(code, 0, "stderr:\n{stderr}"); + assert!( + package_block(&stdout, PURL).contains(&format!("Opt-in required: {NOTE}")), + "stdout={stdout}" + ); + assert!( + !package_block(&stdout, "pkg:npm/bare@1.0.0").contains("Opt-in required"), + "legacy records omit the note: {stdout}" + ); + + let (code, stdout, stderr) = common::run_with_env( + tmp.path(), + &["list", "--json"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_eq!(code, 0, "stderr:\n{stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let event = envelope["events"] + .as_array() + .unwrap() + .iter() + .find(|event| event["purl"] == PURL) + .unwrap(); + assert_eq!(event["details"]["requiresOptIn"], true); + assert_eq!(event["details"]["optInNote"], NOTE); +} + /// A vlt vendored entry (the `flavor: "vlt"` directory artifact) lists as a /// vendored record once no manifest holds it. #[test] diff --git a/crates/socket-patch-cli/tests/cli_parse_list.rs b/crates/socket-patch-cli/tests/cli_parse_list.rs index c25878af9..85701bf3b 100644 --- a/crates/socket-patch-cli/tests/cli_parse_list.rs +++ b/crates/socket-patch-cli/tests/cli_parse_list.rs @@ -120,6 +120,8 @@ fn populated_manifest() -> PatchManifest { description: "Test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -622,6 +624,8 @@ fn multi_manifest() -> PatchManifest { description: format!("description for {uuid}"), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } @@ -972,6 +976,8 @@ fn hosted_record(uuid: &str) -> PatchRecord { description: "Hosted patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/cli_parse_remove.rs b/crates/socket-patch-cli/tests/cli_parse_remove.rs index b836d922d..a9b269854 100644 --- a/crates/socket-patch-cli/tests/cli_parse_remove.rs +++ b/crates/socket-patch-cli/tests/cli_parse_remove.rs @@ -235,6 +235,8 @@ async fn run_removes_matching_patch_and_exits_zero() { description: "test".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs index 6159f8c2a..5253db432 100644 --- a/crates/socket-patch-cli/tests/e2e_embedded_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_embedded_vex.rs @@ -90,6 +90,8 @@ fn make_record( description: format!("Patch {uuid}"), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex.rs b/crates/socket-patch-cli/tests/e2e_vex.rs index 0d0b0c7ad..35d6db35f 100644 --- a/crates/socket-patch-cli/tests/e2e_vex.rs +++ b/crates/socket-patch-cli/tests/e2e_vex.rs @@ -99,6 +99,8 @@ fn make_record( description: format!("Patch {uuid}"), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs index 895d87e84..e8e528484 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/bun.rs @@ -1113,6 +1113,8 @@ fn write_ledger_record(cwd: &Path, uuid: &str) { description: "bun lockfile vex patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); let dir = cwd.join(".socket/vendor"); diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs index 7fc847b18..cba20c70f 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/cargo.rs @@ -295,6 +295,8 @@ fn record(uuid: &str, file: &str, before: &[u8], after: &[u8]) -> PatchRecord { description: "ledger patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/composer.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/composer.rs index 51109a757..d6e8bac5f 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/composer.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/composer.rs @@ -350,6 +350,8 @@ fn record(uuid: &str) -> PatchRecord { description: "lockfile vex fixture".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/deno.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/deno.rs index c466b72aa..8a5d6d4b6 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/deno.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/deno.rs @@ -163,6 +163,8 @@ fn record(uuid: &str) -> PatchRecord { description: "ledger record".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs index da02645f8..bb18780f0 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/golang.rs @@ -304,6 +304,8 @@ fn record(uuid: &str, file: &str, before: &[u8], after: &[u8]) -> PatchRecord { description: "ledger patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs index 1ac5cb196..e99a97077 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/maven.rs @@ -335,6 +335,8 @@ fn record(uuid: &str, files: &[(&str, &[u8], &[u8])]) -> PatchRecord { description: "ledger record".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs index 31ccc5736..7f80369d3 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/nuget.rs @@ -316,6 +316,8 @@ fn record(uuid: &str, files: &[(&str, &[u8], &[u8])]) -> PatchRecord { description: "ledger record".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs index 77c0f2fc4..a4a6b2bd2 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/poetry.rs @@ -390,6 +390,8 @@ fn record(uuid: &str) -> PatchRecord { description: "vexfixture patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs index 148cc8ad3..e6f8aaa3d 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/uv.rs @@ -500,6 +500,8 @@ fn record(uuid: &str) -> PatchRecord { description: "vexfixture patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs index 36e40af84..ffb46f295 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs @@ -395,6 +395,8 @@ fn record(uuid: &str) -> PatchRecord { description: "yarn lockfile patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs index 39d773b77..b431b7b1c 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_redirect.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_redirect.rs @@ -131,6 +131,8 @@ fn make_record(uuid: &str, after_hash: &str, vuln_id: &str, cves: &[&str]) -> Pa description: format!("Patch {uuid}"), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index a982dc3af..3c8fa9440 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -93,6 +93,8 @@ fn make_record( description: format!("Patch {uuid}"), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/get/get_fixture.rs b/crates/socket-patch-cli/tests/get/get_fixture.rs index 25262a552..20078255f 100644 --- a/crates/socket-patch-cli/tests/get/get_fixture.rs +++ b/crates/socket-patch-cli/tests/get/get_fixture.rs @@ -279,6 +279,8 @@ pub fn search_result(uuid: &str, purl: &str) -> PatchSearchResult { license: "MIT".into(), tier: "free".into(), vulnerabilities: HashMap::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/get/get_render.rs b/crates/socket-patch-cli/tests/get/get_render.rs index 4ab6f761b..712902c97 100644 --- a/crates/socket-patch-cli/tests/get/get_render.rs +++ b/crates/socket-patch-cli/tests/get/get_render.rs @@ -354,6 +354,91 @@ async fn human_search_results_show_paid_access_and_fixes_lines() { assert_eq!(manifest_json(tmp.path())["patches"][PURL]["uuid"], UUID); } +#[tokio::test] +async fn opt_in_note_renders_and_persists_in_human_and_json_get() { + const NOTE: &str = + "Set `safeMode` to `true`.\x1b[2J\x1b]0;bad\x07\r\nDefaults remain vulnerable."; + let server = MockServer::start().await; + let cve = "CVE-2024-4243"; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-cve/{cve}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Opt-in patch", "license": "MIT", "tier": "free", + "vulnerabilities": {}, + "requiresOptIn": true, + "optInNote": NOTE, + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": good_files(), + "vulnerabilities": {}, + "description": "Opt-in patch", + "license": "MIT", + "tier": "free", + "requiresOptIn": true, + "optInNote": NOTE, + }))) + .mount(&server) + .await; + + let human = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = run_get_bin(human.path(), &server.uri(), &[cve, "--save-only"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains( + "Opt-in required: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." + ), + "stdout={stdout}" + ); + assert!( + !stdout.contains('\x1b') && !stdout.contains('\x07') && !stdout.contains('\r'), + "terminal controls must be stripped: {stdout:?}" + ); + let record = &manifest_json(human.path())["patches"][PURL]; + assert_eq!(record["requiresOptIn"], true); + assert_eq!(record["optInNote"], NOTE); + + let uuid_human = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = + run_get_bin(uuid_human.path(), &server.uri(), &[UUID, "--save-only"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains(&format!( + "Opt-in required for {PURL}: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." + )), + "stdout={stdout}" + ); + assert!( + !stdout.contains('\x1b') && !stdout.contains('\x07') && !stdout.contains('\r'), + "UUID terminal controls must be stripped: {stdout:?}" + ); + + let json = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = + run_get_bin(json.path(), &server.uri(), &[UUID, "--save-only", "--json"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let event = envelope["events"] + .as_array() + .unwrap() + .iter() + .find(|event| event["uuid"] == UUID) + .unwrap(); + assert_eq!(event["details"]["requiresOptIn"], true); + assert_eq!(event["details"]["optInNote"], NOTE); +} + /// Human paid-only search: the subscription message, exit 0, no download. #[tokio::test] async fn human_paid_only_search_prints_subscription_message() { diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index 82dd0c7dd..d18abe2da 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -77,6 +77,8 @@ fn search_result(uuid: &str, purl: &str) -> PatchSearchResult { license: "MIT".into(), tier: "free".into(), vulnerabilities: HashMap::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index eb00e2a11..7380acb40 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -363,6 +363,8 @@ fn npm_record(uuid: &str, before: &str, after: &str, ghsa: &str) -> PatchRecord description: "x".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs index 8c0e6cf24..dfc021a47 100644 --- a/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs +++ b/crates/socket-patch-cli/tests/in_process_rollback_hosted.rs @@ -414,6 +414,8 @@ fn patch_record(uuid: &str, ghsa: &str) -> PatchRecord { description: "x".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/vex/vex_fixture.rs b/crates/socket-patch-cli/tests/vex/vex_fixture.rs index cbc99b943..08923bc24 100644 --- a/crates/socket-patch-cli/tests/vex/vex_fixture.rs +++ b/crates/socket-patch-cli/tests/vex/vex_fixture.rs @@ -75,6 +75,8 @@ pub(crate) fn make_record( description: format!("Patch {uuid}"), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-cli/tests/vex/vex_render.rs b/crates/socket-patch-cli/tests/vex/vex_render.rs index ea8fb7e53..d5c145d14 100644 --- a/crates/socket-patch-cli/tests/vex/vex_render.rs +++ b/crates/socket-patch-cli/tests/vex/vex_render.rs @@ -5,6 +5,7 @@ //! per message family. use crate::vex_fixture::*; +use socket_patch_core::manifest::schema::PatchManifest; #[test] fn dry_run_with_output_writes_nothing_and_says_so() { @@ -66,6 +67,35 @@ fn output_dash_means_stdout() { assert_eq!(lines[1], "Emitted 1 VEX statement"); } +#[test] +fn opt_in_note_is_appended_to_the_vex_impact_statement() { + const PURL: &str = "pkg:npm/a@1.0.0"; + const UUID: &str = "00000001-1111-4111-8111-111111111111"; + const NOTE: &str = "Set `safeMode` to `true`; defaults remain vulnerable."; + let tmp = tempfile::tempdir().unwrap(); + let mut record = make_record( + UUID, + "package/index.js", + &"a".repeat(64), + &"b".repeat(64), + "GHSA-test-0", + &[], + ); + record.requires_opt_in = Some(true); + record.opt_in_note = Some(NOTE.to_string()); + let mut manifest = PatchManifest::new(); + manifest.patches.insert(PURL.to_string(), record); + write_manifest(tmp.path(), &manifest); + + let o = vex(tmp.path(), &["--no-verify", "-O", "-"]); + assert_eq!(o.status.code(), Some(0), "{}", stderr(&o)); + let doc: Value = serde_json::from_slice(&o.stdout).expect("document on stdout"); + assert_eq!( + doc["statements"][0]["impact_statement"], + format!("Patched via Socket patch {UUID}; not verified on disk; opt-in required: {NOTE}") + ); +} + #[test] fn written_document_ends_with_newline_and_summary_is_singular() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/socket-patch-core/src/api/blob_fetcher.rs b/crates/socket-patch-core/src/api/blob_fetcher.rs index 6d7e796fc..6fff14aad 100644 --- a/crates/socket-patch-core/src/api/blob_fetcher.rs +++ b/crates/socket-patch-core/src/api/blob_fetcher.rs @@ -584,6 +584,8 @@ mod tests { description: "test".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index 2adf3bb3c..8cc0835d1 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -2880,6 +2880,8 @@ fn convert_search_result_to_batch_info(patch: PatchSearchResult) -> BatchPatchIn // `ranking` (the tiebreak after severity and advisory count) — and // it is the one path where we definitely have it. published_at: Some(patch.published_at), + requires_opt_in: patch.requires_opt_in, + opt_in_note: patch.opt_in_note, } } @@ -3428,6 +3430,8 @@ mod tests { license: "MIT".into(), tier: "free".into(), vulnerabilities: vulns, + requires_opt_in: Some(true), + opt_in_note: Some("Set `safeMode` to `true`.".into()), }; let info = convert_search_result_to_batch_info(patch); @@ -3436,6 +3440,11 @@ mod tests { assert_eq!(info.ghsa_ids, vec!["GHSA-1234-5678-9abc"]); assert_eq!(info.severity, Some("high".into())); assert_eq!(info.title, "Test vulnerability"); + assert_eq!(info.requires_opt_in, Some(true)); + assert_eq!( + info.opt_in_note.as_deref(), + Some("Set `safeMode` to `true`.") + ); } #[tokio::test] @@ -3785,6 +3794,8 @@ mod tests { license: "MIT".into(), tier: "free".into(), vulnerabilities: vulns, + requires_opt_in: None, + opt_in_note: None, } } @@ -4508,6 +4519,8 @@ mod tests { license: "MIT".into(), tier: "free".into(), vulnerabilities: HashMap::new(), + requires_opt_in: None, + opt_in_note: None, }) .collect(), can_access_paid_patches, diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index 1652d9d25..5c4cf956d 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -244,6 +244,8 @@ mod tests { license: "MIT".to_string(), tier: tier.to_string(), vulnerabilities: vulns(&refs), + requires_opt_in: None, + opt_in_note: None, } } @@ -276,6 +278,8 @@ mod tests { severity: severity.map(str::to_string), title: String::new(), published_at: published.map(str::to_string), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/api/types.rs b/crates/socket-patch-core/src/api/types.rs index c45cd5116..48779979e 100644 --- a/crates/socket-patch-core/src/api/types.rs +++ b/crates/socket-patch-core/src/api/types.rs @@ -46,6 +46,10 @@ pub struct PatchResponse { pub description: String, pub license: String, pub tier: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requires_opt_in: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opt_in_note: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -80,6 +84,10 @@ pub struct PatchSearchResult { pub license: String, pub tier: String, pub vulnerabilities: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requires_opt_in: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opt_in_note: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -109,6 +117,10 @@ pub struct BatchPatchInfo { /// fills it in from the per-package search results. #[serde(default, skip_serializing_if = "Option::is_none")] pub published_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requires_opt_in: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opt_in_note: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -237,10 +249,17 @@ mod tests { }, "description": "Fix path traversal", "license": "MIT", - "tier": "free" + "tier": "free", + "requiresOptIn": true, + "optInNote": "Set `safeMode` to `true`; defaults remain vulnerable." }"#; let pr: PatchResponse = serde_json::from_str(json).unwrap(); assert_eq!(pr.published_at, "2024-01-01T00:00:00Z"); + assert_eq!(pr.requires_opt_in, Some(true)); + assert_eq!( + pr.opt_in_note.as_deref(), + Some("Set `safeMode` to `true`; defaults remain vulnerable.") + ); let file = pr.files.get("package/index.js").expect("file present"); assert_eq!( @@ -314,6 +333,8 @@ mod tests { assert_eq!(patches[0].severity.as_deref(), Some("high")); assert!(patches[1].severity.is_none()); assert!(patches[1].cve_ids.is_empty()); + assert_eq!(patches[0].requires_opt_in, None); + assert_eq!(patches[0].opt_in_note, None); } /// `scan --json` serializes the batch packages verbatim, so their @@ -331,6 +352,8 @@ mod tests { severity: None, title: "Patch one".into(), published_at: None, + requires_opt_in: None, + opt_in_note: None, }], }; assert_eq!( @@ -440,6 +463,8 @@ mod tests { assert_eq!(sr.patches.len(), 1); assert!(!sr.can_access_paid_patches); assert_eq!(sr.patches[0].published_at, "2024-01-01T00:00:00Z"); + assert_eq!(sr.patches[0].requires_opt_in, None); + assert_eq!(sr.patches[0].opt_in_note, None); } #[test] diff --git a/crates/socket-patch-core/src/hosted/memory/discover.rs b/crates/socket-patch-core/src/hosted/memory/discover.rs index b1581b591..80d35d31a 100644 --- a/crates/socket-patch-core/src/hosted/memory/discover.rs +++ b/crates/socket-patch-core/src/hosted/memory/discover.rs @@ -530,6 +530,8 @@ mod tests { severity: None, title: String::new(), published_at: None, + requires_opt_in: None, + opt_in_note: None, }], }], can_access_paid_patches: false, diff --git a/crates/socket-patch-core/src/ledgers.rs b/crates/socket-patch-core/src/ledgers.rs index 534db70f1..4a7cf470c 100644 --- a/crates/socket-patch-core/src/ledgers.rs +++ b/crates/socket-patch-core/src/ledgers.rs @@ -423,6 +423,8 @@ pub fn uuid_only_record(uuid: &str) -> PatchRecord { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs index ecd36b415..b8e6f929b 100644 --- a/crates/socket-patch-core/src/manifest/cleanup_blobs.rs +++ b/crates/socket-patch-core/src/manifest/cleanup_blobs.rs @@ -324,6 +324,8 @@ mod tests { description: "Test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index d93eb73f3..a8db1a841 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -254,6 +254,8 @@ mod tests { description: "Test patch 1".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -276,6 +278,8 @@ mod tests { description: "Test patch 2".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); diff --git a/crates/socket-patch-core/src/manifest/records.rs b/crates/socket-patch-core/src/manifest/records.rs index 7032d435c..060e4ba12 100644 --- a/crates/socket-patch-core/src/manifest/records.rs +++ b/crates/socket-patch-core/src/manifest/records.rs @@ -44,6 +44,8 @@ pub fn build_patch_record( description: patch.description.clone(), license: patch.license.clone(), tier: patch.tier.clone(), + requires_opt_in: patch.requires_opt_in, + opt_in_note: patch.opt_in_note.clone(), } } diff --git a/crates/socket-patch-core/src/manifest/schema.rs b/crates/socket-patch-core/src/manifest/schema.rs index 9ec859ed4..2627bdd84 100644 --- a/crates/socket-patch-core/src/manifest/schema.rs +++ b/crates/socket-patch-core/src/manifest/schema.rs @@ -52,6 +52,10 @@ pub struct PatchRecord { pub description: String, pub license: String, pub tier: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requires_opt_in: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opt_in_note: Option, } /// Legacy `setup` configuration, written by the `setup` command that v5 @@ -173,8 +177,27 @@ mod tests { // Verify round-trip let serialized = serde_json::to_string_pretty(&manifest).unwrap(); - let reparsed: PatchManifest = serde_json::from_str(&serialized).unwrap(); + assert!(!serialized.contains("requiresOptIn")); + assert!(!serialized.contains("optInNote")); + let mut reparsed: PatchManifest = serde_json::from_str(&serialized).unwrap(); assert_eq!(manifest, reparsed); + let record = reparsed + .patches + .get_mut("pkg:npm/simplehttpserver@0.0.6") + .unwrap(); + assert_eq!(record.requires_opt_in, None); + assert_eq!(record.opt_in_note, None); + record.requires_opt_in = Some(true); + record.opt_in_note = Some("Set `safeMode` to `true`; defaults remain vulnerable.".into()); + let serialized = serde_json::to_string_pretty(&reparsed).unwrap(); + let opted_in: PatchManifest = serde_json::from_str(&serialized).unwrap(); + assert_eq!(reparsed, opted_in); + let record = &opted_in.patches["pkg:npm/simplehttpserver@0.0.6"]; + assert_eq!(record.requires_opt_in, Some(true)); + assert_eq!( + record.opt_in_note.as_deref(), + Some("Set `safeMode` to `true`; defaults remain vulnerable.") + ); } #[test] @@ -200,6 +223,8 @@ mod tests { description: "test".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; let json = serde_json::to_string(&record).unwrap(); assert!(json.contains("exportedAt")); @@ -386,6 +411,8 @@ mod tests { description: "d".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; // Insert in deliberately reverse-sorted order. @@ -457,6 +484,8 @@ mod tests { description: "d".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } }; diff --git a/crates/socket-patch-core/src/patch/redirect/golang_local.rs b/crates/socket-patch-core/src/patch/redirect/golang_local.rs index e22a5b117..a502cd724 100644 --- a/crates/socket-patch-core/src/patch/redirect/golang_local.rs +++ b/crates/socket-patch-core/src/patch/redirect/golang_local.rs @@ -1121,6 +1121,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); m @@ -2898,6 +2900,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); let desired: HashSet = [unsafe_purl.to_string()].into_iter().collect(); diff --git a/crates/socket-patch-core/src/patch/redirect/state.rs b/crates/socket-patch-core/src/patch/redirect/state.rs index 98d0b620e..d1c4a11e6 100644 --- a/crates/socket-patch-core/src/patch/redirect/state.rs +++ b/crates/socket-patch-core/src/patch/redirect/state.rs @@ -194,6 +194,8 @@ mod tests { description: "x".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/policy/filter.rs b/crates/socket-patch-core/src/policy/filter.rs index 05bc8dfe6..efb8c4e26 100644 --- a/crates/socket-patch-core/src/policy/filter.rs +++ b/crates/socket-patch-core/src/policy/filter.rs @@ -717,6 +717,8 @@ mod tests { ) }) .collect(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/policy/tests.rs b/crates/socket-patch-core/src/policy/tests.rs index c42ca7f31..10ca3cd81 100644 --- a/crates/socket-patch-core/src/policy/tests.rs +++ b/crates/socket-patch-core/src/policy/tests.rs @@ -179,6 +179,8 @@ fn floor_filter_uses_max_advisory_severity() { ) }) .collect::>(), + requires_opt_in: None, + opt_in_note: None, }; let policy = load(&[("socket.yml", "version: 2\npatches:\n minSeverity: high\n")]); let (kept, dropped) = policy.floor_filter(vec![ diff --git a/crates/socket-patch-core/src/rollout/stage.rs b/crates/socket-patch-core/src/rollout/stage.rs index 8d09aadba..674f75146 100644 --- a/crates/socket-patch-core/src/rollout/stage.rs +++ b/crates/socket-patch-core/src/rollout/stage.rs @@ -575,6 +575,8 @@ mod tests { ) }) .collect(), + requires_opt_in: None, + opt_in_note: None, } } @@ -591,6 +593,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); } diff --git a/crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs b/crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs index f9d67998d..b8cd7ed09 100644 --- a/crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs +++ b/crates/socket-patch-core/src/utils/purl_key_nuget_vendor_tests.rs @@ -107,6 +107,8 @@ async fn fixture(root: &Path) -> (std::path::PathBuf, std::path::PathBuf, PatchR description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; (installed, blobs, record) } diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 0a039eb41..87c42c539 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -2030,6 +2030,8 @@ mod tests { description: "test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; Fixture { tmp, diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index 03a884b6d..b8f70df6c 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -2233,6 +2233,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index e7324ed2b..c36212a6b 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -1095,6 +1095,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; (dir, blobs, installed, record) } diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 5b8937fc4..b5abb85d2 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -3146,6 +3146,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; (dir, root, installed, blobs, record) } @@ -4803,6 +4805,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; (dir, root, installed, blobs, record) } @@ -5444,6 +5448,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; (installed, record) } diff --git a/crates/socket-patch-core/src/vendor/go_native_vendor.rs b/crates/socket-patch-core/src/vendor/go_native_vendor.rs index 28fb23a77..1860a86c9 100644 --- a/crates/socket-patch-core/src/vendor/go_native_vendor.rs +++ b/crates/socket-patch-core/src/vendor/go_native_vendor.rs @@ -965,6 +965,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index 6b3ab488b..cb8e8f65b 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -796,6 +796,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index e01925a41..48f173db9 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -2036,6 +2036,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; (dir, blobs, installed, record) } @@ -4374,6 +4376,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index f8f15f209..300cdc0c0 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -982,6 +982,8 @@ pub async fn lock_text_refusals( description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; let mut refusals = HashMap::new(); let npm: Vec<(&str, crate::manifest::schema::PatchRecord)> = candidates @@ -1107,6 +1109,8 @@ mod harvest_tests { description: String::new(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ) } diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index 0e477052c..a5d53d7e4 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -992,6 +992,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/npm_flavor.rs b/crates/socket-patch-core/src/vendor/npm_flavor.rs index 22fcefea1..9702c4915 100644 --- a/crates/socket-patch-core/src/vendor/npm_flavor.rs +++ b/crates/socket-patch-core/src/vendor/npm_flavor.rs @@ -1014,6 +1014,8 @@ mod lock_text_refusal_tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -2128,6 +2130,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; (tmp, record) } @@ -3074,6 +3078,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index cb5df69ba..d1309d3c7 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -2154,6 +2154,8 @@ mod tests { description: "test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; Fixture { diff --git a/crates/socket-patch-core/src/vendor/nuget_feed.rs b/crates/socket-patch-core/src/vendor/nuget_feed.rs index 696c6476f..4de0fde82 100644 --- a/crates/socket-patch-core/src/vendor/nuget_feed.rs +++ b/crates/socket-patch-core/src/vendor/nuget_feed.rs @@ -2589,6 +2589,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; (dir, blobs, installed, record) } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock.rs b/crates/socket-patch-core/src/vendor/pnpm_lock.rs index 54bf681ce..c6e4ae3ff 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock.rs @@ -4844,6 +4844,8 @@ snapshots: description: "test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; Fixture { tmp, record } } diff --git a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs index 1762099a1..70d59b5e7 100644 --- a/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs +++ b/crates/socket-patch-core/src/vendor/pnpm_lock_legacy.rs @@ -1481,6 +1481,8 @@ packages: description: "test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; Fixture { _tmp: tmp, diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 4010f649f..fb8ac835d 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -1975,6 +1975,8 @@ pub async fn preview_refusals( description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; if let Err(VendorOutcome::Refused { code, detail }) = pypi_prelude( purl, @@ -4775,6 +4777,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; E2eFixture { _tmp: tmp, diff --git a/crates/socket-patch-core/src/vendor/reuse.rs b/crates/socket-patch-core/src/vendor/reuse.rs index 776a412af..dd1b27f5b 100644 --- a/crates/socket-patch-core/src/vendor/reuse.rs +++ b/crates/socket-patch-core/src/vendor/reuse.rs @@ -376,6 +376,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index fca75828d..9820a3b71 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -407,6 +407,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/state.rs b/crates/socket-patch-core/src/vendor/state.rs index 5f6f6f67a..d60174c4b 100644 --- a/crates/socket-patch-core/src/vendor/state.rs +++ b/crates/socket-patch-core/src/vendor/state.rs @@ -1867,6 +1867,8 @@ mod tests { description: "fixes prototype pollution".into(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, }); let mut state = VendorState::new(); state diff --git a/crates/socket-patch-core/src/vendor/verify.rs b/crates/socket-patch-core/src/vendor/verify.rs index 11761a726..bd714cd39 100644 --- a/crates/socket-patch-core/src/vendor/verify.rs +++ b/crates/socket-patch-core/src/vendor/verify.rs @@ -942,6 +942,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/vlt_lock.rs b/crates/socket-patch-core/src/vendor/vlt_lock.rs index 65c1265d2..1af9c80f6 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock.rs @@ -2465,6 +2465,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs index ae02662fe..3a775541b 100644 --- a/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_berry_lock.rs @@ -2118,6 +2118,8 @@ __metadata: description: "test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; Fixture { diff --git a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs index a842fb968..eb0f2121b 100644 --- a/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs +++ b/crates/socket-patch-core/src/vendor/yarn_classic_lock.rs @@ -1310,6 +1310,8 @@ left-pad@^1.3.0, left-pad@~1.3.0: description: "test patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }; Fixture { diff --git a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs index dd11192c7..355b235e0 100644 --- a/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs +++ b/crates/socket-patch-core/src/vendor/yarn_layering_tests.rs @@ -162,6 +162,8 @@ fn record_for(after_hash: &str) -> PatchRecord { description: "incident regression patch".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vex/build.rs b/crates/socket-patch-core/src/vex/build.rs index 8446de398..dc6c46152 100644 --- a/crates/socket-patch-core/src/vex/build.rs +++ b/crates/socket-patch-core/src/vex/build.rs @@ -117,6 +117,12 @@ pub fn build_document_marking_unverified( if unverified_set.contains(purl.as_str()) { part.push_str(NOT_VERIFIED_SUFFIX); } + if record.requires_opt_in == Some(true) { + if let Some(note) = &record.opt_in_note { + part.push_str("; opt-in required: "); + part.push_str(note); + } + } entry.impact_parts.insert(part); } } @@ -231,6 +237,8 @@ mod tests { description: String::new(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vex/conformance_tests.rs b/crates/socket-patch-core/src/vex/conformance_tests.rs index 7e33eef33..34d7c554f 100644 --- a/crates/socket-patch-core/src/vex/conformance_tests.rs +++ b/crates/socket-patch-core/src/vex/conformance_tests.rs @@ -44,6 +44,8 @@ fn record(uuid: &str, vulns: &[(&str, &[&str])]) -> PatchRecord { description: String::new(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } diff --git a/crates/socket-patch-core/src/vex/verify.rs b/crates/socket-patch-core/src/vex/verify.rs index 197e7be54..9d9882700 100644 --- a/crates/socket-patch-core/src/vex/verify.rs +++ b/crates/socket-patch-core/src/vex/verify.rs @@ -820,6 +820,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -1051,6 +1053,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1119,6 +1123,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1218,6 +1224,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1269,6 +1277,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1307,6 +1317,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1351,6 +1363,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1406,6 +1420,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1501,6 +1517,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1623,6 +1641,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }, ); @@ -1878,6 +1898,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; let mut manifest = PatchManifest::new(); manifest.patches.insert(purl.to_string(), rec); @@ -1948,6 +1970,8 @@ mod tests { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, }; let mut manifest = PatchManifest::new(); manifest.patches.insert(purl.to_string(), rec); diff --git a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs index 83fb0ce38..c27fb4ca3 100644 --- a/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs +++ b/crates/socket-patch-core/tests/blob_fetcher_edges_e2e.rs @@ -54,6 +54,8 @@ fn manifest_with_after_hashes(after: &[&str]) -> PatchManifest { description: "test".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); PatchManifest { diff --git a/crates/socket-patch-core/tests/vlt_locks.rs b/crates/socket-patch-core/tests/vlt_locks.rs index b0ba99ddb..29f33fccd 100644 --- a/crates/socket-patch-core/tests/vlt_locks.rs +++ b/crates/socket-patch-core/tests/vlt_locks.rs @@ -494,6 +494,8 @@ fn patch_record(uuid: &str) -> PatchRecord { description: String::new(), license: String::new(), tier: String::new(), + requires_opt_in: None, + opt_in_note: None, } } From 2f5652b6869add081cc97d1318ba08d6fa7fe572 Mon Sep 17 00:00:00 2001 From: wenxin-jiang Date: Sat, 14 Nov 2026 18:12:22 -0500 Subject: [PATCH 2/7] Fix opt-in metadata refresh and scan output Refresh same-UUID activation metadata, including clearing it, and record the new fields as known manifest keys. Hosted, repeated-agent, and dry-run scans render sanitized notes once per patch UUID. Link nested apply deduplication to the UUIDs its caller already showed. Enrich scan JSON events from both batch and detailed API records. Assisted-by: Claude Code:kimi-k3p1-pro --- .../src/commands/agent_download.rs | 22 +++++- crates/socket-patch-cli/src/commands/apply.rs | 51 ++++++------- crates/socket-patch-cli/src/commands/get.rs | 24 +++++- .../src/commands/scan/hosted.rs | 14 ++++ .../socket-patch-cli/src/commands/scan/mod.rs | 43 ++++++++++- .../src/commands/scan/render.rs | 23 ------ crates/socket-patch-cli/src/ui/mod.rs | 5 +- crates/socket-patch-cli/src/ui/text.rs | 45 +++++++++++ .../socket-patch-cli/tests/get/get_fixture.rs | 1 + .../socket-patch-cli/tests/get/get_render.rs | 64 ++++++++++++++++ .../tests/in_process_get_update_count.rs | 1 + .../tests/scan/scan_fixture.rs | 73 +++++++++++++----- .../tests/scan/scan_render.rs | 75 +++++++++++++++++++ .../src/manifest/operations.rs | 5 ++ .../socket-patch-core/src/manifest/records.rs | 9 +++ 15 files changed, 380 insertions(+), 75 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index baf934074..6a13dc3fa 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -11,7 +11,9 @@ use socket_patch_core::api::ranking::severity_order; use socket_patch_core::api::types::{PatchResponse, PatchSearchResult, VulnerabilityResponse}; use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem}; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; -use socket_patch_core::manifest::records::{build_patch_record, files_for_manifest}; +use socket_patch_core::manifest::records::{ + build_patch_record, files_for_manifest, refresh_opt_in_metadata, +}; use socket_patch_core::manifest::schema::{PatchFileInfo, PatchManifest, PatchRecord}; use socket_patch_core::patch::apply::{is_valid_blob_hash, select_installed_variants_any}; use socket_patch_core::patch::apply_lock::{LockError, LockGuard}; @@ -388,6 +390,7 @@ pub struct DownloadParams { /// `--patch-server-url`: the extra origin whose URLs count as hosted /// when lockfile discovery reads the project's hosted pins. pub patch_server_url: Option, + pub opt_in_notes_shown: Vec, } impl DownloadParams { @@ -701,6 +704,7 @@ pub(crate) struct FetchBatch { failed: usize, /// Fetched, recordable patches in selection order. fetched: Vec, + metadata_refreshes: Vec, /// Ledger store only: `(purl, record)` reused from a detached entry /// already at the selected uuid (no fetch). reused: Vec<(String, PatchRecord)>, @@ -912,6 +916,7 @@ pub(crate) async fn fetch_selected_patches( already_recorded: 0, failed: 0, fetched: Vec::new(), + metadata_refreshes: Vec::new(), reused: Vec::new(), events: Vec::new(), warnings, @@ -1039,13 +1044,15 @@ pub(crate) async fn fetch_selected_patches( }, }; if action == PatchAction::Skipped { + batch.metadata_refreshes.push(patch.clone()); if !quiet { eprintln!("{}", format_record_skip(&patch.purl, "already in manifest")); } batch.events.push( PatchEvent::new(EventAction::Skipped, patch.purl.as_str()) .with_uuid(patch.uuid.as_str()) - .with_reason(ALREADY_IN_MANIFEST, "already in manifest"), + .with_reason(ALREADY_IN_MANIFEST, "already in manifest") + .with_details(patch_event_metadata(&patch)), ); batch.skipped += 1; batch.already_recorded += 1; @@ -1405,6 +1412,7 @@ pub(crate) async fn run_nested_apply( common: GlobalArgs, json: bool, report_user_patch_skips: bool, + shown_opt_in_uuids: Vec, recorded: &[(String, String)], client: &ApiClient, lock: LockGuard, @@ -1419,6 +1427,7 @@ pub(crate) async fn run_nested_apply( nested: Some(super::apply::NestedApply { caller_json: json, report_user_patch_skips, + shown_opt_in_uuids, }), }; // The nested apply runs on the host's client: seeded, never rebuilt. @@ -1713,6 +1722,12 @@ pub async fn download_and_apply_patches_into( // matching the per-patch event (`downloaded` / `updated`) and the // single-uuid flow's summary in `save_and_apply_patch`; their sum gates // the apply step. + let mut metadata_changed = false; + for patch in &batch.metadata_refreshes { + if let Some(record) = manifest.patches.get_mut(&patch.purl) { + metadata_changed |= refresh_opt_in_metadata(record, patch); + } + } let downloaded = batch.fetched.len(); let mut updated = 0usize; let mut new_blobs: Vec = Vec::new(); @@ -1748,7 +1763,7 @@ pub async fn download_and_apply_patches_into( let added = downloaded - updated; // Write only when a record changed: an all-skipped or all-failed run // leaves the manifest bytes (and a fresh project's tree) untouched. - if downloaded > 0 { + if downloaded > 0 || metadata_changed { if let Err(e) = write_manifest(&manifest_path, &manifest).await { // The blobs this run just wrote have no record pointing at them: // unwind exactly those (a pre-existing record's blobs stay). @@ -1819,6 +1834,7 @@ pub async fn download_and_apply_patches_into( nested_apply_args_from_params(params, run, &manifest_path), params.json, !params.user_patch_skips_reported, + params.opt_in_notes_shown.clone(), &recorded, run.api_client, lock, diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index b4d4d8166..ff785998b 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -176,7 +176,7 @@ pub struct ApplyArgs { } /// What a nested apply knows about the command that runs it. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct NestedApply { /// The caller runs under `--json`. The nested run itself is never JSON /// (one envelope per command), but the caller's stdout is, so the @@ -186,6 +186,7 @@ pub struct NestedApply { /// (`get`) checked only its own selection. `scan` already reported /// every protected installed package, so it passes `false`. pub report_user_patch_skips: bool, + pub shown_opt_in_uuids: Vec, } impl ApplyArgs { @@ -193,7 +194,7 @@ impl ApplyArgs { /// `--json` (the envelope is the channel), always otherwise — errors /// are exempt from `--silent`. fn prints_errors(&self) -> bool { - !self.common.json && !self.nested.is_some_and(|n| n.caller_json) + !self.common.json && !self.nested.as_ref().is_some_and(|n| n.caller_json) } } @@ -1848,14 +1849,14 @@ pub(crate) async fn run_locked( for line in block { println!("{line}"); } - if args.nested.is_none() { - let notes = format_opt_in_notes(&opt_ins, &results, &unmatched); - if !notes.is_empty() { - println!("\nOpt-in required:"); - for note in notes { - println!("{note}"); - } - } + let shown_opt_in_uuids = args + .nested + .as_ref() + .map_or(&[][..], |nested| nested.shown_opt_in_uuids.as_slice()); + if let Some(notes) = + format_opt_in_notes(&opt_ins, &results, &unmatched, shown_opt_in_uuids) + { + println!("\n{notes}"); } if args.common.verbose && !results.is_empty() { print_verbose_verification(&results); @@ -2401,30 +2402,26 @@ fn format_opt_in_notes( opt_ins: &OptInPatches, results: &[ApplyResult], unmatched: &[String], -) -> Vec { + shown_opt_in_uuids: &[String], +) -> Option { let shown: HashSet<&str> = results .iter() .map(|result| result.package_key.as_str()) .chain(unmatched.iter().map(String::as_str)) .collect(); - let mut notes: Vec<(&str, &str)> = opt_ins - .iter() - .filter_map(|(purl, _, requires_opt_in, opt_in_note)| { - if requires_opt_in != &Some(true) || !shown.contains(purl.as_str()) { + crate::ui::opt_in_note_block(opt_ins.iter().filter_map( + |(purl, uuid, requires_opt_in, opt_in_note)| { + if !shown.contains(purl.as_str()) || shown_opt_in_uuids.contains(uuid) { return None; } - let note = opt_in_note.as_deref()?; - (!note.trim().is_empty()).then_some((purl.as_str(), note)) - }) - .collect(); - notes.sort_unstable(); - notes - .into_iter() - .map(|(purl, note)| { - let note = crate::ui::terminal_text(note).replace('\n', "\n "); - format!(" {}: {note}", normalize_purl(purl)) - }) - .collect() + Some(crate::ui::OptInEntry { + purl, + uuid, + requires_opt_in: *requires_opt_in, + opt_in_note: opt_in_note.as_deref(), + }) + }, + )) } /// One `Patched packages:` line. `copy` names the physical copy when a diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 30e0d79b2..e5af3ccd2 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -11,7 +11,9 @@ use socket_patch_core::api::types::{ use socket_patch_core::crawlers::fuzzy_match::fuzzy_match_packages; use socket_patch_core::formats::pnpm::PnpmLock; use socket_patch_core::manifest::operations::{read_manifest, write_manifest}; -use socket_patch_core::manifest::records::{build_patch_record, files_for_manifest}; +use socket_patch_core::manifest::records::{ + build_patch_record, files_for_manifest, refresh_opt_in_metadata, +}; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{track_patch_fetch_failed, track_patch_fetched, TelemetryAuth}; use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; @@ -1810,7 +1812,8 @@ pub async fn run(args: GetArgs) -> i32 { } // Download and apply (agent mode), with the run's client and flags. - let params = get_download_params(&args, args.save_only, /*persist_blobs=*/ true); + let mut params = get_download_params(&args, args.save_only, /*persist_blobs=*/ true); + params.opt_in_notes_shown = selected.iter().map(|patch| patch.uuid.clone()).collect(); let run = DownloadRun { api_client: &api_client, lock_timeout: args.common.lock_timeout, @@ -1994,6 +1997,19 @@ async fn save_patch_record( // not `added` — consumers diff manifest replacements on that action. let action = decide_patch_action(&manifest, &patch.purl, &patch.uuid); if action == PatchAction::Skipped { + let refreshed = manifest + .patches + .get_mut(&patch.purl) + .is_some_and(|record| refresh_opt_in_metadata(record, patch)); + if refreshed { + if let Err(e) = write_manifest(manifest_path, &manifest).await { + return Err(report_error( + &args.common, + "manifest_write_failed", + format!("Failed to write manifest: {e}"), + )); + } + } return Ok(action); } @@ -2146,6 +2162,7 @@ async fn save_and_apply_patch( nested_apply_args(&args.common, &manifest_path, quiet), args.common.json, true, + vec![patch.uuid.clone()], &recorded, client, lock, @@ -2265,6 +2282,7 @@ fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> ecosystems: args.common.ecosystems.clone(), persist_blobs, patch_server_url: args.common.patch_server_url.clone(), + opt_in_notes_shown: Vec::new(), } } @@ -4069,6 +4087,7 @@ mod tests { ecosystems: None, persist_blobs: false, patch_server_url: None, + opt_in_notes_shown: Vec::new(), } } @@ -4769,6 +4788,7 @@ mod tests { // The vendor-detached posture this fn exists for. persist_blobs: false, patch_server_url: None, + opt_in_notes_shown: Vec::new(), } } diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 6f3880cf9..ad9d086b5 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -2147,6 +2147,20 @@ async fn run_redirect_selected_untracked( "{}", format_hosted_summary(confirmed.len(), already, human_files.len(), common.dry_run) ); + if let Some(notes) = rollout.as_ref().and_then(|gate| { + crate::ui::opt_in_note_block( + gate.rows + .iter() + .filter(|row| { + selected.iter().any(|(purl, uuid)| { + purl == &row.writer.purl && uuid == &row.writer.uuid + }) + }) + .map(|row| crate::ui::OptInEntry::from_search(&row.writer)), + ) + }) { + println!("\n{notes}"); + } // Under it, per package on a hosted patch: the advisories that // patch fixes and the ones it leaves unpatched. if let Some(gate) = &rollout { diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 6eddb7aec..682c825d6 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -556,7 +556,7 @@ async fn discover_selected( show_progress: bool, warn: bool, detail_error_line: bool, - json_warnings: Option<&mut Envelope>, + mut json_warnings: Option<&mut Envelope>, ) -> Result { let (all_search_results, failures) = fetch_patch_details(api_client, packages, show_progress, warn).await; @@ -577,6 +577,17 @@ async fn discover_selected( // Some queries failed, some succeeded: a `--json` run has no stderr // warning (`warn` is human-only), so each failed package becomes a // run-level `warnings[]` entry — never a silent drop from the envelope. + if let Some(env) = json_warnings.as_deref_mut() { + for patch in &all_search_results { + env.register_opt_in( + &patch.purl, + &patch.uuid, + patch.requires_opt_in, + patch.opt_in_note.as_deref(), + ); + } + env.add_registered_opt_in_to_events(); + } let offers = select_accessible(all_search_results, can_access_paid_patches, policy); if let Some(env) = json_warnings { for (purl, e) in &failures { @@ -979,6 +990,7 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - ecosystems: args.common.ecosystems.clone(), persist_blobs: args.mode != Some(ScanMode::Vendored), patch_server_url: args.common.patch_server_url.clone(), + opt_in_notes_shown: Vec::new(), } } @@ -2950,6 +2962,16 @@ async fn scan_project( if args.common.json { let mut env = scan_envelope(&args.common); + for package in &all_packages_with_patches { + for patch in &package.patches { + env.register_opt_in( + &patch.purl, + &patch.uuid, + patch.requires_opt_in, + patch.opt_in_note.as_deref(), + ); + } + } env.set_extra("scannedPackages", serde_json::json!(package_count)); env.set_extra( "lockfileOnlyPackages", @@ -3593,6 +3615,14 @@ async fn scan_project( if selected.is_empty() && (!reapply || already_recorded.is_empty()) { if !silent { + if let Some(notes) = ui::opt_in_note_block( + selected + .iter() + .chain(&already_recorded) + .map(ui::OptInEntry::from_search), + ) { + println!("\n{notes}"); + } open_paragraph(&mut skip_paragraph); if !stage.deferred_keys().is_empty() { if args.common.dry_run { @@ -3659,6 +3689,17 @@ async fn scan_project( } } + if !silent && (vendor || report_only || args.common.dry_run) { + if let Some(notes) = ui::opt_in_note_block( + selected + .iter() + .chain(&already_recorded) + .map(ui::OptInEntry::from_search), + ) { + println!("\n{notes}"); + } + } + // A report-only run applies nothing, with or without `--dry-run`: both // end with how to apply what was found, and the `--prune` GC (a // read-only preview on a dry run). The preview never says it would diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index e14fbf582..4f550ee65 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -684,14 +684,6 @@ pub(super) fn patch_block(b: &PatchBlock) -> Vec { if !desc.is_empty() { lines.push(format!(" {desc}")); } - if p.requires_opt_in == Some(true) { - if let Some(note) = &p.opt_in_note { - let note = ui::terminal_text(note).replace('\n', "\n "); - if !note.trim().is_empty() { - lines.push(format!(" Opt-in required: {note}")); - } - } - } lines.push(String::new()); lines } @@ -1288,21 +1280,6 @@ mod tests { ); } - #[test] - fn patch_block_renders_sanitized_opt_in_note() { - let mut p = patch(&[], ""); - p.requires_opt_in = Some(true); - p.opt_in_note = Some( - "Set `safeMode` to `true`.\x1b[2J\x1b]0;bad\x07\r\nDefaults remain vulnerable." - .to_string(), - ); - let out = patch_block(&block(&p, false)).join("\n"); - assert!(out.contains( - " Opt-in required: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." - )); - assert!(!out.contains('\x1b') && !out.contains('\x07') && !out.contains('\r')); - } - #[test] fn patch_block_is_deterministic_across_hash_orders() { // Build the same map many times: HashMap iteration order varies diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index 8d604dd24..e326d939c 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -24,8 +24,9 @@ pub(crate) use prompt::confirm; pub use prompt::{select_one, SelectError}; pub(crate) use status::StatusLine; pub(crate) use text::{ - agent_next_steps, manifest_error_message, next_steps, next_steps_installed, plural, - sentence_case, shell_word, short_uuid, sweep_failure, terminal_text, truncate, + agent_next_steps, manifest_error_message, next_steps, next_steps_installed, opt_in_note_block, + plural, sentence_case, shell_word, short_uuid, sweep_failure, terminal_text, truncate, + OptInEntry, }; /// [`text::no_patches_message`] for this run: the manifest path is named diff --git a/crates/socket-patch-cli/src/ui/text.rs b/crates/socket-patch-cli/src/ui/text.rs index 95734ed03..f057f90dc 100644 --- a/crates/socket-patch-cli/src/ui/text.rs +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -255,6 +255,51 @@ pub(crate) fn terminal_text(s: &str) -> String { .collect() } +#[derive(Clone, Copy)] +pub(crate) struct OptInEntry<'a> { + pub purl: &'a str, + pub uuid: &'a str, + pub requires_opt_in: Option, + pub opt_in_note: Option<&'a str>, +} + +impl<'a> OptInEntry<'a> { + pub fn from_search(patch: &'a socket_patch_core::api::types::PatchSearchResult) -> Self { + Self { + purl: &patch.purl, + uuid: &patch.uuid, + requires_opt_in: patch.requires_opt_in, + opt_in_note: patch.opt_in_note.as_deref(), + } + } +} + +pub(crate) fn opt_in_note_block<'a>( + entries: impl IntoIterator>, +) -> Option { + let mut entries: Vec> = entries.into_iter().collect(); + entries.sort_by(|a, b| (a.uuid, a.purl).cmp(&(b.uuid, b.purl))); + let mut seen = std::collections::HashSet::new(); + let mut block = None::; + for entry in entries { + if entry.requires_opt_in != Some(true) { + continue; + } + let Some(note) = entry.opt_in_note else { + continue; + }; + let note = terminal_text(note); + if note.trim().is_empty() || !seen.insert(entry.uuid) { + continue; + } + let note = note.replace('\n', "\n "); + let purl = socket_patch_core::utils::purl::normalize_purl(entry.purl); + let block = block.get_or_insert_with(|| "Opt-in required:".to_string()); + block.push_str(&format!("\n {purl}: {note}")); + } + block +} + /// `word` as one argument a user can paste into their shell: bare when it /// holds only characters no shell treats specially, otherwise quoted (POSIX /// single quotes; double quotes on Windows, which cmd and PowerShell both diff --git a/crates/socket-patch-cli/tests/get/get_fixture.rs b/crates/socket-patch-cli/tests/get/get_fixture.rs index 20078255f..aa82737fc 100644 --- a/crates/socket-patch-cli/tests/get/get_fixture.rs +++ b/crates/socket-patch-cli/tests/get/get_fixture.rs @@ -300,6 +300,7 @@ pub fn engine_params(root: &Path) -> DownloadParams { ecosystems: None, persist_blobs: true, patch_server_url: None, + opt_in_notes_shown: Vec::new(), all_releases: true, } } diff --git a/crates/socket-patch-cli/tests/get/get_render.rs b/crates/socket-patch-cli/tests/get/get_render.rs index 712902c97..27aa01b63 100644 --- a/crates/socket-patch-cli/tests/get/get_render.rs +++ b/crates/socket-patch-cli/tests/get/get_render.rs @@ -439,6 +439,70 @@ async fn opt_in_note_renders_and_persists_in_human_and_json_get() { assert_eq!(event["details"]["optInNote"], NOTE); } +#[tokio::test] +async fn unchanged_uuid_refreshes_and_clears_opt_in_metadata_on_both_save_paths() { + async fn mount_api(server: &MockServer, cve: &str, note: Option<&str>) { + let mut patch = serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Opt-in metadata refresh", + "license": "MIT", + "tier": "free", + "vulnerabilities": {}, + }); + let mut view = view_json(UUID, PURL, good_files()); + if let Some(note) = note { + patch["requiresOptIn"] = serde_json::json!(true); + patch["optInNote"] = serde_json::json!(note); + view["requiresOptIn"] = serde_json::json!(true); + view["optInNote"] = serde_json::json!(note); + } + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-cve/{cve}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [patch], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)) + .mount(server) + .await; + } + + let cve = "CVE-2024-4244"; + for identifier in [UUID, cve] { + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + seed_manifest_with(tmp.path(), PURL, UUID); + mount_api(&server, cve, Some("Set `safeMode` to `true`.")).await; + let (code, stdout, stderr) = + run_get_bin(tmp.path(), &server.uri(), &[identifier, "--save-only"]); + assert_eq!(code, 0, "{identifier}: stdout={stdout}\nstderr={stderr}"); + let manifest = manifest_json(tmp.path()); + let record = &manifest["patches"][PURL]; + assert_eq!(record["requiresOptIn"], true, "{identifier}: {record}"); + assert_eq!(record["optInNote"], "Set `safeMode` to `true`."); + assert_eq!(record["files"], serde_json::json!({})); + + server.reset().await; + mount_api(&server, cve, None).await; + let (code, stdout, stderr) = + run_get_bin(tmp.path(), &server.uri(), &[identifier, "--save-only"]); + assert_eq!(code, 0, "{identifier}: stdout={stdout}\nstderr={stderr}"); + let record = &manifest_json(tmp.path())["patches"][PURL]; + assert!( + record.get("requiresOptIn").is_none(), + "{identifier}: {record}" + ); + assert!(record.get("optInNote").is_none(), "{identifier}: {record}"); + assert_eq!(record["files"], serde_json::json!({})); + } +} + /// Human paid-only search: the subscription message, exit 0, no download. #[tokio::test] async fn human_paid_only_search_prints_subscription_message() { diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index d18abe2da..d2a89e4e3 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -98,6 +98,7 @@ fn params(root: &Path) -> DownloadParams { ecosystems: None, persist_blobs: true, patch_server_url: None, + opt_in_notes_shown: Vec::new(), // Skip release-narrowing; npm has no variants anyway. all_releases: true, } diff --git a/crates/socket-patch-cli/tests/scan/scan_fixture.rs b/crates/socket-patch-cli/tests/scan/scan_fixture.rs index d8548ea16..a77705387 100644 --- a/crates/socket-patch-cli/tests/scan/scan_fixture.rs +++ b/crates/socket-patch-cli/tests/scan/scan_fixture.rs @@ -248,25 +248,40 @@ pub async fn mount_by_package_500(mock: &MockServer, purl: &str) { /// The patch view for `uuid` with an inline blob turning `before` into /// `after\n` in `package/index.js`, so the apply step needs no blob endpoint. pub async fn mount_view(mock: &MockServer, purl: &str, uuid: &str, before: &[u8]) { + mount_view_with_opt_in(mock, purl, uuid, before, None).await; +} + +pub async fn mount_view_with_opt_in( + mock: &MockServer, + purl: &str, + uuid: &str, + before: &[u8], + opt_in_note: Option<&str>, +) { + let mut response = serde_json::json!({ + "uuid": uuid, + "purl": purl, + "publishedAt": "2024-01-01T00:00:00Z", + "files": { + "package/index.js": { + "beforeHash": git_sha256(before), + "afterHash": git_sha256(b"after\n"), + // base64 of "after\n" + "blobContent": "YWZ0ZXIK", + } + }, + "vulnerabilities": {}, + "description": "Covgap test patch", + "license": "MIT", + "tier": "free", + }); + if let Some(note) = opt_in_note { + response["requiresOptIn"] = serde_json::json!(true); + response["optInNote"] = serde_json::json!(note); + } Mock::given(method("GET")) .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/view/{uuid}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "uuid": uuid, - "purl": purl, - "publishedAt": "2024-01-01T00:00:00Z", - "files": { - "package/index.js": { - "beforeHash": git_sha256(before), - "afterHash": git_sha256(b"after\n"), - // base64 of "after\n" - "blobContent": "YWZ0ZXIK", - } - }, - "vulnerabilities": {}, - "description": "Covgap test patch", - "license": "MIT", - "tier": "free", - }))) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) .mount(mock) .await; } @@ -287,6 +302,30 @@ pub async fn mount_one_patch_api(mock: &MockServer, purl: &str, before: &[u8]) { mount_patch_api(mock, &[(purl, UUID)], before).await; } +pub async fn mount_opt_in_patch_api(mock: &MockServer, before: &[u8], note: &str) { + mount_batch_free(mock, &[(MINIMIST, UUID)]).await; + mount_by_package_body( + mock, + MINIMIST, + serde_json::json!({ + "patches": [{ + "uuid": UUID, + "purl": MINIMIST, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Covgap test patch", + "license": "MIT", + "tier": "free", + "vulnerabilities": {}, + "requiresOptIn": true, + "optInNote": note, + }], + "canAccessPaidPatches": false, + }), + ) + .await; + mount_view_with_opt_in(mock, MINIMIST, UUID, before, Some(note)).await; +} + /// Seed `.socket/manifest.json` with one entry per `(purl, uuid)` pair /// (camelCase wire shape, mirroring `scan_invariants.rs`). pub fn seed_manifest(root: &Path, entries: &[(&str, &str)]) { diff --git a/crates/socket-patch-cli/tests/scan/scan_render.rs b/crates/socket-patch-cli/tests/scan/scan_render.rs index e3e455cca..87b2486e7 100644 --- a/crates/socket-patch-cli/tests/scan/scan_render.rs +++ b/crates/socket-patch-cli/tests/scan/scan_render.rs @@ -17,6 +17,19 @@ use wiremock::MockServer; use crate::scan_fixture::*; +const OPT_IN_NOTE: &str = "Set `safeMode` to `true`; defaults remain vulnerable."; + +fn assert_opt_in_event(envelope: &serde_json::Value) { + let event = envelope["events"] + .as_array() + .unwrap() + .iter() + .find(|event| event["uuid"] == UUID) + .unwrap_or_else(|| panic!("no event for {UUID}: {envelope}")); + assert_eq!(event["details"]["requiresOptIn"], true, "{event}"); + assert_eq!(event["details"]["optInNote"], OPT_IN_NOTE, "{event}"); +} + // --------------------------------------------------------------------------- // Terminals reachable without a network // --------------------------------------------------------------------------- @@ -359,6 +372,68 @@ async fn scan_human_preview_renders_vulnerability_details() { } } +#[tokio::test] +async fn scan_hosted_dry_run_prints_opt_in_note() { + let mock = MockServer::start().await; + mount_opt_in_patch_api(&mock, b"x\n", OPT_IN_NOTE).await; + mount_forbidden_reference(&mock, MINIMIST).await; + let tmp = minimist_project(b"x\n"); + seed_hosted_pin(tmp.path(), MINIMIST, OLD_UUID); + + let (code, stdout, stderr) = run_scan_api(tmp.path(), &mock.uri(), &["--dry-run"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert!( + stdout.contains(&format!("Opt-in required:\n {MINIMIST}: {OPT_IN_NOTE}")), + "hosted result must include the activation instruction: {stdout}" + ); + assert_eq!(stdout.matches(OPT_IN_NOTE).count(), 1, "{stdout}"); +} + +#[tokio::test] +async fn repeated_agent_scan_prints_opt_in_note_once() { + let mock = MockServer::start().await; + mount_opt_in_patch_api(&mock, b"before\n", OPT_IN_NOTE).await; + let tmp = minimist_project(b"before\n"); + + let (code, first, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "first run: stdout={first}; stderr={stderr}"); + assert_eq!(first.matches(OPT_IN_NOTE).count(), 1, "first run: {first}"); + let (code, second, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0, "second run: stdout={second}; stderr={stderr}"); + assert!(second.contains("already patched"), "second run: {second}"); + assert_eq!( + second.matches(OPT_IN_NOTE).count(), + 1, + "second run: {second}" + ); +} + +#[tokio::test] +async fn scan_json_events_carry_opt_in_fields_in_hosted_and_repeated_agent_runs() { + let hosted_mock = MockServer::start().await; + mount_opt_in_patch_api(&hosted_mock, b"x\n", OPT_IN_NOTE).await; + mount_forbidden_reference(&hosted_mock, MINIMIST).await; + let hosted_tmp = minimist_project(b"x\n"); + seed_hosted_pin(hosted_tmp.path(), MINIMIST, OLD_UUID); + let (code, stdout, stderr) = run_scan_api( + hosted_tmp.path(), + &hosted_mock.uri(), + &["--dry-run", "--json"], + ); + assert_eq!(code, 0, "hosted: stdout={stdout}; stderr={stderr}"); + assert_opt_in_event(&serde_json::from_str(&stdout).unwrap()); + + let agent_mock = MockServer::start().await; + mount_opt_in_patch_api(&agent_mock, b"before\n", OPT_IN_NOTE).await; + let agent_tmp = minimist_project(b"before\n"); + for run in 1..=2 { + let (code, stdout, stderr) = + run_scan_agent(agent_tmp.path(), &agent_mock.uri(), &["--json"]); + assert_eq!(code, 0, "agent run {run}: stdout={stdout}; stderr={stderr}"); + assert_opt_in_event(&serde_json::from_str(&stdout).unwrap()); + } +} + /// A `package.json` that is not valid JSON may declare project patches /// nobody can read, so its packages are left alone. The run says that the /// file is broken and to fix it. It does not call the packages "protected diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index a8db1a841..2ec504c99 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -132,6 +132,8 @@ const RECORD_KEYS: &[&str] = &[ "description", "license", "tier", + "requiresOptIn", + "optInNote", ]; /// The manifest now at `path` as a JSON object; `None` when there is none @@ -825,6 +827,9 @@ mod tests { exclude: vec!["packages/a".to_string()], manual: Vec::new(), }); + let record = manifest.patches.values_mut().next().unwrap(); + record.requires_opt_in = Some(true); + record.opt_in_note = Some("Set `safeMode` to `true`.".to_string()); let document = serde_json::to_value(&manifest).unwrap(); let keys = |value: &serde_json::Value| -> Vec { value.as_object().unwrap().keys().cloned().collect() diff --git a/crates/socket-patch-core/src/manifest/records.rs b/crates/socket-patch-core/src/manifest/records.rs index 060e4ba12..6ade2bc45 100644 --- a/crates/socket-patch-core/src/manifest/records.rs +++ b/crates/socket-patch-core/src/manifest/records.rs @@ -49,6 +49,15 @@ pub fn build_patch_record( } } +pub fn refresh_opt_in_metadata(record: &mut PatchRecord, patch: &PatchResponse) -> bool { + if record.requires_opt_in == patch.requires_opt_in && record.opt_in_note == patch.opt_in_note { + return false; + } + record.requires_opt_in = patch.requires_opt_in; + record.opt_in_note = patch.opt_in_note.clone(); + true +} + /// Build the manifest-shaped `files` map from a fetched patch view, /// keeping EVERY file the patch touches — including net-new files the /// patch ADDS, which carry an `afterHash` but no `beforeHash`. A new From 5178ef8fd11c4bb603e8380964eaecdc2b3a5230 Mon Sep 17 00:00:00 2001 From: wenxin-jiang Date: Sat, 14 Nov 2026 18:30:51 -0500 Subject: [PATCH 3/7] Harden opt-in note dedup and terminal text Suppress a nested note only when its UUID, flag, and full note match what the caller displayed. Keep changed API instructions visible. Strip control characters from decoded PURLs in note output. Add regressions for differing search/view notes, terminal controls, and UUID deduplication. Assisted-by: Claude Code:kimi-k3p1-pro --- .../src/commands/agent_download.rs | 6 +- crates/socket-patch-cli/src/commands/apply.rs | 16 ++-- crates/socket-patch-cli/src/commands/get.rs | 9 ++- crates/socket-patch-cli/src/ui/mod.rs | 5 +- crates/socket-patch-cli/src/ui/text.rs | 77 ++++++++++++++++++- .../socket-patch-cli/tests/get/get_render.rs | 52 +++++++++++++ 6 files changed, 150 insertions(+), 15 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 6a13dc3fa..0ff6ce24a 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -390,7 +390,7 @@ pub struct DownloadParams { /// `--patch-server-url`: the extra origin whose URLs count as hosted /// when lockfile discovery reads the project's hosted pins. pub patch_server_url: Option, - pub opt_in_notes_shown: Vec, + pub opt_in_notes_shown: Vec, } impl DownloadParams { @@ -1412,7 +1412,7 @@ pub(crate) async fn run_nested_apply( common: GlobalArgs, json: bool, report_user_patch_skips: bool, - shown_opt_in_uuids: Vec, + shown_opt_ins: Vec, recorded: &[(String, String)], client: &ApiClient, lock: LockGuard, @@ -1427,7 +1427,7 @@ pub(crate) async fn run_nested_apply( nested: Some(super::apply::NestedApply { caller_json: json, report_user_patch_skips, - shown_opt_in_uuids, + shown_opt_ins, }), }; // The nested apply runs on the host's client: seeded, never rebuilt. diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index ff785998b..f356213ce 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -186,7 +186,7 @@ pub struct NestedApply { /// (`get`) checked only its own selection. `scan` already reported /// every protected installed package, so it passes `false`. pub report_user_patch_skips: bool, - pub shown_opt_in_uuids: Vec, + pub shown_opt_ins: Vec, } impl ApplyArgs { @@ -1849,12 +1849,12 @@ pub(crate) async fn run_locked( for line in block { println!("{line}"); } - let shown_opt_in_uuids = args + let shown_opt_ins = args .nested .as_ref() - .map_or(&[][..], |nested| nested.shown_opt_in_uuids.as_slice()); + .map_or(&[][..], |nested| nested.shown_opt_ins.as_slice()); if let Some(notes) = - format_opt_in_notes(&opt_ins, &results, &unmatched, shown_opt_in_uuids) + format_opt_in_notes(&opt_ins, &results, &unmatched, shown_opt_ins) { println!("\n{notes}"); } @@ -2402,7 +2402,7 @@ fn format_opt_in_notes( opt_ins: &OptInPatches, results: &[ApplyResult], unmatched: &[String], - shown_opt_in_uuids: &[String], + shown_opt_ins: &[crate::ui::ShownOptIn], ) -> Option { let shown: HashSet<&str> = results .iter() @@ -2411,7 +2411,11 @@ fn format_opt_in_notes( .collect(); crate::ui::opt_in_note_block(opt_ins.iter().filter_map( |(purl, uuid, requires_opt_in, opt_in_note)| { - if !shown.contains(purl.as_str()) || shown_opt_in_uuids.contains(uuid) { + if !shown.contains(purl.as_str()) + || shown_opt_ins + .iter() + .any(|shown| shown.matches(uuid, *requires_opt_in, opt_in_note.as_deref())) + { return None; } Some(crate::ui::OptInEntry { diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index e5af3ccd2..46bdb3286 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1297,7 +1297,7 @@ pub async fn run(args: GetArgs) -> i32 { let note = crate::ui::terminal_text(note).replace('\n', "\n "); println!( "Opt-in required for {}: {note}", - normalize_purl(&patch.purl) + crate::ui::terminal_purl(&patch.purl) ); } } @@ -1813,7 +1813,10 @@ pub async fn run(args: GetArgs) -> i32 { // Download and apply (agent mode), with the run's client and flags. let mut params = get_download_params(&args, args.save_only, /*persist_blobs=*/ true); - params.opt_in_notes_shown = selected.iter().map(|patch| patch.uuid.clone()).collect(); + params.opt_in_notes_shown = selected + .iter() + .map(crate::ui::ShownOptIn::from_search) + .collect(); let run = DownloadRun { api_client: &api_client, lock_timeout: args.common.lock_timeout, @@ -2162,7 +2165,7 @@ async fn save_and_apply_patch( nested_apply_args(&args.common, &manifest_path, quiet), args.common.json, true, - vec![patch.uuid.clone()], + vec![crate::ui::ShownOptIn::from_response(patch)], &recorded, client, lock, diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index e326d939c..f78af43e8 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -23,10 +23,11 @@ use crate::args::GlobalArgs; pub(crate) use prompt::confirm; pub use prompt::{select_one, SelectError}; pub(crate) use status::StatusLine; +pub use text::ShownOptIn; pub(crate) use text::{ agent_next_steps, manifest_error_message, next_steps, next_steps_installed, opt_in_note_block, - plural, sentence_case, shell_word, short_uuid, sweep_failure, terminal_text, truncate, - OptInEntry, + plural, sentence_case, shell_word, short_uuid, sweep_failure, terminal_purl, terminal_text, + truncate, OptInEntry, }; /// [`text::no_patches_message`] for this run: the manifest path is named diff --git a/crates/socket-patch-cli/src/ui/text.rs b/crates/socket-patch-cli/src/ui/text.rs index f057f90dc..769332bbd 100644 --- a/crates/socket-patch-cli/src/ui/text.rs +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -255,6 +255,13 @@ pub(crate) fn terminal_text(s: &str) -> String { .collect() } +pub(crate) fn terminal_purl(purl: &str) -> String { + socket_patch_core::utils::purl::normalize_purl(purl) + .chars() + .filter(|c| !c.is_control()) + .collect() +} + #[derive(Clone, Copy)] pub(crate) struct OptInEntry<'a> { pub purl: &'a str, @@ -274,6 +281,50 @@ impl<'a> OptInEntry<'a> { } } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ShownOptIn { + uuid: String, + requires_opt_in: Option, + opt_in_note: Option, +} + +impl ShownOptIn { + fn new(uuid: &str, requires_opt_in: Option, opt_in_note: Option<&str>) -> Self { + Self { + uuid: uuid.to_string(), + requires_opt_in, + opt_in_note: opt_in_note.map(str::to_string), + } + } + + pub fn from_search(patch: &socket_patch_core::api::types::PatchSearchResult) -> Self { + Self::new( + &patch.uuid, + patch.requires_opt_in, + patch.opt_in_note.as_deref(), + ) + } + + pub fn from_response(patch: &socket_patch_core::api::types::PatchResponse) -> Self { + Self::new( + &patch.uuid, + patch.requires_opt_in, + patch.opt_in_note.as_deref(), + ) + } + + pub(crate) fn matches( + &self, + uuid: &str, + requires_opt_in: Option, + opt_in_note: Option<&str>, + ) -> bool { + self.uuid == uuid + && self.requires_opt_in == requires_opt_in + && self.opt_in_note.as_deref() == opt_in_note + } +} + pub(crate) fn opt_in_note_block<'a>( entries: impl IntoIterator>, ) -> Option { @@ -293,7 +344,7 @@ pub(crate) fn opt_in_note_block<'a>( continue; } let note = note.replace('\n', "\n "); - let purl = socket_patch_core::utils::purl::normalize_purl(entry.purl); + let purl = terminal_purl(entry.purl); let block = block.get_or_insert_with(|| "Opt-in required:".to_string()); block.push_str(&format!("\n {purl}: {note}")); } @@ -335,6 +386,30 @@ mod tests { assert_eq!(plural(7, "patch", "patches"), "7 patches"); } + #[test] + fn opt_in_block_sanitizes_purls_and_dedups_uuids() { + let block = opt_in_note_block([ + OptInEntry { + purl: "pkg:npm/bad%1Bname%09more\r\nnext@1.0.0", + uuid: "uuid-a", + requires_opt_in: Some(true), + opt_in_note: Some("Enable safe mode."), + }, + OptInEntry { + purl: "pkg:npm/other@1.0.0", + uuid: "uuid-a", + requires_opt_in: Some(true), + opt_in_note: Some("Enable safe mode."), + }, + ]) + .unwrap(); + assert_eq!( + block, + "Opt-in required:\n pkg:npm/badnamemorenext@1.0.0: Enable safe mode." + ); + assert!(!block.contains('\x1b') && !block.contains('\r') && !block.contains('\t')); + } + #[test] fn truncate_short_input_is_untouched() { assert_eq!(truncate("hello", 60), "hello"); diff --git a/crates/socket-patch-cli/tests/get/get_render.rs b/crates/socket-patch-cli/tests/get/get_render.rs index 27aa01b63..7db1718bc 100644 --- a/crates/socket-patch-cli/tests/get/get_render.rs +++ b/crates/socket-patch-cli/tests/get/get_render.rs @@ -439,6 +439,58 @@ async fn opt_in_note_renders_and_persists_in_human_and_json_get() { assert_eq!(event["details"]["optInNote"], NOTE); } +#[tokio::test] +async fn changed_view_opt_in_note_is_not_suppressed_in_nested_apply() { + const PREVIEW_NOTE: &str = "Preview configuration instruction."; + const LATEST_NOTE: &str = "Latest configuration instruction."; + let server = MockServer::start().await; + let cve = "CVE-2024-4245"; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-cve/{cve}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Opt-in metadata changes", "license": "MIT", "tier": "free", + "vulnerabilities": {}, + "requiresOptIn": true, "optInNote": PREVIEW_NOTE, + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "files": one_file_view(), + "vulnerabilities": {}, + "description": "Opt-in metadata changes", + "license": "MIT", + "tier": "free", + "requiresOptIn": true, + "optInNote": LATEST_NOTE, + }))) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + + let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[cve]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + assert!(stdout.contains(PREVIEW_NOTE), "{stdout}"); + assert!( + stdout.contains(&format!("Opt-in required:\n {PURL}: {LATEST_NOTE}")), + "nested apply must show the refreshed view's note: {stdout}" + ); + assert_eq!( + manifest_json(tmp.path())["patches"][PURL]["optInNote"], + LATEST_NOTE + ); +} + #[tokio::test] async fn unchanged_uuid_refreshes_and_clears_opt_in_metadata_on_both_save_paths() { async fn mount_api(server: &MockServer, cve: &str, note: Option<&str>) { From d1f94b36d65f36b4e193541cbfafba16c0413d54 Mon Sep 17 00:00:00 2001 From: wenxin-jiang Date: Sat, 14 Nov 2026 19:11:00 -0500 Subject: [PATCH 4/7] Use fetched views for opt-in metadata Fetched patch views now override search metadata in get and scan JSON, including changed or cleared instructions. Reused detached vendor records consume the same prefetched view and persist activation metadata even when artifacts and wiring need no work. Make the manifest schema-key test populate both records so its assertion is not HashMap-order dependent. Assisted-by: Claude Code:kimi-k3p1-pro --- .../src/commands/agent_download.rs | 44 +++++- .../src/commands/scan/vendor_flow.rs | 42 +++++ crates/socket-patch-cli/src/json_envelope.rs | 28 +++- .../socket-patch-cli/tests/get/get_render.rs | 145 ++++++++++++++++++ .../tests/scan/scan_render.rs | 31 ++++ .../src/manifest/operations.rs | 7 +- 6 files changed, 290 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 0ff6ce24a..5f980e0fd 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -24,7 +24,7 @@ use socket_patch_core::vendor::refusals::{ vendor_refusals, LedgerLoad, RefusalScope, VendorRefusal, VendorRefusals, }; use socket_patch_core::vendor::{load_state, lookup_entry, VendorEntry, VendorState}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -974,7 +974,12 @@ pub(crate) async fn fetch_selected_patches( // Idempotency (ledger store): a detached entry already at this uuid // carries its own record — no view fetch needed. - if let Some(record) = detached_ledger_record(store, purl, uuid).cloned() { + if let Some(mut record) = detached_ledger_record(store, purl, uuid).cloned() { + if let Some(patch) = prefetched.get(uuid) { + record.requires_opt_in = patch.requires_opt_in; + record.opt_in_note = patch.opt_in_note.clone(); + batch.metadata_refreshes.push(patch.clone()); + } if !quiet { eprintln!("{}", format_record_skip(purl, "already vendored")); } @@ -1153,6 +1158,7 @@ pub(crate) async fn fetch_selected_patches( /// envelope, how many patches failed, and the records the vendor step /// consumes. pub(crate) struct DetachedDownload { + authoritative: HashSet, pub(crate) code: i32, pub(crate) events: Vec, pub(crate) warnings: Vec, @@ -1168,6 +1174,16 @@ impl DetachedDownload { self, env: &mut Envelope, ) -> (i32, usize, HashMap) { + for (purl, record) in &self.records { + if self.authoritative.contains(&record.uuid) { + env.register_view_opt_in( + purl, + &record.uuid, + record.requires_opt_in, + record.opt_in_note.as_deref(), + ); + } + } for event in self.events { env.record(event); } @@ -1251,11 +1267,18 @@ pub(crate) async fn download_patch_records_preflighted( ) .await; + let mut authoritative: HashSet = batch + .metadata_refreshes + .iter() + .map(|patch| patch.uuid.clone()) + .collect(); let mut records: HashMap = batch.reused.into_iter().collect(); for FetchedPatch { patch, files, .. } in batch.fetched { + authoritative.insert(patch.uuid.clone()); records.insert(patch.purl.clone(), build_patch_record(&patch, files)); } DetachedDownload { + authoritative, code: i32::from(batch.failed > 0), events: batch.events, warnings: release_warnings(batch.warnings), @@ -1722,6 +1745,23 @@ pub async fn download_and_apply_patches_into( // matching the per-patch event (`downloaded` / `updated`) and the // single-uuid flow's summary in `save_and_apply_patch`; their sum gates // the apply step. + for fetched in &batch.fetched { + env.register_view_opt_in( + &fetched.patch.purl, + &fetched.patch.uuid, + fetched.patch.requires_opt_in, + fetched.patch.opt_in_note.as_deref(), + ); + } + for patch in &batch.metadata_refreshes { + env.register_view_opt_in( + &patch.purl, + &patch.uuid, + patch.requires_opt_in, + patch.opt_in_note.as_deref(), + ); + } + let mut metadata_changed = false; for patch in &batch.metadata_refreshes { if let Some(record) = manifest.patches.get_mut(&patch.purl) { diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index a5cc9aa76..6d38cd691 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -581,6 +581,13 @@ async fn vendor_under_lock( ) .await; let has_errors = applied; + if let Err(e) = synchronize_embedded_opt_in_metadata(common, &manifest.patches).await { + return Err(( + "vendor_state_write_failed", + format!("could not persist the vendor records' activation metadata: {e}"), + Some(Box::new(env)), + )); + } migrate_legacy_manifest_records(common, &manifest_path, &manifest.patches, &mut env).await; if has_errors { env.mark_partial_failure(); @@ -595,6 +602,41 @@ fn ledger_key_for(state: &VendorState, purl: &str) -> Option { socket_patch_core::vendor::state::lookup_entry_kv(&state.entries, purl).map(|(k, _)| k.clone()) } +async fn synchronize_embedded_opt_in_metadata( + common: &GlobalArgs, + records: &HashMap, +) -> std::io::Result<()> { + if common.dry_run { + return Ok(()); + } + let mut state = load_state(&common.cwd).await?; + let mut changed = false; + for (purl, record) in records { + let Some(key) = ledger_key_for(&state, purl) else { + continue; + }; + let entry = state.entries.get_mut(&key).expect("ledger key was present"); + if entry.uuid != record.uuid { + continue; + } + let Some(existing) = entry.record.as_mut() else { + continue; + }; + if existing.requires_opt_in == record.requires_opt_in + && existing.opt_in_note == record.opt_in_note + { + continue; + } + existing.requires_opt_in = record.requires_opt_in; + existing.opt_in_note = record.opt_in_note.clone(); + changed = true; + } + if changed { + save_state(&common.cwd, &state).await?; + } + Ok(()) +} + /// Migrate a project vendored by an older, manifest-mode CLI. For the purls /// THIS run vendored (`records`, in-sync `already_vendored` skips included): /// (1) a legacy entry in sync at a record's uuid is upgraded in place diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 1ea3dc05a..f0d78ef6b 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -216,6 +216,26 @@ impl Envelope { if requires_opt_in.is_none() && opt_in_note.is_none() { return; } + self.insert_opt_in(purl, uuid, requires_opt_in, opt_in_note); + } + + pub(crate) fn register_view_opt_in( + &mut self, + purl: &str, + uuid: &str, + requires_opt_in: Option, + opt_in_note: Option<&str>, + ) { + self.insert_opt_in(purl, uuid, requires_opt_in, opt_in_note); + } + + fn insert_opt_in( + &mut self, + purl: &str, + uuid: &str, + requires_opt_in: Option, + opt_in_note: Option<&str>, + ) { let metadata = OptInMetadata { requires_opt_in, opt_in_note: opt_in_note.map(str::to_string), @@ -257,10 +277,14 @@ impl Envelope { .filter(serde_json::Value::is_object) .unwrap_or_else(|| serde_json::json!({})); if let Some(requires_opt_in) = metadata.requires_opt_in { - details["requiresOptIn"] = serde_json::json!(requires_opt_in); + if details.get("requiresOptIn").is_none() { + details["requiresOptIn"] = serde_json::json!(requires_opt_in); + } } if let Some(opt_in_note) = &metadata.opt_in_note { - details["optInNote"] = serde_json::json!(opt_in_note); + if details.get("optInNote").is_none() { + details["optInNote"] = serde_json::json!(opt_in_note); + } } event.details = Some(details); } diff --git a/crates/socket-patch-cli/tests/get/get_render.rs b/crates/socket-patch-cli/tests/get/get_render.rs index 7db1718bc..2b82ad880 100644 --- a/crates/socket-patch-cli/tests/get/get_render.rs +++ b/crates/socket-patch-cli/tests/get/get_render.rs @@ -439,6 +439,53 @@ async fn opt_in_note_renders_and_persists_in_human_and_json_get() { assert_eq!(event["details"]["optInNote"], NOTE); } +#[tokio::test] +async fn get_json_uses_fetched_view_instead_of_stale_search_opt_in_metadata() { + const STALE: &str = "Stale search instruction."; + const LATEST: &str = "Latest view instruction."; + let server = MockServer::start().await; + let cve = "CVE-2024-4246"; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-cve/{cve}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "View metadata is authoritative", + "license": "MIT", "tier": "free", "vulnerabilities": {}, + "requiresOptIn": false, "optInNote": STALE, + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; + let mut view = view_json(UUID, PURL, good_files()); + view["requiresOptIn"] = serde_json::json!(true); + view["optInNote"] = serde_json::json!(LATEST); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)) + .mount(&server) + .await; + let tmp = tempfile::tempdir().unwrap(); + + let (code, stdout, stderr) = + run_get_bin(tmp.path(), &server.uri(), &[cve, "--save-only", "--json"]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let envelope: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let event = envelope["events"] + .as_array() + .unwrap() + .iter() + .find(|event| event["uuid"] == UUID) + .unwrap(); + assert_eq!(event["details"]["requiresOptIn"], true, "{event}"); + assert_eq!(event["details"]["optInNote"], LATEST, "{event}"); + let record = &manifest_json(tmp.path())["patches"][PURL]; + assert_eq!(record["requiresOptIn"], true); + assert_eq!(record["optInNote"], LATEST); +} + #[tokio::test] async fn changed_view_opt_in_note_is_not_suppressed_in_nested_apply() { const PREVIEW_NOTE: &str = "Preview configuration instruction."; @@ -846,6 +893,104 @@ async fn human_vendored_uuid_rerun_prints_already_vendored_skip() { assert!(artifact.is_file(), "stdout={stdout}\nstderr={stderr}"); } +#[tokio::test] +async fn vendored_same_uuid_view_refresh_updates_state_list_and_vex() { + const NOTE: &str = "Set `safeMode` to `true`; defaults remain vulnerable."; + let server = MockServer::start().await; + let files = serde_json::json!({ + "package/index.js": { + "beforeHash": git_hash(BEFORE_BYTES), + "afterHash": git_hash(AFTER_BYTES), + "blobContent": b64(AFTER_BYTES), + } + }); + let mut view = view_json(UUID, PURL, files); + view["vulnerabilities"] = serde_json::json!({ + GHSA: { + "cves": ["CVE-2024-4247"], + "summary": "Activation metadata refresh", + "severity": "high", + "description": "d", + } + }); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view.clone())) + .mount(&server) + .await; + crate::prebuilt_common::mount_view(&server, &view, None).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + + let (code, stdout, stderr) = run_get_bin( + tmp.path(), + &server.uri(), + &[ + UUID, + "--mode", + "vendored", + "--vendor-source", + "service", + "--json", + ], + ); + assert_eq!(code, 0, "initial vendor: stdout={stdout}\nstderr={stderr}"); + let entry = &vendor_state(tmp.path())["entries"][PURL]; + assert!(entry["record"].get("requiresOptIn").is_none()); + assert!(entry["record"].get("optInNote").is_none()); + let artifact = tmp + .path() + .join(".socket/vendor/npm") + .join(UUID) + .join(format!("{NAME}-1.0.0.tgz")); + let artifact_before = std::fs::read(&artifact).unwrap(); + + server.reset().await; + view["requiresOptIn"] = serde_json::json!(true); + view["optInNote"] = serde_json::json!(NOTE); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view)) + .mount(&server) + .await; + let (code, stdout, stderr) = run_get_bin( + tmp.path(), + &server.uri(), + &[UUID, "--mode", "vendored", "--vendor-source", "service"], + ); + assert_eq!(code, 0, "reused vendor: stdout={stdout}\nstderr={stderr}"); + let entry = &vendor_state(tmp.path())["entries"][PURL]; + assert_eq!(entry["record"]["requiresOptIn"], true, "{entry}"); + assert_eq!(entry["record"]["optInNote"], NOTE, "{entry}"); + assert_eq!(std::fs::read(&artifact).unwrap(), artifact_before); + + let (code, stdout, stderr) = crate::common::run_with_env( + tmp.path(), + &["list", "--offline"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_eq!(code, 0, "list: stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains(&format!("Opt-in required: {NOTE}")), + "{stdout}" + ); + + let (code, stdout, stderr) = crate::common::run_with_env( + tmp.path(), + &["vex", "--no-verify"], + &[("SOCKET_TELEMETRY_DISABLED", "1")], + ); + assert_eq!(code, 0, "vex: stdout={stdout}\nstderr={stderr}"); + let document: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let impact = document["statements"][0]["impact_statement"] + .as_str() + .unwrap(); + assert!( + impact.contains(&format!("opt-in required: {NOTE}")), + "{document}" + ); +} + /// Agent-mode `--dry-run` (search path) previews and writes NOTHING: no /// `.socket/`, no view fetch, no prompt, exit 0. #[tokio::test] diff --git a/crates/socket-patch-cli/tests/scan/scan_render.rs b/crates/socket-patch-cli/tests/scan/scan_render.rs index 87b2486e7..52732db6b 100644 --- a/crates/socket-patch-cli/tests/scan/scan_render.rs +++ b/crates/socket-patch-cli/tests/scan/scan_render.rs @@ -408,6 +408,37 @@ async fn repeated_agent_scan_prints_opt_in_note_once() { ); } +#[tokio::test] +async fn agent_scan_json_uses_fetched_view_over_stale_search_metadata() { + let mock = MockServer::start().await; + mount_batch_free(&mock, &[(MINIMIST, UUID)]).await; + mount_by_package_body( + &mock, + MINIMIST, + serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": MINIMIST, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Stale search metadata", + "license": "MIT", "tier": "free", "vulnerabilities": {}, + "requiresOptIn": false, + "optInNote": "Stale search instruction.", + }], + "canAccessPaidPatches": false, + }), + ) + .await; + mount_view_with_opt_in(&mock, MINIMIST, UUID, b"before\n", Some(OPT_IN_NOTE)).await; + let tmp = minimist_project(b"before\n"); + + let (code, stdout, stderr) = run_scan_agent(tmp.path(), &mock.uri(), &["--json"]); + assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}"); + assert_opt_in_event(&serde_json::from_str(&stdout).unwrap()); + let record = &read_manifest(tmp.path())["patches"][MINIMIST]; + assert_eq!(record["requiresOptIn"], true); + assert_eq!(record["optInNote"], OPT_IN_NOTE); +} + #[tokio::test] async fn scan_json_events_carry_opt_in_fields_in_hosted_and_repeated_agent_runs() { let hosted_mock = MockServer::start().await; diff --git a/crates/socket-patch-core/src/manifest/operations.rs b/crates/socket-patch-core/src/manifest/operations.rs index 2ec504c99..c28db9af4 100644 --- a/crates/socket-patch-core/src/manifest/operations.rs +++ b/crates/socket-patch-core/src/manifest/operations.rs @@ -827,9 +827,10 @@ mod tests { exclude: vec!["packages/a".to_string()], manual: Vec::new(), }); - let record = manifest.patches.values_mut().next().unwrap(); - record.requires_opt_in = Some(true); - record.opt_in_note = Some("Set `safeMode` to `true`.".to_string()); + for record in manifest.patches.values_mut() { + record.requires_opt_in = Some(true); + record.opt_in_note = Some("Set `safeMode` to `true`.".to_string()); + } let document = serde_json::to_value(&manifest).unwrap(); let keys = |value: &serde_json::Value| -> Vec { value.as_object().unwrap().keys().cloned().collect() From 54692fa0482d3464e6eee19b7a50baeb70b97bdd Mon Sep 17 00:00:00 2001 From: wenxin-jiang Date: Sat, 14 Nov 2026 19:52:27 -0500 Subject: [PATCH 5/7] Fetch views before opt-in warnings Fetch authoritative patch views for same-UUID detached reuse, including search-based reuse, while preserving existing fields offline. Move human activation notes after download and persistence. Use one shared formatter keyed by UUID and note, and carry that exact displayed metadata into nested apply deduplication. Assisted-by: Claude Code:kimi-k3p1-pro --- .../src/commands/agent_download.rs | 57 +++++++++++-- crates/socket-patch-cli/src/commands/get.rs | 81 +++++++++++-------- .../socket-patch-cli/src/commands/scan/mod.rs | 2 +- crates/socket-patch-cli/src/json_envelope.rs | 20 +++++ crates/socket-patch-cli/src/ui/mod.rs | 4 +- crates/socket-patch-cli/src/ui/text.rs | 25 +++--- .../socket-patch-cli/tests/get/get_fixture.rs | 2 +- .../socket-patch-cli/tests/get/get_render.rs | 50 ++++++++++-- .../tests/in_process_get_update_count.rs | 2 +- 9 files changed, 184 insertions(+), 59 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index 5f980e0fd..ecebf22ca 100644 --- a/crates/socket-patch-cli/src/commands/agent_download.rs +++ b/crates/socket-patch-cli/src/commands/agent_download.rs @@ -364,6 +364,7 @@ pub struct DownloadParams { pub global_prefix: Option, pub json: bool, pub silent: bool, + pub offline: bool, /// When `false` (the default — narrow), a release-variant package (PyPI /// `?artifact_id=`, RubyGems `?platform=`, Maven `?classifier=`) is /// filtered down to the variant(s) matching the locally-installed @@ -390,7 +391,6 @@ pub struct DownloadParams { /// `--patch-server-url`: the extra origin whose URLs count as hosted /// when lockfile discovery reads the project's hosted pins. pub patch_server_url: Option, - pub opt_in_notes_shown: Vec, } impl DownloadParams { @@ -853,7 +853,8 @@ pub(crate) fn claimed_purls( } /// The record a detached ledger entry already carries for `purl` at -/// exactly `uuid` — the ledger store's idempotency skip (no view fetch). +/// exactly `uuid`. Online reuse still fetches its view for authoritative +/// activation metadata; offline reuse keeps the existing fields. /// Always `None` for the manifest store. pub(crate) fn detached_ledger_record<'a>( store: RecordStore<'a>, @@ -973,12 +974,39 @@ pub(crate) async fn fetch_selected_patches( } // Idempotency (ledger store): a detached entry already at this uuid - // carries its own record — no view fetch needed. + // retains its artifact and wiring; its view still governs activation metadata. if let Some(mut record) = detached_ledger_record(store, purl, uuid).cloned() { - if let Some(patch) = prefetched.get(uuid) { + if !params.offline { + let view = match prefetched.remove(uuid) { + Some(patch) => Ok(Some(patch)), + None => api_client.fetch_patch(uuid).await, + }; + let patch = match view { + Ok(Some(patch)) => patch, + Ok(None) => { + batch.fail( + params.json, + Some(format!("[fail] {purl} (could not fetch details)")), + (purl, uuid), + DOWNLOAD_FAILED, + "could not fetch details", + ); + continue; + } + Err(e) => { + batch.fail( + params.json, + Some(format!("[fail] {purl} ({e})")), + (purl, uuid), + DOWNLOAD_FAILED, + &e.to_string(), + ); + continue; + } + }; record.requires_opt_in = patch.requires_opt_in; record.opt_in_note = patch.opt_in_note.clone(); - batch.metadata_refreshes.push(patch.clone()); + batch.metadata_refreshes.push(patch); } if !quiet { eprintln!("{}", format_record_skip(purl, "already vendored")); @@ -1182,6 +1210,13 @@ impl DetachedDownload { record.requires_opt_in, record.opt_in_note.as_deref(), ); + } else { + env.register_opt_in( + purl, + &record.uuid, + record.requires_opt_in, + record.opt_in_note.as_deref(), + ); } } for event in self.events { @@ -1859,6 +1894,16 @@ pub async fn download_and_apply_patches_into( ); } + if !quiet { + if let Some(notes) = env.opt_in_note_block() { + println!("\n{notes}"); + } + } + let shown_opt_ins: Vec<_> = recorded + .iter() + .filter_map(|(_, uuid)| env.shown_opt_ins(uuid)) + .collect(); + // Auto-apply unless --save-only (the lock decision above). let mut apply_report: Option = None; if let Some(lock) = apply_lock { @@ -1874,7 +1919,7 @@ pub async fn download_and_apply_patches_into( nested_apply_args_from_params(params, run, &manifest_path), params.json, !params.user_patch_skips_reported, - params.opt_in_notes_shown.clone(), + shown_opt_ins, &recorded, run.api_client, lock, diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 46bdb3286..6566d1b7b 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -369,12 +369,6 @@ fn format_search_results( if !desc.is_empty() { out.push_str(&format!(" Description: {desc}\n")); } - if patch.requires_opt_in == Some(true) { - if let Some(note) = &patch.opt_in_note { - let note = crate::ui::terminal_text(note).replace('\n', "\n "); - out.push_str(&format!(" Opt-in required: {note}\n")); - } - } let mut fixes: Vec<(String, String)> = patch .vulnerabilities .iter() @@ -1292,15 +1286,6 @@ pub async fn run(args: GetArgs) -> i32 { crate::ui::stderr_color(), ) ); - if patch.requires_opt_in == Some(true) { - if let Some(note) = &patch.opt_in_note { - let note = crate::ui::terminal_text(note).replace('\n', "\n "); - println!( - "Opt-in required for {}: {note}", - crate::ui::terminal_purl(&patch.purl) - ); - } - } } // Record the fetch BEFORE the save+apply step so the @@ -1349,7 +1334,7 @@ pub async fn run(args: GetArgs) -> i32 { // re-fetching with a fresh one, which would re-hit the // 401/403 the fallback just recovered from. An explicit // UUID is exempt from installed narrowing (exact intent). - return match mode { + let code = match mode { // Save to manifest and apply in place. super::scan::ScanMode::Agent => { save_and_apply_patch(&args, &api_client, &patch, &uuid_warnings).await @@ -1371,6 +1356,14 @@ pub async fn run(args: GetArgs) -> i32 { .await } }; + if !quiet { + if let Some(notes) = + crate::ui::opt_in_note_block([crate::ui::OptInEntry::from_response(&patch)]) + { + println!("\n{notes}"); + } + } + return code; } // The public proxy answers a paid patch with 403 rather than // a tier=paid view: the same outcome as the branch above, not @@ -1812,11 +1805,7 @@ pub async fn run(args: GetArgs) -> i32 { } // Download and apply (agent mode), with the run's client and flags. - let mut params = get_download_params(&args, args.save_only, /*persist_blobs=*/ true); - params.opt_in_notes_shown = selected - .iter() - .map(crate::ui::ShownOptIn::from_search) - .collect(); + let params = get_download_params(&args, args.save_only, /*persist_blobs=*/ true); let run = DownloadRun { api_client: &api_client, lock_timeout: args.common.lock_timeout, @@ -2285,7 +2274,7 @@ fn get_download_params(args: &GetArgs, save_only: bool, persist_blobs: bool) -> ecosystems: args.common.ecosystems.clone(), persist_blobs, patch_server_url: args.common.patch_server_url.clone(), - opt_in_notes_shown: Vec::new(), + offline: args.common.offline, } } @@ -2319,7 +2308,7 @@ async fn run_get_hosted( // default-off VexEmbedArgs — deliberately NOT env-bound here, so an // ambient SOCKET_VEX only affects commands that declare the flag. let vex = crate::commands::vex::VexEmbedArgs::default(); - super::scan::boxed_run_redirect_selected( + let code = super::scan::boxed_run_redirect_selected( &args.common, &vex, /*prune_requested=*/ false, @@ -2331,7 +2320,15 @@ async fn run_get_hosted( None, "get", ) - .await + .await; + if !args.common.json && !args.common.silent { + if let Some(notes) = + crate::ui::opt_in_note_block(selected.iter().map(crate::ui::OptInEntry::from_search)) + { + println!("\n{notes}"); + } + } + code } /// `get … --mode vendored`, both identifier paths: scan's vendored posture @@ -2462,7 +2459,7 @@ async fn run_get_vendored( // records fetched above (moved in — nothing here needs them afterwards) // and over this run's client, then the run's telemetry. A per-patch // download failure does not skip it (scan parity). - match super::scan::boxed_vendor_step(super::scan::VendorStep { + let code = match super::scan::boxed_vendor_step(super::scan::VendorStep { common: &args.common, records, client: api_client.clone(), @@ -2503,7 +2500,13 @@ async fn run_get_vendored( } 1 } + }; + if !args.common.json && !args.common.silent { + if let Some(notes) = env.opt_in_note_block() { + println!("\n{notes}"); + } } + code } #[cfg(test)] @@ -4090,7 +4093,7 @@ mod tests { ecosystems: None, persist_blobs: false, patch_server_url: None, - opt_in_notes_shown: Vec::new(), + offline: false, } } @@ -4791,7 +4794,7 @@ mod tests { // The vendor-detached posture this fn exists for. persist_blobs: false, patch_server_url: None, - opt_in_notes_shown: Vec::new(), + offline: false, } } @@ -5540,6 +5543,8 @@ mod tests { "description": "embedded", "license": "MIT", "tier": "free", + "requiresOptIn": true, + "optInNote": "Keep the embedded instruction offline", } }} })) @@ -5551,6 +5556,7 @@ mod tests { let mut params = detached_params(tmp.path()); params.json = false; params.silent = false; + params.offline = true; let (code, json, records) = download_patch_records(&selected, ¶ms, &server.uri()).await; assert_eq!(code, 0, "json={json}"); @@ -5562,6 +5568,11 @@ mod tests { Some(uuid), "the ledger's embedded record must be reused" ); + assert_eq!(records[purl].requires_opt_in, Some(true)); + assert_eq!( + records[purl].opt_in_note.as_deref(), + Some("Keep the embedded instruction offline") + ); assert!( server .received_requests() @@ -6159,9 +6170,9 @@ mod tests { ) .mount(&server) .await; - // `d` is held in memory and `e` is reused from the ledger: never - // requested. - for u in [&d, &e] { + // `d` is held in memory. `e` is reused from the ledger, but its view + // still supplies current activation metadata without a download event. + for u in [&d] { Mock::given(method("GET")) .and(wm_path(format!("/v0/orgs/test-org/patches/view/{u}"))) .respond_with(ResponseTemplate::new(500)) @@ -6169,6 +6180,12 @@ mod tests { .mount(&server) .await; } + mount( + &e, + ResponseTemplate::new(200).set_body_json(view(&e, &purl("e"))), + ) + .mount(&server) + .await; mount( &f, ResponseTemplate::new(200).set_body_json(view(&f, &purl("f"))), @@ -6252,8 +6269,8 @@ mod tests { row("b", "failed", "could not fetch details"), row("c", "failed", "API request failed with status 500: boom"), row("d", "downloaded", ""), - // e: the ledger already holds it at this uuid — reused, no - // fetch and no event (the vendor engine reports it). + // e: its view refreshes activation metadata; the vendor engine + // reports the reuse, so the download phase still has no event. row("f", "downloaded", ""), ], "json={json}" diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 682c825d6..a369050d4 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -990,7 +990,7 @@ fn download_params(args: &ScanArgs, save_only: bool, json: bool, silent: bool) - ecosystems: args.common.ecosystems.clone(), persist_blobs: args.mode != Some(ScanMode::Vendored), patch_server_url: args.common.patch_server_url.clone(), - opt_in_notes_shown: Vec::new(), + offline: args.common.offline, } } diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index f0d78ef6b..d280c2941 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -289,6 +289,26 @@ impl Envelope { event.details = Some(details); } + pub(crate) fn opt_in_note_block(&self) -> Option { + crate::ui::opt_in_note_block(self.opt_in_by_patch.iter().map(|((purl, uuid), metadata)| { + crate::ui::OptInEntry { + purl, + uuid, + requires_opt_in: metadata.requires_opt_in, + opt_in_note: metadata.opt_in_note.as_deref(), + } + })) + } + + pub(crate) fn shown_opt_ins(&self, uuid: &str) -> Option { + let metadata = self.opt_in_by_uuid.get(uuid)?; + Some(crate::ui::ShownOptIn::new( + uuid, + metadata.requires_opt_in, + metadata.opt_in_note.as_deref(), + )) + } + pub(crate) fn add_registered_opt_in_to_events(&mut self) { let mut events = std::mem::take(&mut self.events); for event in &mut events { diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index f78af43e8..e1c050f87 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -26,8 +26,8 @@ pub(crate) use status::StatusLine; pub use text::ShownOptIn; pub(crate) use text::{ agent_next_steps, manifest_error_message, next_steps, next_steps_installed, opt_in_note_block, - plural, sentence_case, shell_word, short_uuid, sweep_failure, terminal_purl, terminal_text, - truncate, OptInEntry, + plural, sentence_case, shell_word, short_uuid, sweep_failure, terminal_text, truncate, + OptInEntry, }; /// [`text::no_patches_message`] for this run: the manifest path is named diff --git a/crates/socket-patch-cli/src/ui/text.rs b/crates/socket-patch-cli/src/ui/text.rs index 769332bbd..cf1d30130 100644 --- a/crates/socket-patch-cli/src/ui/text.rs +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -279,6 +279,15 @@ impl<'a> OptInEntry<'a> { opt_in_note: patch.opt_in_note.as_deref(), } } + + pub fn from_response(patch: &'a socket_patch_core::api::types::PatchResponse) -> Self { + Self { + purl: &patch.purl, + uuid: &patch.uuid, + requires_opt_in: patch.requires_opt_in, + opt_in_note: patch.opt_in_note.as_deref(), + } + } } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -289,7 +298,11 @@ pub struct ShownOptIn { } impl ShownOptIn { - fn new(uuid: &str, requires_opt_in: Option, opt_in_note: Option<&str>) -> Self { + pub(crate) fn new( + uuid: &str, + requires_opt_in: Option, + opt_in_note: Option<&str>, + ) -> Self { Self { uuid: uuid.to_string(), requires_opt_in, @@ -297,14 +310,6 @@ impl ShownOptIn { } } - pub fn from_search(patch: &socket_patch_core::api::types::PatchSearchResult) -> Self { - Self::new( - &patch.uuid, - patch.requires_opt_in, - patch.opt_in_note.as_deref(), - ) - } - pub fn from_response(patch: &socket_patch_core::api::types::PatchResponse) -> Self { Self::new( &patch.uuid, @@ -340,7 +345,7 @@ pub(crate) fn opt_in_note_block<'a>( continue; }; let note = terminal_text(note); - if note.trim().is_empty() || !seen.insert(entry.uuid) { + if note.trim().is_empty() || !seen.insert((entry.uuid.to_string(), note.clone())) { continue; } let note = note.replace('\n', "\n "); diff --git a/crates/socket-patch-cli/tests/get/get_fixture.rs b/crates/socket-patch-cli/tests/get/get_fixture.rs index aa82737fc..985470ae6 100644 --- a/crates/socket-patch-cli/tests/get/get_fixture.rs +++ b/crates/socket-patch-cli/tests/get/get_fixture.rs @@ -300,7 +300,7 @@ pub fn engine_params(root: &Path) -> DownloadParams { ecosystems: None, persist_blobs: true, patch_server_url: None, - opt_in_notes_shown: Vec::new(), + offline: false, all_releases: true, } } diff --git a/crates/socket-patch-cli/tests/get/get_render.rs b/crates/socket-patch-cli/tests/get/get_render.rs index 2b82ad880..56fb66a47 100644 --- a/crates/socket-patch-cli/tests/get/get_render.rs +++ b/crates/socket-patch-cli/tests/get/get_render.rs @@ -397,7 +397,7 @@ async fn opt_in_note_renders_and_persists_in_human_and_json_get() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( stdout.contains( - "Opt-in required: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." + "Opt-in required:\n pkg:npm/covgap-pkg@1.0.0: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." ), "stdout={stdout}" ); @@ -415,7 +415,7 @@ async fn opt_in_note_renders_and_persists_in_human_and_json_get() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); assert!( stdout.contains(&format!( - "Opt-in required for {PURL}: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." + "Opt-in required:\n {PURL}: Set `safeMode` to `true`.[2J]0;bad\n Defaults remain vulnerable." )), "stdout={stdout}" ); @@ -467,6 +467,15 @@ async fn get_json_uses_fetched_view_instead_of_stale_search_opt_in_metadata() { .respond_with(ResponseTemplate::new(200).set_body_json(view)) .mount(&server) .await; + let human = tempfile::tempdir().unwrap(); + let (code, stdout, stderr) = run_get_bin(human.path(), &server.uri(), &[cve, "--save-only"]); + assert_eq!(code, 0, "save-only: stdout={stdout}\nstderr={stderr}"); + assert!( + stdout.contains(&format!("Opt-in required:\n {PURL}: {LATEST}")), + "save-only must show the fetched view: {stdout}" + ); + assert!(!stdout.contains(STALE), "{stdout}"); + let tmp = tempfile::tempdir().unwrap(); let (code, stdout, stderr) = @@ -487,7 +496,7 @@ async fn get_json_uses_fetched_view_instead_of_stale_search_opt_in_metadata() { } #[tokio::test] -async fn changed_view_opt_in_note_is_not_suppressed_in_nested_apply() { +async fn get_renders_latest_view_note_not_stale_search_preview() { const PREVIEW_NOTE: &str = "Preview configuration instruction."; const LATEST_NOTE: &str = "Latest configuration instruction."; let server = MockServer::start().await; @@ -527,7 +536,11 @@ async fn changed_view_opt_in_note_is_not_suppressed_in_nested_apply() { let (code, stdout, stderr) = run_get_bin(tmp.path(), &server.uri(), &[cve]); assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); - assert!(stdout.contains(PREVIEW_NOTE), "{stdout}"); + assert!( + !stdout.contains(PREVIEW_NOTE), + "the search note must not print: {stdout}" + ); + assert_eq!(stdout.matches(LATEST_NOTE).count(), 1, "{stdout}"); assert!( stdout.contains(&format!("Opt-in required:\n {PURL}: {LATEST_NOTE}")), "nested apply must show the refreshed view's note: {stdout}" @@ -894,7 +907,7 @@ async fn human_vendored_uuid_rerun_prints_already_vendored_skip() { } #[tokio::test] -async fn vendored_same_uuid_view_refresh_updates_state_list_and_vex() { +async fn vendored_search_reuse_refreshes_view_metadata_everywhere() { const NOTE: &str = "Set `safeMode` to `true`; defaults remain vulnerable."; let server = MockServer::start().await; let files = serde_json::json!({ @@ -953,12 +966,37 @@ async fn vendored_same_uuid_view_refresh_updates_state_list_and_vex() { .respond_with(ResponseTemplate::new(200).set_body_json(view)) .mount(&server) .await; + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-cve/CVE-2024-4247"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [{ + "uuid": UUID, "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Search must not govern reuse", "license": "MIT", "tier": "free", + "vulnerabilities": {}, "requiresOptIn": false, + }], + "canAccessPaidPatches": false, + }))) + .mount(&server) + .await; let (code, stdout, stderr) = run_get_bin( tmp.path(), &server.uri(), - &[UUID, "--mode", "vendored", "--vendor-source", "service"], + &[ + "CVE-2024-4247", + "--mode", + "vendored", + "--vendor-source", + "service", + ], ); assert_eq!(code, 0, "reused vendor: stdout={stdout}\nstderr={stderr}"); + assert!(stdout.contains(NOTE), "latest fetched note: {stdout}"); + assert_eq!( + requests_containing(&server, "/patches/view/").await, + 1, + "search-based reuse must fetch the authoritative view" + ); let entry = &vendor_state(tmp.path())["entries"][PURL]; assert_eq!(entry["record"]["requiresOptIn"], true, "{entry}"); assert_eq!(entry["record"]["optInNote"], NOTE, "{entry}"); diff --git a/crates/socket-patch-cli/tests/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index d2a89e4e3..d60018b31 100644 --- a/crates/socket-patch-cli/tests/in_process_get_update_count.rs +++ b/crates/socket-patch-cli/tests/in_process_get_update_count.rs @@ -98,7 +98,7 @@ fn params(root: &Path) -> DownloadParams { ecosystems: None, persist_blobs: true, patch_server_url: None, - opt_in_notes_shown: Vec::new(), + offline: false, // Skip release-narrowing; npm has no variants anyway. all_releases: true, } From a5b042fd1dca302da01fbba3bf8757da43cfd245 Mon Sep 17 00:00:00 2001 From: wenxin-jiang Date: Sat, 14 Nov 2026 20:55:03 -0500 Subject: [PATCH 6/7] Render opt-in notes once on every path Use fetched or persisted activation metadata after real downloads and one shared preview renderer for dry runs. Defer wet vendored output to the shared vendor step and cover agent, hosted, vendored, agent, apply, and list paths with a parameterized exact-once matrix. Keep unsupported save-only combinations as zero-request usage errors and preserve offline vendor activation metadata. Assisted-by: Claude Code:kimi-k3p1-pro --- crates/socket-patch-cli/src/commands/apply.rs | 37 +++ crates/socket-patch-cli/src/commands/get.rs | 17 +- crates/socket-patch-cli/src/commands/list.rs | 39 ++- .../socket-patch-cli/src/commands/scan/mod.rs | 6 +- .../src/commands/scan/vendor_flow.rs | 14 + crates/socket-patch-cli/src/ui/mod.rs | 4 +- crates/socket-patch-cli/src/ui/text.rs | 6 + .../tests/opt_in_output_matrix.rs | 276 ++++++++++++++++++ 8 files changed, 386 insertions(+), 13 deletions(-) create mode 100644 crates/socket-patch-cli/tests/opt_in_output_matrix.rs diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index f356213ce..26c16329b 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -603,6 +603,21 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } } + if !args.common.json && !args.common.silent { + if let Some(notes) = + crate::ui::opt_in_note_block(manifest.patches.iter().map(|(purl, record)| { + crate::ui::OptInEntry { + purl, + uuid: &record.uuid, + requires_opt_in: record.requires_opt_in, + opt_in_note: record.opt_in_note.as_deref(), + } + })) + { + println!("\n{notes}"); + } + } + if drifts.is_empty() { if args.common.json { let mut env = Envelope::new(Command::Apply); @@ -1333,6 +1348,7 @@ pub async fn run(args: ApplyArgs) -> i32 { _ => {} } } + print_vendor_opt_in_notes(&args.common).await; return i32::from(matches!(vex_result, Some(ManifestlessVex::Failed(_)))); } @@ -1502,6 +1518,27 @@ fn collect_apply_outcomes( (applied, skipped) } +async fn print_vendor_opt_in_notes(common: &GlobalArgs) { + if common.json || common.silent || common.is_global() { + return; + } + let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await else { + return; + }; + let entries = state.entries.values().filter_map(|entry| { + let record = entry.record.as_ref()?; + Some(crate::ui::OptInEntry { + purl: &entry.base_purl, + uuid: &record.uuid, + requires_opt_in: record.requires_opt_in, + opt_in_note: record.opt_in_note.as_deref(), + }) + }); + if let Some(notes) = crate::ui::opt_in_note_block(entries) { + println!("\n{notes}"); + } +} + type OptInPatches = Vec<(String, String, Option, Option)>; fn opt_in_patches(manifest: &PatchManifest) -> OptInPatches { diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 6566d1b7b..f30404d8c 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1356,7 +1356,7 @@ pub async fn run(args: GetArgs) -> i32 { .await } }; - if !quiet { + if !quiet && mode == super::scan::ScanMode::Agent { if let Some(notes) = crate::ui::opt_in_note_block([crate::ui::OptInEntry::from_response(&patch)]) { @@ -1925,6 +1925,11 @@ async fn agent_dry_run( for line in &lines { println!("{line}"); } + if let Some(notes) = + crate::ui::opt_in_preview(selected.iter().map(crate::ui::OptInEntry::from_search)) + { + println!("\n{notes}"); + } let action = if args.save_only { "download and record" } else { @@ -2371,6 +2376,11 @@ async fn run_get_vendored( emit(&env); } else if !args.common.silent { preview.print_human(); + if let Some(notes) = + crate::ui::opt_in_preview(selected.iter().map(crate::ui::OptInEntry::from_search)) + { + println!("\n{notes}"); + } } return 0; } @@ -2501,11 +2511,6 @@ async fn run_get_vendored( 1 } }; - if !args.common.json && !args.common.silent { - if let Some(notes) = env.opt_in_note_block() { - println!("\n{notes}"); - } - } code } diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index 56560fc98..93e016c1b 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -274,7 +274,12 @@ fn field(indent: &str, label: &str, value: &str) -> Option { } /// One entry of the human listing (no trailing newline). +#[cfg(test)] fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { + format_entry_inner(entry, color, true) +} + +fn format_entry_inner(entry: &ListEntry<'_>, color: bool, show_opt_in: bool) -> String { let patch = entry.record; let mut lines = vec![format!("Package: {}", sanitize(entry.purl))]; lines.extend(field(" ", "UUID", &patch.uuid)); @@ -293,7 +298,7 @@ fn format_entry(entry: &ListEntry<'_>, color: bool) -> String { lines.extend(field(" ", "License", &patch.license)); lines.extend(field(" ", "Exported", &patch.exported_at)); lines.extend(field(" ", "Description", &patch.description)); - if patch.requires_opt_in == Some(true) { + if show_opt_in && patch.requires_opt_in == Some(true) { if let Some(note) = &patch.opt_in_note { lines.extend(field(" ", "Opt-in required", note)); } @@ -348,7 +353,22 @@ fn format_listing(entries: &[ListEntry<'_>], color: bool) -> String { "Found {}:\n\n", crate::ui::plural(entries.len(), "patch", "patches") ); - let blocks: Vec = entries.iter().map(|e| format_entry(e, color)).collect(); + let mut shown_opt_ins = std::collections::HashSet::new(); + let blocks: Vec = entries + .iter() + .map(|entry| { + let show_opt_in = match ( + entry.record.requires_opt_in, + entry.record.opt_in_note.as_deref(), + ) { + (Some(true), Some(note)) if !note.trim().is_empty() => { + shown_opt_ins.insert((entry.record.uuid.as_str(), note)) + } + _ => false, + }; + format_entry_inner(entry, color, show_opt_in) + }) + .collect(); out.push_str(&blocks.join("\n\n")); out } @@ -1231,6 +1251,21 @@ mod tests { assert!(out.contains(" Summary: 漏洞\n"), "{out}"); } + #[test] + fn format_listing_deduplicates_identical_opt_in_notes_per_uuid() { + let mut manifest = sample_manifest(); + let mut other = manifest.patches["pkg:npm/minimist@1.2.2"].clone(); + other.requires_opt_in = Some(true); + other.opt_in_note = Some("Enable safe mode.".into()); + manifest.patches.insert("pkg:npm/other@1.0.0".into(), other); + let first = manifest.patches.get_mut("pkg:npm/minimist@1.2.2").unwrap(); + first.requires_opt_in = Some(true); + first.opt_in_note = Some("Enable safe mode.".into()); + + let out = format_listing(&combined_entries(Some(&manifest), &[], None), false); + assert_eq!(out.matches("Enable safe mode.").count(), 1, "{out}"); + } + #[test] fn format_listing_counts_and_separates_entries() { assert_eq!(format_listing(&[], false), NO_PATCHES); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index a369050d4..b9e2ba8f4 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -3615,7 +3615,7 @@ async fn scan_project( if selected.is_empty() && (!reapply || already_recorded.is_empty()) { if !silent { - if let Some(notes) = ui::opt_in_note_block( + if let Some(notes) = ui::opt_in_preview( selected .iter() .chain(&already_recorded) @@ -3689,8 +3689,8 @@ async fn scan_project( } } - if !silent && (vendor || report_only || args.common.dry_run) { - if let Some(notes) = ui::opt_in_note_block( + if !silent && (report_only || args.common.dry_run) { + if let Some(notes) = ui::opt_in_preview( selected .iter() .chain(&already_recorded) diff --git a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs index 6d38cd691..0bf26c69f 100644 --- a/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs +++ b/crates/socket-patch-cli/src/commands/scan/vendor_flow.rs @@ -588,6 +588,20 @@ async fn vendor_under_lock( Some(Box::new(env)), )); } + if !common.json && !common.silent { + if let Some(notes) = + crate::ui::opt_in_note_block(manifest.patches.iter().map(|(purl, record)| { + crate::ui::OptInEntry { + purl, + uuid: &record.uuid, + requires_opt_in: record.requires_opt_in, + opt_in_note: record.opt_in_note.as_deref(), + } + })) + { + println!("\n{notes}"); + } + } migrate_legacy_manifest_records(common, &manifest_path, &manifest.patches, &mut env).await; if has_errors { env.mark_partial_failure(); diff --git a/crates/socket-patch-cli/src/ui/mod.rs b/crates/socket-patch-cli/src/ui/mod.rs index e1c050f87..8b25adfd9 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -26,8 +26,8 @@ pub(crate) use status::StatusLine; pub use text::ShownOptIn; pub(crate) use text::{ agent_next_steps, manifest_error_message, next_steps, next_steps_installed, opt_in_note_block, - plural, sentence_case, shell_word, short_uuid, sweep_failure, terminal_text, truncate, - OptInEntry, + opt_in_preview, plural, sentence_case, shell_word, short_uuid, sweep_failure, terminal_text, + truncate, OptInEntry, }; /// [`text::no_patches_message`] for this run: the manifest path is named diff --git a/crates/socket-patch-cli/src/ui/text.rs b/crates/socket-patch-cli/src/ui/text.rs index cf1d30130..100e69679 100644 --- a/crates/socket-patch-cli/src/ui/text.rs +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -330,6 +330,12 @@ impl ShownOptIn { } } +pub(crate) fn opt_in_preview<'a>( + entries: impl IntoIterator>, +) -> Option { + opt_in_note_block(entries) +} + pub(crate) fn opt_in_note_block<'a>( entries: impl IntoIterator>, ) -> Option { diff --git a/crates/socket-patch-cli/tests/opt_in_output_matrix.rs b/crates/socket-patch-cli/tests/opt_in_output_matrix.rs new file mode 100644 index 000000000..076cd8bb6 --- /dev/null +++ b/crates/socket-patch-cli/tests/opt_in_output_matrix.rs @@ -0,0 +1,276 @@ +#[path = "common/mod.rs"] +mod common; +#[path = "get/get_fixture.rs"] +#[allow(dead_code)] +mod get_fixture; +#[path = "prebuilt_common/mod.rs"] +mod prebuilt_common; + +use get_fixture::*; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const CVE: &str = "CVE-2024-4248"; +const NOTE: &str = "Enable safe mode explicitly before using the patch."; + +#[derive(Clone, Copy)] +enum CommandKind { + Get, + Scan, + Apply, + List, +} + +#[derive(Clone, Copy)] +enum Mode { + Agent, + Hosted, + Vendored, +} + +#[derive(Clone, Copy)] +enum Action { + Real, + DryRun, + SaveOnly, +} + +impl CommandKind { + fn name(self) -> &'static str { + match self { + Self::Get => "get", + Self::Scan => "scan", + Self::Apply => "apply", + Self::List => "list", + } + } +} + +impl Mode { + fn name(self) -> &'static str { + match self { + Self::Agent => "agent", + Self::Hosted => "hosted", + Self::Vendored => "vendored", + } + } +} + +impl Action { + fn name(self) -> &'static str { + match self { + Self::Real => "real", + Self::DryRun => "dry-run", + Self::SaveOnly => "save-only", + } + } +} + +async fn mount_api(server: &MockServer) { + let mut view = view_json( + UUID, + PURL, + serde_json::json!({ + "package/index.js": { + "beforeHash": git_hash(BEFORE_BYTES), + "afterHash": git_hash(AFTER_BYTES), + "blobContent": b64(AFTER_BYTES), + } + }), + ); + view["requiresOptIn"] = serde_json::json!(true); + view["optInNote"] = serde_json::json!(NOTE); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(view.clone())) + .mount(server) + .await; + crate::prebuilt_common::mount_view(server, &view, None).await; + + let records = serde_json::json!([{ + "uuid": UUID, + "purl": PURL, + "publishedAt": "2024-01-01T00:00:00Z", + "description": "Activation metadata matrix", + "license": "MIT", + "tier": "free", + "vulnerabilities": {}, + "requiresOptIn": true, + "optInNote": NOTE, + }]); + Mock::given(method("GET")) + .and(path(format!("/v0/orgs/{ORG}/patches/by-cve/{CVE}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": records.clone(), + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(format!( + "/v0/orgs/{ORG}/patches/by-package/{}", + encode_purl(PURL) + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": records, + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": PURL, + "patches": [{ + "uuid": UUID, "purl": PURL, "tier": "free", + "cveIds": [], "ghsaIds": [], "severity": "high", + "title": "Activation metadata matrix", + "requiresOptIn": true, "optInNote": NOTE, + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(server) + .await; +} + +fn encode_purl(purl: &str) -> String { + purl.replace(':', "%3A") + .replace('/', "%2F") + .replace('@', "%40") +} + +fn api_args(server: &MockServer) -> Vec { + vec![ + "--api-url".into(), + server.uri(), + "--api-token".into(), + "fake-token-for-test".into(), + "--org".into(), + ORG.into(), + ] +} + +fn run_argv(root: &std::path::Path, args: Vec) -> (i32, String, String) { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + common::run_with_env(root, &args, &[("SOCKET_TELEMETRY_DISABLED", "1")]) +} + +fn get_args(server: &MockServer, mode: Mode, action: Action) -> Vec { + let mut args = vec!["get".into(), CVE.into()]; + args.extend(api_args(server)); + args.extend(["--mode".into(), mode.name().into(), "--yes".into()]); + match action { + Action::Real => {} + Action::DryRun => args.push("--dry-run".into()), + Action::SaveOnly => args.push("--save-only".into()), + } + args +} + +async fn stage_state( + root: &std::path::Path, + server: &MockServer, + mode: Mode, + command: CommandKind, +) { + let (code, _, stderr) = run_argv(root, get_args(server, mode, Action::Real)); + assert_eq!(code, 0, "staging {}: {stderr}", mode.name()); + if matches!(command, CommandKind::Apply | CommandKind::List) && matches!(mode, Mode::Hosted) { + seed_manifest_with(root, PURL, UUID); + let path = root.join(".socket/manifest.json"); + let mut manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let record = &mut manifest["patches"][PURL]; + record["requiresOptIn"] = serde_json::json!(true); + record["optInNote"] = serde_json::json!(NOTE); + record["files"] = view_json( + UUID, + PURL, + serde_json::json!({ + "package/index.js": { + "beforeHash": git_hash(BEFORE_BYTES), + "afterHash": git_hash(AFTER_BYTES), + "blobContent": b64(AFTER_BYTES), + } + }), + )["files"] + .clone(); + record["exportedAt"] = serde_json::json!("2024-01-01T00:00:00Z"); + std::fs::write(path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap(); + let socket = root.join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("blobs").join(git_hash(AFTER_BYTES)), + AFTER_BYTES, + ) + .unwrap(); + } +} + +#[test] +fn opt_in_output_matrix() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + for command in [ + CommandKind::Get, + CommandKind::Scan, + CommandKind::Apply, + CommandKind::List, + ] { + for mode in [Mode::Agent, Mode::Hosted, Mode::Vendored] { + for action in [Action::Real, Action::DryRun, Action::SaveOnly] { + runtime.block_on(run_case(command, mode, action)); + } + } + } +} + +async fn run_case(command: CommandKind, mode: Mode, action: Action) { + let valid = !matches!(action, Action::SaveOnly) + || matches!((command, mode), (CommandKind::Get, Mode::Agent)); + let label = format!("{}:{}:{}", command.name(), mode.name(), action.name()); + let server = MockServer::start().await; + mount_api(&server).await; + let tmp = tempfile::tempdir().unwrap(); + write_project(tmp.path()); + let mut args = match command { + CommandKind::Get => get_args(&server, mode, action), + CommandKind::Scan => { + let mut args = vec!["scan".into()]; + args.extend(api_args(&server)); + args.extend(["--mode".into(), mode.name().into()]); + args + } + CommandKind::Apply => vec!["apply".into()], + CommandKind::List => vec!["list".into()], + }; + if !matches!(command, CommandKind::Get) { + if matches!(action, Action::DryRun) { + args.push("--dry-run".into()); + } else if matches!(action, Action::SaveOnly) { + args.push("--save-only".into()); + } + } + + let (code, stdout, stderr) = if matches!(command, CommandKind::Apply | CommandKind::List) { + stage_state(tmp.path(), &server, mode, command).await; + server.reset().await; + mount_api(&server).await; + run_argv(tmp.path(), args) + } else { + run_argv(tmp.path(), args) + }; + if valid { + assert_eq!(code, 0, "{label}: stdout={stdout}\nstderr={stderr}"); + assert_eq!(stdout.matches(NOTE).count(), 1, "{label}: {stdout}"); + assert_eq!(stderr.matches(NOTE).count(), 0, "{label}: {stderr}"); + } else { + assert_ne!(code, 0, "{label}"); + assert!(!stdout.contains(NOTE) && !stderr.contains(NOTE), "{label}"); + assert!( + received_paths(&server).await.is_empty(), + "{label} must not fetch patch metadata" + ); + } +} From 5c2b1aa5c51437ca04fb30bd2a3eea8ddc62b528 Mon Sep 17 00:00:00 2001 From: wenxin-jiang Date: Sat, 14 Nov 2026 21:36:24 -0500 Subject: [PATCH 7/7] Carry opt-in fields through check output Register manifest and detached vendor metadata for both check JSON envelopes, including already-patched and drift events. Represent detached vendor state in manifest-free JSON without changing unrelated empty applies. Keep human output deduplicated by UUID and note, and leave direct UUID dry-run rendering to its preview layer. Extend the output matrix with human and JSON assertions for every mode and operation. Assisted-by: Claude Code:kimi-k3p1-pro --- crates/socket-patch-cli/src/commands/apply.rs | 97 +++++++++++++------ crates/socket-patch-cli/src/commands/get.rs | 2 +- .../tests/opt_in_output_matrix.rs | 57 +++++++++++ 3 files changed, 128 insertions(+), 28 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs index 26c16329b..35d6ca523 100644 --- a/crates/socket-patch-cli/src/commands/apply.rs +++ b/crates/socket-patch-cli/src/commands/apply.rs @@ -422,6 +422,9 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { Err(e) => return refuse_unreadable_manifest(args, manifest_path, &e), }; + let mut opt_ins = opt_in_patches(&manifest); + opt_ins.extend(vendor_opt_in_patches(&args.common).await); + // (purl_or_name, reason_code, detail) for each drift. let mut drifts: Vec<(String, String, String)> = Vec::new(); let mut checked: usize = 0; @@ -604,16 +607,14 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } if !args.common.json && !args.common.silent { - if let Some(notes) = - crate::ui::opt_in_note_block(manifest.patches.iter().map(|(purl, record)| { - crate::ui::OptInEntry { - purl, - uuid: &record.uuid, - requires_opt_in: record.requires_opt_in, - opt_in_note: record.opt_in_note.as_deref(), - } - })) - { + if let Some(notes) = crate::ui::opt_in_note_block(opt_ins.iter().map( + |(purl, uuid, requires_opt_in, opt_in_note)| crate::ui::OptInEntry { + purl, + uuid, + requires_opt_in: *requires_opt_in, + opt_in_note: opt_in_note.as_deref(), + }, + )) { println!("\n{notes}"); } } @@ -621,6 +622,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { if drifts.is_empty() { if args.common.json { let mut env = Envelope::new(Command::Apply); + register_opt_ins(&mut env, &opt_ins); record_check_skips(&mut env, &in_sync, ¬_installed); for event in unchecked_events { env.record(event); @@ -637,6 +639,7 @@ async fn run_check(args: &ApplyArgs, manifest_path: &Path) -> i32 { } else { if args.common.json { let mut env = Envelope::new(Command::Apply); + register_opt_ins(&mut env, &opt_ins); for (id, code, detail) in &drifts { env.record( PatchEvent::new(PatchAction::Failed, id.clone()) @@ -1305,6 +1308,21 @@ pub async fn run(args: ApplyArgs) -> i32 { env.status = Status::NoManifest; env.dry_run = args.common.dry_run; env.warnings.extend(below.warnings()); + let vendor_patches = vendor_opt_in_patches(&args.common).await; + for (purl, uuid, requires_opt_in, opt_in_note) in &vendor_patches { + if requires_opt_in != &Some(true) && opt_in_note.is_none() { + continue; + } + env.register_opt_in(purl, uuid, *requires_opt_in, opt_in_note.as_deref()); + env.record( + PatchEvent::new(PatchAction::Skipped, purl.clone()) + .with_uuid(uuid.clone()) + .with_reason( + "already_vendored", + "vendored patch is recorded in .socket/vendor/state.json", + ), + ); + } match &vex_result { Some(ManifestlessVex::Written(summary)) => { env.vex = Some(VexSummary { @@ -1519,21 +1537,19 @@ fn collect_apply_outcomes( } async fn print_vendor_opt_in_notes(common: &GlobalArgs) { - if common.json || common.silent || common.is_global() { + if common.json || common.silent { return; } - let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await else { - return; - }; - let entries = state.entries.values().filter_map(|entry| { - let record = entry.record.as_ref()?; - Some(crate::ui::OptInEntry { - purl: &entry.base_purl, - uuid: &record.uuid, - requires_opt_in: record.requires_opt_in, - opt_in_note: record.opt_in_note.as_deref(), - }) - }); + let patches = vendor_opt_in_patches(common).await; + let entries = + patches.iter().map( + |(purl, uuid, requires_opt_in, opt_in_note)| crate::ui::OptInEntry { + purl, + uuid, + requires_opt_in: *requires_opt_in, + opt_in_note: opt_in_note.as_deref(), + }, + ); if let Some(notes) = crate::ui::opt_in_note_block(entries) { println!("\n{notes}"); } @@ -1556,6 +1572,34 @@ fn opt_in_patches(manifest: &PatchManifest) -> OptInPatches { .collect() } +async fn vendor_opt_in_patches(common: &GlobalArgs) -> OptInPatches { + if common.is_global() { + return Vec::new(); + } + let Ok(state) = socket_patch_core::vendor::load_state(&common.cwd).await else { + return Vec::new(); + }; + state + .entries + .into_iter() + .filter_map(|(purl, entry)| { + let record = entry.record?; + Some(( + purl, + record.uuid, + record.requires_opt_in, + record.opt_in_note, + )) + }) + .collect() +} + +fn register_opt_ins(env: &mut Envelope, patches: &OptInPatches) { + for (purl, uuid, requires_opt_in, opt_in_note) in patches { + env.register_opt_in(purl, uuid, *requires_opt_in, opt_in_note.as_deref()); + } +} + /// The locked half of `apply`: everything from the manifest read on — the /// package-manager layout gate, the apply loop, embedded VEX, output and /// telemetry — over a `lock` the caller already holds and the caller's @@ -1612,7 +1656,8 @@ pub(crate) async fn run_locked( // Packages the project patched itself sit this run out (warned); every // other record still applies. The read-only `--check` verifies them all. - let opt_ins = opt_in_patches(&manifest); + let mut opt_ins = opt_in_patches(&manifest); + opt_ins.extend(vendor_opt_in_patches(&args.common).await); let mut manifest = manifest; let (user_patch_warnings, user_patch_skips) = if args.check { Default::default() @@ -1937,9 +1982,7 @@ pub(crate) async fn run_locked( if args.common.json { let mut env = Envelope::new(Command::Apply); env.dry_run = args.common.dry_run; - for (purl, uuid, requires_opt_in, opt_in_note) in &opt_ins { - env.register_opt_in(purl, uuid, *requires_opt_in, opt_in_note.as_deref()); - } + register_opt_ins(&mut env, &opt_ins); for skip in &user_patch_skips { env.record(skip.clone()); } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index f30404d8c..8ce7f245b 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1356,7 +1356,7 @@ pub async fn run(args: GetArgs) -> i32 { .await } }; - if !quiet && mode == super::scan::ScanMode::Agent { + if !quiet && !args.common.dry_run && mode == super::scan::ScanMode::Agent { if let Some(notes) = crate::ui::opt_in_note_block([crate::ui::OptInEntry::from_response(&patch)]) { diff --git a/crates/socket-patch-cli/tests/opt_in_output_matrix.rs b/crates/socket-patch-cli/tests/opt_in_output_matrix.rs index 076cd8bb6..34d63f9da 100644 --- a/crates/socket-patch-cli/tests/opt_in_output_matrix.rs +++ b/crates/socket-patch-cli/tests/opt_in_output_matrix.rs @@ -33,6 +33,7 @@ enum Action { Real, DryRun, SaveOnly, + Check, } impl CommandKind { @@ -62,6 +63,7 @@ impl Action { Self::Real => "real", Self::DryRun => "dry-run", Self::SaveOnly => "save-only", + Self::Check => "check", } } } @@ -165,6 +167,7 @@ fn get_args(server: &MockServer, mode: Mode, action: Action) -> Vec { Action::Real => {} Action::DryRun => args.push("--dry-run".into()), Action::SaveOnly => args.push("--save-only".into()), + Action::Check => args.push("--check".into()), } args } @@ -206,6 +209,11 @@ async fn stage_state( AFTER_BYTES, ) .unwrap(); + std::fs::write( + root.join("node_modules").join(NAME).join("index.js"), + AFTER_BYTES, + ) + .unwrap(); } } @@ -222,10 +230,26 @@ fn opt_in_output_matrix() { for action in [Action::Real, Action::DryRun, Action::SaveOnly] { runtime.block_on(run_case(command, mode, action)); } + if matches!(command, CommandKind::Apply) { + runtime.block_on(run_case(command, mode, Action::Check)); + } } } } +fn assert_json_opt_in(stdout: &str, stderr: &str, label: &str) { + let doc: serde_json::Value = serde_json::from_str(stdout) + .unwrap_or_else(|e| panic!("{label}: JSON {e}; stdout={stdout}\nstderr={stderr}")); + let event = doc["events"] + .as_array() + .unwrap() + .iter() + .find(|event| event["uuid"] == UUID || event["purl"] == PURL) + .unwrap_or_else(|| panic!("{label}: no matching event in {doc}")); + assert_eq!(event["details"]["requiresOptIn"], true, "{label}: {event}"); + assert_eq!(event["details"]["optInNote"], NOTE, "{label}: {event}"); +} + async fn run_case(command: CommandKind, mode: Mode, action: Action) { let valid = !matches!(action, Action::SaveOnly) || matches!((command, mode), (CommandKind::Get, Mode::Agent)); @@ -252,7 +276,12 @@ async fn run_case(command: CommandKind, mode: Mode, action: Action) { args.push("--save-only".into()); } } + if matches!(command, CommandKind::Apply) && matches!(action, Action::Check) { + args.push("--check".into()); + } + let mut json_args = args.clone(); + json_args.push("--json".into()); let (code, stdout, stderr) = if matches!(command, CommandKind::Apply | CommandKind::List) { stage_state(tmp.path(), &server, mode, command).await; server.reset().await; @@ -273,4 +302,32 @@ async fn run_case(command: CommandKind, mode: Mode, action: Action) { "{label} must not fetch patch metadata" ); } + + if matches!(action, Action::Check) && matches!(mode, Mode::Agent) { + std::fs::write( + tmp.path().join("node_modules").join(NAME).join("index.js"), + BEFORE_BYTES, + ) + .unwrap(); + } + let (code, json, stderr) = run_argv(tmp.path(), json_args); + if valid { + let expected_code = if matches!(action, Action::Check) && matches!(mode, Mode::Agent) { + 1 + } else { + 0 + }; + assert_eq!( + code, expected_code, + "{label} JSON: stdout={json}\nstderr={stderr}" + ); + assert_json_opt_in(&json, &stderr, &label); + } else { + assert_ne!(code, 0, "{label} JSON: stdout={json}\nstderr={stderr}"); + assert!(!json.contains(NOTE), "{label}: {json}"); + assert!( + received_paths(&server).await.is_empty(), + "{label} JSON must not fetch patch metadata" + ); + } }