diff --git a/crates/socket-patch-cli/src/commands/agent_download.rs b/crates/socket-patch-cli/src/commands/agent_download.rs index dab40dc10..ecebf22ca 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}; @@ -22,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; @@ -148,6 +150,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()), @@ -356,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 @@ -695,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)>, @@ -843,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>, @@ -906,6 +917,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, @@ -962,8 +974,40 @@ 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() { + // retains its artifact and wiring; its view still governs activation metadata. + if let Some(mut record) = detached_ledger_record(store, purl, uuid).cloned() { + 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); + } if !quiet { eprintln!("{}", format_record_skip(purl, "already vendored")); } @@ -1033,13 +1077,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; @@ -1140,6 +1186,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, @@ -1155,6 +1202,23 @@ 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(), + ); + } else { + env.register_opt_in( + purl, + &record.uuid, + record.requires_opt_in, + record.opt_in_note.as_deref(), + ); + } + } for event in self.events { env.record(event); } @@ -1238,11 +1302,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), @@ -1399,6 +1470,7 @@ pub(crate) async fn run_nested_apply( common: GlobalArgs, json: bool, report_user_patch_skips: bool, + shown_opt_ins: Vec, recorded: &[(String, String)], client: &ApiClient, lock: LockGuard, @@ -1413,6 +1485,7 @@ pub(crate) async fn run_nested_apply( nested: Some(super::apply::NestedApply { caller_json: json, report_user_patch_skips, + shown_opt_ins, }), }; // The nested apply runs on the host's client: seeded, never rebuilt. @@ -1707,6 +1780,29 @@ 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) { + metadata_changed |= refresh_opt_in_metadata(record, patch); + } + } let downloaded = batch.fetched.len(); let mut updated = 0usize; let mut new_blobs: Vec = Vec::new(); @@ -1742,7 +1838,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). @@ -1798,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 { @@ -1813,6 +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, + shown_opt_ins, &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 197ec080e..35d6ca523 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_ins: 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) } } @@ -421,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; @@ -602,9 +606,23 @@ 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(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}"); + } + } + 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); @@ -621,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()) @@ -1289,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 { @@ -1332,6 +1366,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(_)))); } @@ -1501,6 +1536,70 @@ fn collect_apply_outcomes( (applied, skipped) } +async fn print_vendor_opt_in_notes(common: &GlobalArgs) { + if common.json || common.silent { + return; + } + 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}"); + } +} + +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() +} + +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 @@ -1557,6 +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 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() @@ -1830,6 +1931,15 @@ pub(crate) async fn run_locked( for line in block { println!("{line}"); } + let shown_opt_ins = args + .nested + .as_ref() + .map_or(&[][..], |nested| nested.shown_opt_ins.as_slice()); + if let Some(notes) = + format_opt_in_notes(&opt_ins, &results, &unmatched, shown_opt_ins) + { + println!("\n{notes}"); + } if args.common.verbose && !results.is_empty() { print_verbose_verification(&results); } @@ -1872,6 +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; + register_opt_ins(&mut env, &opt_ins); for skip in &user_patch_skips { env.record(skip.clone()); } @@ -2367,6 +2478,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], + shown_opt_ins: &[crate::ui::ShownOptIn], +) -> Option { + let shown: HashSet<&str> = results + .iter() + .map(|result| result.package_key.as_str()) + .chain(unmatched.iter().map(String::as_str)) + .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_ins + .iter() + .any(|shown| shown.matches(uuid, *requires_opt_in, opt_in_note.as_deref())) + { + return None; + } + 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 /// 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..8ce7f245b 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}; @@ -1332,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 @@ -1354,6 +1356,14 @@ pub async fn run(args: GetArgs) -> i32 { .await } }; + 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)]) + { + 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 @@ -1802,6 +1812,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 +1915,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); @@ -1913,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 { @@ -1977,6 +1994,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); } @@ -2129,6 +2159,7 @@ async fn save_and_apply_patch( nested_apply_args(&args.common, &manifest_path, quiet), args.common.json, true, + vec![crate::ui::ShownOptIn::from_response(patch)], &recorded, client, lock, @@ -2159,6 +2190,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 +2243,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(), + ); } } @@ -2229,6 +2279,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(), + offline: args.common.offline, } } @@ -2254,6 +2305,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 }); @@ -2261,7 +2313,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, @@ -2273,7 +2325,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 @@ -2305,6 +2365,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. @@ -2315,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; } @@ -2403,7 +2469,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(), @@ -2444,7 +2510,8 @@ async fn run_get_vendored( } 1 } - } + }; + code } #[cfg(test)] @@ -2568,6 +2635,8 @@ mod tests { license: "MIT".into(), tier: tier.into(), vulnerabilities: HashMap::::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -2899,6 +2968,8 @@ mod tests { description: String::new(), license: String::new(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); m @@ -3080,6 +3151,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 +3183,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 +3226,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 +3250,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 +3827,8 @@ mod tests { description: "desc".into(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, } } @@ -4017,6 +4098,7 @@ mod tests { ecosystems: None, persist_blobs: false, patch_server_url: None, + offline: false, } } @@ -4717,6 +4799,7 @@ mod tests { // The vendor-detached posture this fn exists for. persist_blobs: false, patch_server_url: None, + offline: false, } } @@ -5465,6 +5548,8 @@ mod tests { "description": "embedded", "license": "MIT", "tier": "free", + "requiresOptIn": true, + "optInNote": "Keep the embedded instruction offline", } }} })) @@ -5476,6 +5561,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}"); @@ -5487,6 +5573,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() @@ -6084,9 +6175,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)) @@ -6094,6 +6185,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"))), @@ -6177,8 +6274,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/list.rs b/crates/socket-patch-cli/src/commands/list.rs index b0c9c2961..93e016c1b 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 @@ -271,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)); @@ -290,6 +298,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 show_opt_in && 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)); @@ -340,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 } @@ -667,6 +695,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 +742,8 @@ mod tests { description: "desc".to_string(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } @@ -1219,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/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..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 { @@ -3466,6 +3480,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..b9e2ba8f4 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(), + offline: args.common.offline, } } @@ -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_preview( + 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 && (report_only || args.common.dry_run) { + if let Some(notes) = ui::opt_in_preview( + 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 @@ -3995,6 +4036,8 @@ mod tests { description: String::new(), license: String::new(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, }, ); } @@ -4144,6 +4187,8 @@ mod tests { description: String::new(), license: "MIT".to_string(), tier: "free".to_string(), + requires_opt_in: None, + opt_in_note: None, } } @@ -4410,6 +4455,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..4f550ee65 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -745,6 +745,8 @@ mod tests { .iter() .map(|(k, v)| (k.to_string(), v.clone())) .collect(), + requires_opt_in: None, + opt_in_note: None, } } 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..0bf26c69f 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 @@ -580,6 +581,27 @@ 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)), + )); + } + 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(); @@ -594,6 +616,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 @@ -1201,6 +1258,8 @@ mod migration_tests { description: "fixture".into(), license: "MIT".into(), tier: "free".into(), + requires_opt_in: None, + opt_in_note: None, } } @@ -1588,6 +1647,8 @@ mod preview_tests { license: "MIT".into(), tier: "free".into(), vulnerabilities: HashMap::new(), + requires_opt_in: None, + opt_in_note: None, } } @@ -2216,6 +2277,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..d280c2941 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,121 @@ 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; + } + 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), + }; + 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 { + if details.get("requiresOptIn").is_none() { + details["requiresOptIn"] = serde_json::json!(requires_opt_in); + } + } + if let Some(opt_in_note) = &metadata.opt_in_note { + if details.get("optInNote").is_none() { + details["optInNote"] = serde_json::json!(opt_in_note); + } + } + 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 { + self.add_registered_opt_in(event); } + self.events = events; } /// Set a command-specific top-level key (see [`Envelope::extra`]). @@ -229,7 +356,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..8b25adfd9 100644 --- a/crates/socket-patch-cli/src/ui/mod.rs +++ b/crates/socket-patch-cli/src/ui/mod.rs @@ -23,9 +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, plural, - sentence_case, shell_word, short_uuid, sweep_failure, truncate, + agent_next_steps, manifest_error_message, next_steps, next_steps_installed, opt_in_note_block, + 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 9453fa173..100e69679 100644 --- a/crates/socket-patch-cli/src/ui/text.rs +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -248,6 +248,120 @@ 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() +} + +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, + 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 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)] +pub struct ShownOptIn { + uuid: String, + requires_opt_in: Option, + opt_in_note: Option, +} + +impl ShownOptIn { + pub(crate) 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_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_preview<'a>( + entries: impl IntoIterator>, +) -> Option { + opt_in_note_block(entries) +} + +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.to_string(), note.clone())) { + continue; + } + let note = note.replace('\n', "\n "); + 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}")); + } + 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 @@ -283,6 +397,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/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..985470ae6 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, } } @@ -298,6 +300,7 @@ pub fn engine_params(root: &Path) -> DownloadParams { ecosystems: None, persist_blobs: true, patch_server_url: None, + 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 4ab6f761b..56fb66a47 100644 --- a/crates/socket-patch-cli/tests/get/get_render.rs +++ b/crates/socket-patch-cli/tests/get/get_render.rs @@ -354,6 +354,267 @@ 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:\n pkg:npm/covgap-pkg@1.0.0: 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:\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'), + "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); +} + +#[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 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) = + 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 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; + 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), + "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}" + ); + 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>) { + 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() { @@ -645,6 +906,129 @@ async fn human_vendored_uuid_rerun_prints_already_vendored_skip() { assert!(artifact.is_file(), "stdout={stdout}\nstderr={stderr}"); } +#[tokio::test] +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!({ + "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; + 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(), + &[ + "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}"); + 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/in_process_get_update_count.rs b/crates/socket-patch-cli/tests/in_process_get_update_count.rs index 82dd0c7dd..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 @@ -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, } } @@ -96,6 +98,7 @@ fn params(root: &Path) -> DownloadParams { ecosystems: None, persist_blobs: true, patch_server_url: None, + offline: false, // Skip release-narrowing; npm has no variants anyway. all_releases: true, } 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/opt_in_output_matrix.rs b/crates/socket-patch-cli/tests/opt_in_output_matrix.rs new file mode 100644 index 000000000..34d63f9da --- /dev/null +++ b/crates/socket-patch-cli/tests/opt_in_output_matrix.rs @@ -0,0 +1,333 @@ +#[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, + Check, +} + +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", + Self::Check => "check", + } + } +} + +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()), + Action::Check => args.push("--check".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(); + std::fs::write( + root.join("node_modules").join(NAME).join("index.js"), + 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)); + } + 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)); + 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()); + } + } + 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; + 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" + ); + } + + 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" + ); + } +} 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..52732db6b 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,99 @@ 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 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; + 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-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..c28db9af4 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 @@ -254,6 +256,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 +280,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, }, ); @@ -821,6 +827,10 @@ mod tests { exclude: vec!["packages/a".to_string()], manual: Vec::new(), }); + 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() diff --git a/crates/socket-patch-core/src/manifest/records.rs b/crates/socket-patch-core/src/manifest/records.rs index 7032d435c..6ade2bc45 100644 --- a/crates/socket-patch-core/src/manifest/records.rs +++ b/crates/socket-patch-core/src/manifest/records.rs @@ -44,9 +44,20 @@ 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(), } } +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 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, } }