From d4b2f0521caeca6a84200be6996d985beb1476e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 04:58:13 +0000 Subject: [PATCH 1/4] Start sweep of scan/discovery.rs Assisted-by: Claude Code:claude-opus-5-5 From 251a97ba2375957569756dbf1e5f1dfe8ee7f509 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:12:16 +0000 Subject: [PATCH 2/4] Test shell_word, supersedes and layout in place Move the tests for ui::shell_word, ranking::batch_supersedes and lock_inventory::unsupported_layout_warnings next to the code they check. scan/discovery.rs tested them through wrappers and re-exports that the next commit deletes. The shell_word test now covers the Windows quoting and `%`. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/ui/text.rs | 19 +++++++++++ crates/socket-patch-core/src/api/ranking.rs | 32 +++++++++++++++++++ .../src/vendor/lock_inventory/tests.rs | 26 +++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/crates/socket-patch-cli/src/ui/text.rs b/crates/socket-patch-cli/src/ui/text.rs index a983a1464..6c39a9ed6 100644 --- a/crates/socket-patch-cli/src/ui/text.rs +++ b/crates/socket-patch-cli/src/ui/text.rs @@ -422,4 +422,23 @@ mod tests { ] ); } + + #[test] + fn shell_word_quotes_only_what_the_shell_would_split() { + assert_eq!(shell_word("tools/x"), "tools/x"); + assert_eq!(shell_word("/repo/@scope/x-1.0"), "/repo/@scope/x-1.0"); + if cfg!(windows) { + assert_eq!(shell_word(r"C:\repo\x"), r"C:\repo\x"); + assert_eq!(shell_word("tools/my x"), "\"tools/my x\""); + assert_eq!(shell_word("100%"), "\"100%\""); + assert_eq!(shell_word(r"my dir\"), r#""my dir\\""#); + assert_eq!(shell_word(""), "\"\""); + } else { + assert_eq!(shell_word("tools/my x"), "'tools/my x'"); + assert_eq!(shell_word("100%"), "'100%'"); + assert_eq!(shell_word("it's"), r"'it'\''s'"); + assert_eq!(shell_word("$HOME"), "'$HOME'"); + assert_eq!(shell_word(""), "''"); + } + } } diff --git a/crates/socket-patch-core/src/api/ranking.rs b/crates/socket-patch-core/src/api/ranking.rs index 58ca27078..1652d9d25 100644 --- a/crates/socket-patch-core/src/api/ranking.rs +++ b/crates/socket-patch-core/src/api/ranking.rs @@ -983,4 +983,36 @@ mod tests { assert_eq!(ids(&first), ids(&second)); assert_eq!(ids(&first), ["u0", "u1", "u2", "u3"]); } + + #[test] + fn batch_supersedes_on_advisory_count_at_equal_severity() { + // No dates: only the severity and advisory-count rungs can decide. + let single = batch("a_single", "free", None, Some("high")); + let merged = batch_multi("z_merged", "free", None, Some("high"), 2); + let larger = batch_multi("z_larger", "free", None, Some("high"), 3); + assert!(batch_supersedes(&merged, &single)); + assert!(!batch_supersedes(&single, &merged)); + assert!(batch_supersedes(&larger, &merged)); + assert!(!batch_supersedes(&merged, &larger)); + } + + #[test] + fn batch_supersedes_on_severity_before_advisory_count() { + let merged = batch_multi("a_merged", "free", None, Some("low"), 2); + let critical = batch("z_critical", "free", None, Some("critical")); + assert!(batch_supersedes(&critical, &merged)); + assert!(!batch_supersedes(&merged, &critical)); + } + + #[test] + fn batch_supersedes_ignores_cve_aliases_beside_a_ghsa() { + // Both sides name one GHSA, so their CVE lists are aliases: neither + // is merged, and without two real dates neither supersedes. + let mut candidate = batch("a_cand", "free", None, Some("high")); + candidate.cve_ids = vec!["CVE-1".into(), "CVE-2".into()]; + let mut applied = batch("z_appl", "free", None, Some("high")); + applied.cve_ids = vec!["CVE-3".into()]; + assert!(!batch_supersedes(&candidate, &applied)); + assert!(!batch_supersedes(&applied, &candidate)); + } } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs index 8cb8e5425..bd14058d0 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/tests.rs @@ -3708,3 +3708,29 @@ async fn lookup_matches_by_purl_identity() { assert!(lookup(&entries, "pkg:composer/psr/log@3.0.3").is_none()); assert!(lookup(&entries, "pkg:pypi/typing_extensions@4.12.2").is_some()); } + +#[test] +fn unsupported_layout_warnings_rewrites_known_codes_and_forwards_unknown_ones() { + let layout = |code: &'static str| UnsupportedNpmLayout { + code, + detail: "probe detail text".to_string(), + }; + let warnings = unsupported_layout_warnings(&[ + layout("vendor_yarn_berry_unsupported"), + layout("vendor_pnpm_pnp_unsupported"), + layout("vendor_future_layout_unsupported"), + ]); + let codes: Vec<&str> = warnings.iter().map(|(code, _)| code.as_str()).collect(); + assert_eq!( + codes, + [ + "yarn_pnp_unsupported", + "pnpm_pnp_unsupported", + "vendor_future_layout_unsupported" + ] + ); + // Known codes get scan's phrasing; an unknown one keeps the probe's. + assert_ne!(warnings[0].1, "probe detail text"); + assert_ne!(warnings[1].1, "probe detail text"); + assert_eq!(warnings[2].1, "probe detail text"); +} From afedf6c870bab9a8c03f23cdb0772819747160b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:12:16 +0000 Subject: [PATCH 3/4] Sweep scan/discovery.rs Drop the re-exports of unsupported_layout_warnings and merge_ledger_records_for_updates; scan and get import them from core. Inline candidate_supersedes and the severity_order wrapper into their callers, and delete the 5 severity_order tests core's ranking tests already cover. The nested-project remedy now quotes its PATH with ui::shell_word, so on Windows it is double-quoted and a `%` is quoted everywhere (#1859). Trim restating doc comments and test banners, and fold the trailing collect_vuln_ids dedup test into its siblings. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/get.rs | 3 +- .../src/commands/scan/discovery.rs | 266 ++---------------- .../socket-patch-cli/src/commands/scan/mod.rs | 14 +- .../src/commands/scan/render.rs | 8 +- 4 files changed, 37 insertions(+), 254 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index 2d242dcf8..547c38571 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -908,7 +908,8 @@ async fn filter_to_installed_purls( } } - let warnings = super::scan::unsupported_layout_warnings(&pnp_diags); + let warnings = + socket_patch_core::vendor::lock_inventory::unsupported_layout_warnings(&pnp_diags); let pnp_yarn = pnp_diags .iter() .any(|d| d.code == "vendor_yarn_berry_unsupported"); diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index e9f4498eb..2c32f434a 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -1,13 +1,11 @@ //! Discovery-side helpers for `scan`: lockfile / vendored-ledger crawl //! supplements, update detection against the existing manifest, vendor -//! baseline pre-verification, and the table's vuln-ID / severity helpers. +//! baseline pre-verification, and the table's vuln-ID helper. use futures_util::StreamExt; use socket_patch_core::api::client::hold_back_debug; -use socket_patch_core::api::ranking::cmp_batch_infos; -use socket_patch_core::api::types::{ - BatchPackagePatches, BatchPatchInfo, PatchResponse, PatchSearchResult, -}; +use socket_patch_core::api::ranking::{batch_supersedes, cmp_batch_infos}; +use socket_patch_core::api::types::{BatchPackagePatches, PatchResponse, PatchSearchResult}; use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; use socket_patch_core::utils::purl::{normalize_purl, strip_purl_qualifiers}; @@ -18,9 +16,8 @@ use std::collections::{HashMap, HashSet}; use crate::args::GlobalArgs; -/// Surfaced in `scan --json` output. Tells a bot which PURLs in the discovery -/// would replace an existing manifest entry with a newer UUID. Stable schema — -/// see CLI_CONTRACT.md (`scan` JSON output / `updates` field). +/// A PURL whose newest patch would replace the recorded one: the `scan +/// --json` `updates` field (CLI_CONTRACT.md). #[derive(Debug, PartialEq, Eq, Clone)] pub(super) struct UpdateInfo { pub(super) purl: String, @@ -46,8 +43,6 @@ pub(crate) struct LockfileSupplement { pub(crate) unsupported: Vec, } -pub(crate) use socket_patch_core::vendor::lock_inventory::unsupported_layout_warnings; - /// Inventory the project's lockfile(s) and fabricate crawl entries for /// dependencies that are not installed. The fabricated `path` is the /// WOULD-BE install dir — every consumer degrades safely on a nonexistent @@ -426,12 +421,7 @@ pub(super) async fn preverify_vendor_baselines( (mismatched, views) } -pub(super) use socket_patch_core::ledgers::merge_ledger_records_for_updates; - -/// Cross-reference an existing manifest against discovery results to find -/// PURLs whose newest available patch UUID differs from the locally-recorded -/// one. Used by both the discovery JSON path and the table-print path. -/// Pure / no I/O so it's unit-testable. +/// The PURLs whose newest available patch UUID differs from the recorded one. pub(super) fn detect_updates( existing_manifest: Option<&PatchManifest>, packages: &[BatchPackagePatches], @@ -491,7 +481,7 @@ pub(super) fn detect_updates( // still offered, only report a candidate that genuinely supersedes // it. If it is no longer offered, any different candidate is flagged. if let Some(applied) = pkg.patches.iter().find(|p| p.uuid == existing.uuid) { - if !candidate_supersedes(candidate, applied) { + if !batch_supersedes(candidate, applied) { continue; } } @@ -504,13 +494,6 @@ pub(super) fn detect_updates( updates } -/// Whether `candidate` genuinely supersedes the applied patch (see -/// [`socket_patch_core::api::ranking::batch_supersedes`]): the guard that -/// keeps an equal sibling from showing as a perpetual `[UPDATE]`. -fn candidate_supersedes(candidate: &BatchPatchInfo, applied: &BatchPatchInfo) -> bool { - socket_patch_core::api::ranking::batch_supersedes(candidate, applied) -} - /// The scan table's VULNERABILITIES data for one package, built from the /// batch results (see [`collect_vuln_ids`]). #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -525,11 +508,8 @@ pub(super) struct VulnIds { pub count: usize, } -/// Collect a package's vulnerability ids across all its patches, for the -/// scan table's VULNERABILITIES column. The output is sorted and deduped, -/// so the rendered table is stable (the per-patch lists and set-based -/// dedup are otherwise nondeterministic in order). Pure / no I/O so it's -/// unit-testable. +/// A package's vulnerability ids across all its patches, sorted and +/// deduped so the rendered table is stable. /// /// A GHSA id is usually an alias of a CVE listed beside it, but the batch /// endpoint gives the two lists without their pairing, so listing both made @@ -570,13 +550,6 @@ pub(super) fn collect_vuln_ids(pkg: &BatchPackagePatches) -> VulnIds { } } -/// Severity ordering for the scan table's SEVERITY column: lower = worse. -/// Delegates to the workspace-wide ladder so the table, the selector and -/// the API client can never disagree about what `moderate` means. -pub(super) fn severity_order(s: &str) -> u8 { - socket_patch_core::api::ranking::severity_order(Some(s)) -} - /// Warning code: a hosted or vendored scan left patched npm packages that /// only a nested, independent npm project installs (#1534). pub(crate) const NESTED_PROJECT_SKIPPED: &str = "nested_project_skipped"; @@ -702,7 +675,7 @@ pub(crate) async fn nested_project_skips( } else { ("are", "they are") }; - let word = shell_word(&dir); + let word = crate::ui::shell_word(&dir); // #1554: a project installed without a lockfile has none for a // hosted or vendored run of its own to rewire either. let detail = if locked { @@ -730,41 +703,15 @@ pub(crate) async fn nested_project_skips( (skipped, warnings) } -/// `word` as one POSIX shell word: single-quoted unless it is plain. -fn shell_word(word: &str) -> String { - let plain = !word.is_empty() - && word - .chars() - .all(|c| c.is_ascii_alphanumeric() || "_./-:@%+=,".contains(c)); - if plain { - word.to_string() - } else { - format!("'{}'", word.replace('\'', r"'\''")) - } -} - #[cfg(test)] mod tests { use super::*; - - /// #1534: the PATH in the `nested_project_skipped` remedy survives a - /// copy-paste into a POSIX shell. - #[test] - fn shell_word_quotes_only_what_the_shell_would_split() { - assert_eq!(shell_word("tools/x"), "tools/x"); - assert_eq!(shell_word("/repo/@scope/x-1.0"), "/repo/@scope/x-1.0"); - assert_eq!(shell_word("tools/my x"), "'tools/my x'"); - assert_eq!(shell_word("it's"), r"'it'\''s'"); - assert_eq!(shell_word("$HOME"), "'$HOME'"); - assert_eq!(shell_word(""), "''"); - } use socket_patch_core::api::types::BatchPatchInfo; + use socket_patch_core::ledgers::merge_ledger_records_for_updates; use std::borrow::Cow; use crate::commands::scan::tests::manifest_with; - // ---- lockfile_only_packages -------------------------------------------- - fn lock_entry(ecosystem: &'static str, purl: &str) -> LockfileEntry { use socket_patch_core::vendor::lock_inventory::{LockIntegrity, SourceKind}; LockfileEntry { @@ -813,45 +760,6 @@ mod tests { } } - // ---- severity_order ---------------------------------------------------- - - #[test] - fn severity_order_critical_is_zero() { - assert_eq!(severity_order("critical"), 0); - } - - #[test] - fn severity_order_is_case_insensitive() { - assert_eq!(severity_order("Critical"), 0); - assert_eq!(severity_order("CRITICAL"), 0); - assert_eq!(severity_order("High"), 1); - } - - #[test] - fn severity_order_known_levels() { - assert_eq!(severity_order("high"), 1); - assert_eq!(severity_order("medium"), 2); - assert_eq!(severity_order("low"), 3); - } - - #[test] - fn severity_order_moderate_is_medium_tier() { - // GHSA emits `moderate` for the medium tier and scan passes raw API - // severities through, so it must rank as medium, not unknown. - assert_eq!(severity_order("moderate"), severity_order("medium")); - assert!(severity_order("moderate") < severity_order("low")); - assert_eq!(severity_order("Moderate"), severity_order("medium")); - } - - #[test] - fn severity_order_unknown_is_four() { - assert_eq!(severity_order("unknown"), 4); - assert_eq!(severity_order(""), 4); - assert_eq!(severity_order("informational"), 4); - } - - // ---- detect_updates ----------------------------------------------------- - fn batch_with(purl: &str, uuids: &[&str]) -> BatchPackagePatches { BatchPackagePatches { purl: purl.to_string(), @@ -1108,7 +1016,6 @@ mod tests { assert_eq!(updates[0].new_uuid, "uuid-new"); } - // ---- merge_ledger_records_for_updates ----------------------------------- // Hosted mode records patches ONLY in the lockfiles (the hosted pins) and // vendored mode ONLY in the vendor ledger — these pin that manifest-less // projects still surface `updates[]` (the documented CI signal) through @@ -1260,7 +1167,6 @@ mod tests { ); } - // ---- vendored_ledger_supplement (corrupt-ledger fallback) --------------- // Vendored purls enter `scanned_purls` via this supplement, which shields // their manifest entries from `scan --prune`. A corrupt ledger must fall // back to the committed artifact dirs rather than return empty. @@ -1686,8 +1592,6 @@ mod tests { ); } - // ---- collect_vuln_ids -------------------------------------------------- - /// Build a single-patch package whose patch carries the given CVE and /// GHSA identifier lists. fn batch_with_vulns(purl: &str, cves: &[&str], ghsas: &[&str]) -> BatchPackagePatches { @@ -1828,41 +1732,22 @@ mod tests { assert_eq!(ids.count, 1); } - // ---- unsupported_layout_warnings ----------------------------------- - #[test] - fn unsupported_layout_warnings_forwards_unknown_codes_verbatim() { - use socket_patch_core::vendor::lock_inventory::UnsupportedNpmLayout; - - // Forward-compat contract: a refusal code this match doesn't know - // yet must surface verbatim — code AND the probe's own detail — - // rather than being swallowed back into silence. - let unknown = UnsupportedNpmLayout { - code: "vendor_future_layout_unsupported", - detail: "probe detail text".to_string(), - }; - assert_eq!( - unsupported_layout_warnings(std::slice::from_ref(&unknown)), - vec![( - "vendor_future_layout_unsupported".to_string(), - "probe detail text".to_string(), - )], - ); - - // Contrast: a KNOWN code is rewritten — renamed to apply's refusal - // errorCode and scan-phrased, not the probe's vendor-phrased text. - let known = UnsupportedNpmLayout { - code: "vendor_yarn_berry_unsupported", - detail: "probe detail text".to_string(), + fn collect_vuln_ids_dedups_across_patches() { + // u2's GHSA is the alias of its one CVE, so it is not counted. + let pkg = BatchPackagePatches { + purl: "pkg:npm/foo@1.0".to_string(), + patches: vec![ + patch_with("u1", &["CVE-2024-1"], &[]), + patch_with("u2", &["CVE-2024-1"], &["GHSA-aaaa-aaaa-aaaa"]), + ], }; - let rewritten = unsupported_layout_warnings(std::slice::from_ref(&known)); - assert_eq!(rewritten.len(), 1); - assert_eq!(rewritten[0].0, "yarn_pnp_unsupported"); - assert_ne!(rewritten[0].1, "probe detail text"); + let ids = collect_vuln_ids(&pkg); + assert_eq!(ids.primary, strs(&["CVE-2024-1"])); + assert_eq!(ids.all, strs(&["CVE-2024-1", "GHSA-aaaa-aaaa-aaaa"])); + assert_eq!(ids.count, 1); } - // ---- candidate_supersedes (advisory-count rung) ------------------------- - /// A batch-shaped patch with explicit advisory lists and NO publish /// date, so only the severity and advisory-count rungs can decide. fn info_with_advisories( @@ -1883,75 +1768,6 @@ mod tests { } } - #[test] - fn candidate_supersedes_on_broader_ghsa_merge_coverage() { - // Same severity, no dates: only the advisory count separates them. - // A merged patch (>= 2 GHSAs) genuinely supersedes a single one. - let merged = info_with_advisories( - "uuid-merged", - Some("high"), - &["GHSA-1111-1111-1111", "GHSA-2222-2222-2222"], - &[], - ); - let single = - info_with_advisories("uuid-single", Some("high"), &["GHSA-3333-3333-3333"], &[]); - assert!( - candidate_supersedes(&merged, &single), - "broader merge coverage is a genuine supersede" - ); - // Swapped: fewer advisories at equal severity cannot supersede. - assert!( - !candidate_supersedes(&single, &merged), - "narrower coverage must never supersede" - ); - } - - #[test] - fn a_more_severe_single_candidate_supersedes_a_lower_severity_merge() { - // Same rule as selection: severity beats advisory count, so the - // [UPDATE] marker names the patch scan would install. - let merged = info_with_advisories( - "uuid-merged", - Some("low"), - &["GHSA-1111-1111-1111", "GHSA-2222-2222-2222"], - &[], - ); - let critical = - info_with_advisories("uuid-crit", Some("critical"), &["GHSA-3333-3333-3333"], &[]); - assert!(!candidate_supersedes(&merged, &critical)); - assert!(candidate_supersedes(&critical, &merged)); - } - - #[test] - fn a_larger_merge_supersedes_a_smaller_merge_at_equal_severity() { - let smaller = info_with_advisories("small", Some("high"), &["GHSA-a", "GHSA-b"], &[]); - let larger = - info_with_advisories("large", Some("high"), &["GHSA-a", "GHSA-b", "GHSA-c"], &[]); - assert!(candidate_supersedes(&larger, &smaller)); - assert!(!candidate_supersedes(&smaller, &larger)); - } - - #[test] - fn candidate_supersedes_cve_aliases_do_not_inflate_ghsa_coverage() { - // Both sides name a GHSA, so the CVE lists are aliases and must not - // count: both unmerged, same severity, no dates -> not a supersede - // in either direction (the date rung requires two REAL dates). - let candidate = info_with_advisories( - "uuid-cand", - Some("high"), - &["GHSA-xxxx-xxxx-xxxx"], - &["CVE-2026-1", "CVE-2026-2"], - ); - let applied = info_with_advisories( - "uuid-appl", - Some("high"), - &["GHSA-yyyy-yyyy-yyyy"], - &["CVE-2026-3"], - ); - assert!(!candidate_supersedes(&candidate, &applied)); - assert!(!candidate_supersedes(&applied, &candidate)); - } - #[test] fn detect_updates_flags_merged_patch_superseding_applied_single() { // End-to-end through detect_updates: the manifest holds the @@ -1977,7 +1793,6 @@ mod tests { assert_eq!(updates[0].new_uuid, "uuid-merged"); } - // ---- preverify_vendor_baselines -------------------------------------- // The HashMismatch positive path is covered end-to-end by // tests/scan_vendor_e2e.rs; these pin the three SKIP shapes: the two // pre-fetch skips (lockfile-only, no crawled counterpart) and the @@ -2533,39 +2348,4 @@ mod tests { assert_eq!(mock.received_requests().await.unwrap().len(), 2); drop(mock); } - - #[test] - fn collect_vuln_ids_dedups_across_patches() { - // The same CVE appears on two patches of one package; it must be - // reported once. - let pkg = BatchPackagePatches { - purl: "pkg:npm/foo@1.0".to_string(), - patches: vec![ - BatchPatchInfo { - uuid: "u1".to_string(), - purl: "pkg:npm/foo@1.0".to_string(), - tier: "free".to_string(), - cve_ids: vec!["CVE-2024-1".to_string()], - ghsa_ids: vec![], - severity: None, - title: String::new(), - published_at: None, - }, - BatchPatchInfo { - uuid: "u2".to_string(), - purl: "pkg:npm/foo@1.0".to_string(), - tier: "free".to_string(), - cve_ids: vec!["CVE-2024-1".to_string()], - ghsa_ids: vec!["GHSA-aaaa-aaaa-aaaa".to_string()], - severity: None, - title: String::new(), - published_at: None, - }, - ], - }; - // (u2's GHSA is the alias of its one CVE, so it is not counted.) - let ids = collect_vuln_ids(&pkg); - assert_eq!(ids.primary, vec!["CVE-2024-1".to_string()]); - assert_eq!(ids.count, 1); - } } diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index d9f79b2d2..72720cad8 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -12,11 +12,13 @@ use socket_patch_core::api::client::{ build_proxy_fallback_client, get_api_client_with_overrides, hold_back_debug, is_fallback_candidate, ApiClient, ApiError, }; +use socket_patch_core::api::ranking::severity_order; use socket_patch_core::api::types::{BatchPackagePatches, BatchSearchResponse, PatchSearchResult}; use socket_patch_core::crawlers::ruby_crawler::{ config_path_ignored_warning, stale_plugin_registration_warning, }; use socket_patch_core::crawlers::Ecosystem; +use socket_patch_core::ledgers::merge_ledger_records_for_updates; use socket_patch_core::manifest::schema::PatchManifest; use socket_patch_core::telemetry::{ spawn_patch_scan_failed, spawn_patch_scanned, PendingTelemetry, TelemetryAuth, @@ -24,6 +26,7 @@ use socket_patch_core::telemetry::{ use socket_patch_core::utils::concurrent::{api_concurrency_for, ordered_concurrent}; use socket_patch_core::utils::purl::{canonical_purl, normalize_purl}; use socket_patch_core::utils::purl_key::{canonical_base_purl, PurlKey}; +use socket_patch_core::vendor::lock_inventory::unsupported_layout_warnings; use socket_patch_core::vendor::{purl_keys_cover, VendorState}; use socket_patch_core::vex::discover::{LedgerLiveness, WiringMode}; use std::collections::{HashMap, HashSet}; @@ -62,15 +65,14 @@ mod withdrawn; use self::discovery::{ collect_vuln_ids, detect_updates, lockfile_only_contains, lockfile_supplement, - merge_ledger_records_for_updates, preverify_vendor_baselines, severity_order, - vendored_ledger_supplement, LockfileSupplement, + preverify_vendor_baselines, vendored_ledger_supplement, LockfileSupplement, }; // Shared with `get --mode hosted|vendored` (commands::get): the advisory- // pinned entry into the hosted engine, the vendor step + its dry-run -// preview, and the PnP layout-refusal warning mapping. `pub(crate)` -// re-exports because the submodules themselves stay private to scan. +// preview. `pub(crate)` re-exports because the submodules themselves stay +// private to scan. pub(crate) use self::discovery::{ - lockfile_supplement as project_lockfile_supplement, unsupported_layout_warnings, + lockfile_supplement as project_lockfile_supplement, vendored_ledger_supplement as project_vendored_supplement, }; use self::gc::gc_into; @@ -3152,7 +3154,7 @@ async fn run_scan( .patches .iter() .filter_map(|p| p.severity.as_deref()) - .min_by_key(|s| severity_order(s)) + .min_by_key(|s| severity_order(Some(s))) .unwrap_or("unknown"); // Collect vuln IDs (deterministic; see collect_vuln_ids). diff --git a/crates/socket-patch-cli/src/commands/scan/render.rs b/crates/socket-patch-cli/src/commands/scan/render.rs index 5311e93cb..d51aaec87 100644 --- a/crates/socket-patch-cli/src/commands/scan/render.rs +++ b/crates/socket-patch-cli/src/commands/scan/render.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use socket_patch_core::api::types::{PatchSearchResult, VulnerabilityResponse}; -use super::discovery::severity_order; use crate::args::GlobalArgs; use crate::ui::{self, plural, Align}; +use socket_patch_core::api::ranking::severity_order; /// Visible widths of the table's PATCHES and SEVERITY columns. pub(super) const PATCHES_COL: usize = 8; @@ -373,8 +373,8 @@ fn sorted_vulns( ) -> Vec<(&String, &VulnerabilityResponse)> { let mut v: Vec<_> = vulns.iter().collect(); v.sort_by(|a, b| { - severity_order(&a.1.severity) - .cmp(&severity_order(&b.1.severity)) + severity_order(Some(&a.1.severity)) + .cmp(&severity_order(Some(&b.1.severity))) .then_with(|| vuln_label(a.0, a.1).cmp(&vuln_label(b.0, b.1))) }); v @@ -398,7 +398,7 @@ pub(super) fn highest_severity(patch: &PatchSearchResult) -> Option<&str> { .vulnerabilities .values() .map(|v| v.severity.as_str()) - .min_by_key(|s| severity_order(s)) + .min_by_key(|s| severity_order(Some(s))) } /// The warning for a replacement that leaves some of the recorded patch's From ff4b1aed3d00f364160b22b15d987be46278dcf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 05:24:29 +0000 Subject: [PATCH 4/4] Address review of the discovery sweep Fold the last info_with_advisories caller onto patch_with, and make the comment over scan's discovery re-exports name what they export. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/discovery.rs | 39 ++++++------------- .../socket-patch-cli/src/commands/scan/mod.rs | 7 ++-- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/discovery.rs b/crates/socket-patch-cli/src/commands/scan/discovery.rs index 2c32f434a..0562e26ee 100644 --- a/crates/socket-patch-cli/src/commands/scan/discovery.rs +++ b/crates/socket-patch-cli/src/commands/scan/discovery.rs @@ -1748,26 +1748,6 @@ mod tests { assert_eq!(ids.count, 1); } - /// A batch-shaped patch with explicit advisory lists and NO publish - /// date, so only the severity and advisory-count rungs can decide. - fn info_with_advisories( - uuid: &str, - severity: Option<&str>, - ghsas: &[&str], - cves: &[&str], - ) -> BatchPatchInfo { - BatchPatchInfo { - uuid: uuid.to_string(), - purl: "pkg:npm/foo@1.0".to_string(), - tier: "free".to_string(), - cve_ids: cves.iter().map(|s| (*s).to_string()).collect(), - ghsa_ids: ghsas.iter().map(|s| (*s).to_string()).collect(), - severity: severity.map(str::to_string), - title: String::new(), - published_at: None, - } - } - #[test] fn detect_updates_flags_merged_patch_superseding_applied_single() { // End-to-end through detect_updates: the manifest holds the @@ -1778,13 +1758,18 @@ mod tests { let pkgs = vec![BatchPackagePatches { purl: "pkg:npm/foo@1.0".to_string(), patches: vec![ - info_with_advisories("uuid-single", Some("high"), &["GHSA-3333-3333-3333"], &[]), - info_with_advisories( - "uuid-merged", - Some("high"), - &["GHSA-1111-1111-1111", "GHSA-2222-2222-2222"], - &[], - ), + BatchPatchInfo { + severity: Some("high".to_string()), + ..patch_with("uuid-single", &[], &["GHSA-3333-3333-3333"]) + }, + BatchPatchInfo { + severity: Some("high".to_string()), + ..patch_with( + "uuid-merged", + &[], + &["GHSA-1111-1111-1111", "GHSA-2222-2222-2222"], + ) + }, ], }]; let updates = detect_updates(Some(&m), &pkgs); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 72720cad8..75560fdd2 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -67,10 +67,9 @@ use self::discovery::{ collect_vuln_ids, detect_updates, lockfile_only_contains, lockfile_supplement, preverify_vendor_baselines, vendored_ledger_supplement, LockfileSupplement, }; -// Shared with `get --mode hosted|vendored` (commands::get): the advisory- -// pinned entry into the hosted engine, the vendor step + its dry-run -// preview. `pub(crate)` re-exports because the submodules themselves stay -// private to scan. +// Shared with `get --mode hosted|vendored` (commands::get): the lockfile +// and vendored-ledger crawl supplements. `pub(crate)` re-exports because +// the submodules themselves stay private to scan. pub(crate) use self::discovery::{ lockfile_supplement as project_lockfile_supplement, vendored_ledger_supplement as project_vendored_supplement,