Skip to main content

socket_patch_cli/commands/
scan.rs

1use clap::Args;
2use socket_patch_core::api::client::{
3    build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate,
4};
5use socket_patch_core::api::types::{BatchPackagePatches, PatchSearchResult};
6use socket_patch_core::crawlers::{CrawlerOptions, Ecosystem};
7use socket_patch_core::manifest::operations::{read_manifest, write_manifest};
8use socket_patch_core::manifest::schema::PatchManifest;
9use socket_patch_core::utils::cleanup_blobs::{
10    cleanup_unused_archives, cleanup_unused_blobs, CleanupResult,
11};
12use socket_patch_core::utils::purl::strip_purl_qualifiers;
13use socket_patch_core::utils::telemetry::{track_patch_scan_failed, track_patch_scanned};
14use std::collections::HashSet;
15use std::path::Path;
16
17use crate::args::{apply_env_toggles, GlobalArgs};
18use crate::ecosystem_dispatch::crawl_all_ecosystems;
19use crate::output::{color, confirm, format_severity, stderr_is_tty, stdout_is_tty};
20
21use super::get::{
22    download_and_apply_patches, select_patches, truncate_with_ellipsis, DownloadParams,
23};
24
25const DEFAULT_BATCH_SIZE: usize = 100;
26
27/// Surfaced in `scan --json` output. Tells a bot which PURLs in the discovery
28/// would replace an existing manifest entry with a newer UUID. Stable schema —
29/// see CLI_CONTRACT.md (`scan` JSON output / `updates` field).
30#[derive(Debug, PartialEq, Eq, Clone)]
31pub(crate) struct UpdateInfo {
32    pub purl: String,
33    pub old_uuid: String,
34    pub new_uuid: String,
35}
36
37/// Aggregated outcome of a GC pass (or preview). Serialized into the
38/// `scan --json` output's `gc` sub-object. See CLI_CONTRACT.md for the
39/// stable schema.
40#[derive(Debug, Default)]
41pub(crate) struct GcSummary {
42    /// PURLs removed from the manifest (apply mode) or eligible to be
43    /// removed (preview mode).
44    pub pruned: Vec<String>,
45    pub blobs: CleanupResult,
46    pub diffs: CleanupResult,
47    pub packages: CleanupResult,
48    /// `true` when `--no-prune` was set; the sub-object only carries the
49    /// `skipped: true` field in that case.
50    pub skipped: bool,
51}
52
53impl GcSummary {
54    fn total_bytes(&self) -> u64 {
55        self.blobs.bytes_freed + self.diffs.bytes_freed + self.packages.bytes_freed
56    }
57
58    /// Serialize for a *mutating* GC pass (post-apply).
59    fn to_apply_json(&self) -> serde_json::Value {
60        if self.skipped {
61            return serde_json::json!({ "skipped": true });
62        }
63        serde_json::json!({
64            "prunedManifestEntries": self.pruned,
65            "removedBlobs": self.blobs.blobs_removed,
66            "removedDiffArchives": self.diffs.blobs_removed,
67            "removedPackageArchives": self.packages.blobs_removed,
68            "bytesFreed": self.total_bytes(),
69        })
70    }
71
72    /// Serialize for a *non-mutating* GC pass (read-only preview).
73    fn to_preview_json(&self) -> serde_json::Value {
74        if self.skipped {
75            return serde_json::json!({ "skipped": true });
76        }
77        serde_json::json!({
78            "prunableManifestEntries": self.pruned,
79            "orphanBlobs": self.blobs.blobs_removed,
80            "orphanDiffArchives": self.diffs.blobs_removed,
81            "orphanPackageArchives": self.packages.blobs_removed,
82            "bytesReclaimable": self.total_bytes(),
83        })
84    }
85}
86
87/// Compute GC actions without performing them. `dry_run = true` for the
88/// preview path; `dry_run = false` for the apply path. The cleanup helpers
89/// from `socket_patch_core::utils::cleanup_blobs` natively support dry-run,
90/// so the same function works for both.
91async fn run_gc(
92    manifest: &PatchManifest,
93    pruned: Vec<String>,
94    socket_dir: &Path,
95    dry_run: bool,
96) -> GcSummary {
97    let blobs = cleanup_unused_blobs(manifest, &socket_dir.join("blobs"), dry_run)
98        .await
99        .unwrap_or_default();
100    let diffs = cleanup_unused_archives(manifest, &socket_dir.join("diffs"), dry_run)
101        .await
102        .unwrap_or_default();
103    let packages = cleanup_unused_archives(manifest, &socket_dir.join("packages"), dry_run)
104        .await
105        .unwrap_or_default();
106    GcSummary {
107        pruned,
108        blobs,
109        diffs,
110        packages,
111        skipped: false,
112    }
113}
114
115/// Apply-mode GC: re-read the manifest written by `download_and_apply_patches`,
116/// prune manifest entries for PURLs not in `scanned_purls`, write the manifest
117/// back, then sweep orphan blob/diff/package files. Callers must gate on the
118/// `prune` flag — when GC isn't requested, simply don't call this function and
119/// don't emit a `gc` sub-object.
120async fn run_apply_gc(
121    manifest_path: &Path,
122    socket_dir: &Path,
123    scanned_purls: &HashSet<String>,
124) -> GcSummary {
125    // Re-read the just-written manifest (the apply step may have added
126    // or updated entries we now want to consider for pruning).
127    let mut manifest = match read_manifest(manifest_path).await {
128        Ok(Some(m)) => m,
129        _ => return GcSummary::default(),
130    };
131    let prunable = detect_prunable(&manifest, scanned_purls);
132    for purl in &prunable {
133        manifest.patches.remove(purl);
134    }
135    if !prunable.is_empty() {
136        // If pruning failed mid-write the manifest may be stale, but the
137        // file-level cleanup below still operates on the in-memory copy.
138        let _ = write_manifest(manifest_path, &manifest).await;
139    }
140    run_gc(&manifest, prunable, socket_dir, /*dry_run=*/false).await
141}
142
143/// Dry-run preview of the apply-mode GC pass. Same shape as
144/// [`run_apply_gc`] but emits `prunable*`/`orphan*` field names and
145/// performs no mutation.
146async fn preview_apply_gc(
147    manifest_path: &Path,
148    socket_dir: &Path,
149    scanned_purls: &HashSet<String>,
150) -> GcSummary {
151    let manifest = match read_manifest(manifest_path).await {
152        Ok(Some(m)) => m,
153        _ => return GcSummary::default(),
154    };
155    let prunable = detect_prunable(&manifest, scanned_purls);
156    run_gc(&manifest, prunable, socket_dir, /*dry_run=*/true).await
157}
158
159/// PURL strings present in the manifest but absent from `scanned_purls`.
160/// These are candidates for pruning during `scan`'s GC pass — they
161/// correspond to packages that were once patched but are no longer
162/// installed (or no longer reachable to the crawler). Pure / no I/O so
163/// it's unit-testable.
164///
165/// Comparison is on the **base** PURL (qualifiers stripped) on both
166/// sides: the pypi crawler reports base PURLs, but a manifest may hold
167/// several qualified release variants (`?artifact_id=...`) of one
168/// installed package. Matching on the base keeps every variant of an
169/// installed package while still pruning all variants of one that is
170/// gone — otherwise `scan --all-releases --sync` would prune the very
171/// variants it just downloaded.
172pub(crate) fn detect_prunable(
173    manifest: &PatchManifest,
174    scanned_purls: &HashSet<String>,
175) -> Vec<String> {
176    let scanned_bases: HashSet<&str> =
177        scanned_purls.iter().map(|p| strip_purl_qualifiers(p)).collect();
178    manifest
179        .patches
180        .keys()
181        .filter(|p| !scanned_bases.contains(strip_purl_qualifiers(p)))
182        .cloned()
183        .collect()
184}
185
186/// Cross-reference an existing manifest against discovery results to find
187/// PURLs whose newest available patch UUID differs from the locally-recorded
188/// one. Used by both the discovery JSON path and the table-print path.
189/// Pure / no I/O so it's unit-testable.
190pub(crate) fn detect_updates(
191    existing_manifest: Option<&PatchManifest>,
192    packages: &[BatchPackagePatches],
193) -> Vec<UpdateInfo> {
194    let Some(manifest) = existing_manifest else {
195        return Vec::new();
196    };
197    let mut updates = Vec::new();
198    for pkg in packages {
199        let Some(existing) = manifest.patches.get(&pkg.purl) else {
200            continue;
201        };
202        // Treat the first patch in the batch as the candidate the apply path
203        // would resolve to (mirrors `select_patches` ordering — newest-first
204        // for paid users, single-patch auto-select for free).
205        let Some(candidate) = pkg.patches.first() else {
206            continue;
207        };
208        if candidate.uuid != existing.uuid {
209            updates.push(UpdateInfo {
210                purl: pkg.purl.clone(),
211                old_uuid: existing.uuid.clone(),
212                new_uuid: candidate.uuid.clone(),
213            });
214        }
215    }
216    updates
217}
218
219/// Collect the deduplicated CVE and GHSA identifiers across every patch of
220/// a package, for the scan table's VULNERABILITIES column. CVEs are listed
221/// before GHSAs and each group is sorted, so the rendered output is stable —
222/// the per-patch ID lists and set-based dedup are otherwise nondeterministic
223/// in order. Pure / no I/O so it's unit-testable.
224pub(crate) fn collect_vuln_ids(pkg: &BatchPackagePatches) -> Vec<String> {
225    let mut cves: HashSet<String> = HashSet::new();
226    let mut ghsas: HashSet<String> = HashSet::new();
227    for patch in &pkg.patches {
228        for cve in &patch.cve_ids {
229            cves.insert(cve.clone());
230        }
231        for ghsa in &patch.ghsa_ids {
232            ghsas.insert(ghsa.clone());
233        }
234    }
235    let mut cves: Vec<String> = cves.into_iter().collect();
236    cves.sort();
237    let mut ghsas: Vec<String> = ghsas.into_iter().collect();
238    ghsas.sort();
239    cves.into_iter().chain(ghsas).collect()
240}
241
242#[derive(Args)]
243pub struct ScanArgs {
244    #[command(flatten)]
245    pub common: GlobalArgs,
246
247    /// Number of packages to query per API request.
248    #[arg(long = "batch-size", env = "SOCKET_BATCH_SIZE", default_value_t = DEFAULT_BATCH_SIZE)]
249    pub batch_size: usize,
250
251    /// Download and apply selected patches in JSON mode (non-interactive).
252    /// Without this flag, `scan --json` is read-only — it lists available
253    /// patches plus an `updates` array but does not mutate the manifest.
254    /// Designed for unattended workflows (cron jobs, bots that open PRs);
255    /// pair with `--yes` for clarity though `--json` already implies non-
256    /// interactive confirmation. No effect outside `--json` mode (the
257    /// non-JSON path always prompts the user).
258    #[arg(long, default_value_t = false)]
259    pub apply: bool,
260
261    /// Garbage-collect after the scan: prune manifest entries for
262    /// packages no longer present in the crawl, then delete orphan
263    /// blob, diff, and package-archive files from `.socket/`. Off by
264    /// default to preserve manifest state across temporary uninstalls;
265    /// pair with `--apply` (or use `--sync`) for the auto-update
266    /// workflow.
267    #[arg(long, default_value_t = false)]
268    pub prune: bool,
269
270    /// Convenience flag for the auto-update workflow: implies both
271    /// `--apply` and `--prune`. Designed so a cron job or CI workflow
272    /// can run `socket-patch scan --json --sync --yes` and end up in a
273    /// fully-reconciled state in one invocation.
274    #[arg(long, default_value_t = false)]
275    pub sync: bool,
276
277    /// Download patches for every release/distribution variant of a
278    /// matched package, not just the one(s) matching the locally-
279    /// installed distribution. Affects ecosystems with per-release
280    /// variants — PyPI (wheel/sdist via `artifact_id`), RubyGems
281    /// (`platform`), and Maven (`classifier`). Off by default: narrow
282    /// scans store only the patch(es) for the installed dist, keeping
283    /// `.socket/` small; `--all-releases` makes the manifest portable
284    /// across environments (e.g. cross-platform CI caches).
285    #[arg(
286        long = "all-releases",
287        env = "SOCKET_ALL_RELEASES",
288        default_value_t = false,
289        value_parser = clap::builder::BoolishValueParser::new(),
290    )]
291    pub all_releases: bool,
292}
293
294pub async fn run(args: ScanArgs) -> i32 {
295    apply_env_toggles(&args.common);
296
297    // `--sync` is sugar for `--apply --prune`. Derive locals once and
298    // use them everywhere downstream so the flag interactions are
299    // expressed in one place. `--apply --prune --sync` is redundant
300    // but legal (all three end up true).
301    let apply = args.apply || args.sync;
302    let prune = args.prune || args.sync;
303
304    let overrides = args.common.api_client_overrides();
305    let (mut api_client, mut use_public_proxy) =
306        get_api_client_with_overrides(overrides.clone()).await;
307    let telemetry_token = api_client.api_token().cloned();
308    let telemetry_org = api_client.org_slug().cloned();
309    // Tracks whether scan was downgraded from the authenticated
310    // endpoint to the public proxy mid-run after a 401/403. Surfaces
311    // in the final `patch_scanned` telemetry event so we can measure
312    // how often stale-token fallbacks fire in the wild.
313    let mut fallback_to_proxy = false;
314
315    // org slug is already stored in the client
316    let effective_org_slug: Option<&str> = None;
317
318    let crawler_options = CrawlerOptions {
319        cwd: args.common.cwd.clone(),
320        global: args.common.global,
321        global_prefix: args.common.global_prefix.clone(),
322        batch_size: args.batch_size,
323    };
324
325    let scan_target = if args.common.global || args.common.global_prefix.is_some() {
326        "global packages"
327    } else {
328        "packages"
329    };
330
331    let show_progress = !args.common.json && stderr_is_tty();
332
333    if show_progress {
334        eprint!("Scanning {scan_target}...");
335    }
336
337    // Crawl packages
338    let (all_crawled, eco_counts) = crawl_all_ecosystems(&crawler_options).await;
339
340    // Filter by --ecosystems if provided
341    let filtered_crawled: Vec<_> = if let Some(ref allowed) = args.common.ecosystems {
342        all_crawled
343            .into_iter()
344            .filter(|pkg| {
345                if let Some(eco) = Ecosystem::from_purl(&pkg.purl) {
346                    allowed.iter().any(|a| a == eco.cli_name())
347                } else {
348                    false
349                }
350            })
351            .collect()
352    } else {
353        all_crawled
354    };
355
356    let all_purls: Vec<String> = filtered_crawled.iter().map(|p| p.purl.clone()).collect();
357    let package_count = all_purls.len();
358
359    if package_count == 0 {
360        if show_progress {
361            eprintln!();
362        }
363        if args.common.json {
364            // When the crawler finds nothing, GC is intentionally skipped
365            // — pruning every manifest entry on the assumption that the
366            // user "uninstalled everything" is too destructive. Bots
367            // that need full cleanup can call `repair` explicitly. No
368            // `gc` field emitted because the user didn't request one.
369            println!(
370                "{}",
371                serde_json::to_string_pretty(&serde_json::json!({
372                    "status": "success",
373                    "scannedPackages": 0,
374                    "packagesWithPatches": 0,
375                    "totalPatches": 0,
376                    "freePatches": 0,
377                    "paidPatches": 0,
378                    "canAccessPaidPatches": false,
379                    "packages": [],
380                    "updates": [],
381                }))
382                .unwrap()
383            );
384        } else if args.common.global || args.common.global_prefix.is_some() {
385            println!("No global packages found.");
386        } else {
387            #[allow(unused_mut)]
388            let mut install_cmds = String::from("npm/yarn/pnpm/pip");
389            #[cfg(feature = "cargo")]
390            install_cmds.push_str("/cargo");
391            #[cfg(feature = "golang")]
392            install_cmds.push_str("/go");
393            #[cfg(feature = "maven")]
394            install_cmds.push_str("/mvn");
395            #[cfg(feature = "composer")]
396            install_cmds.push_str("/composer");
397            println!("No packages found. Run {install_cmds} install first.");
398        }
399        // Telemetry: empty-scan still counts as a successful scan.
400        track_patch_scanned(
401            0,
402            0,
403            0,
404            false,
405            args.common.ecosystems.clone().unwrap_or_default().as_slice(),
406            false,
407            telemetry_token.as_deref(),
408            telemetry_org.as_deref(),
409        )
410        .await;
411        return 0;
412    }
413
414    // Build ecosystem summary
415    let mut eco_parts = Vec::new();
416    for eco in Ecosystem::all() {
417        let count = if args.common.ecosystems.is_some() {
418            // When filtering, count the filtered packages
419            filtered_crawled.iter().filter(|p| Ecosystem::from_purl(&p.purl) == Some(*eco)).count()
420        } else {
421            eco_counts.get(eco).copied().unwrap_or(0)
422        };
423        if count > 0 {
424            eco_parts.push(format!("{count} {}", eco.display_name()));
425        }
426    }
427    let eco_summary = if eco_parts.is_empty() {
428        String::new()
429    } else {
430        format!(" ({})", eco_parts.join(", "))
431    };
432
433    if !args.common.json {
434        if show_progress {
435            eprintln!("\rFound {package_count} packages{eco_summary}");
436        } else {
437            eprintln!("Found {package_count} packages{eco_summary}");
438        }
439    }
440
441    // Query API in batches
442    let mut all_packages_with_patches: Vec<BatchPackagePatches> = Vec::new();
443    let mut can_access_paid_patches = false;
444    let total_batches = all_purls.len().div_ceil(args.batch_size);
445    let mut batch_error_count = 0usize;
446    let mut last_batch_error: Option<String> = None;
447
448    if show_progress {
449        eprint!("Querying API for patches... (batch 1/{total_batches})");
450    }
451
452    for (batch_idx, chunk) in all_purls.chunks(args.batch_size).enumerate() {
453        if show_progress {
454            eprint!(
455                "\rQuerying API for patches... (batch {}/{})",
456                batch_idx + 1,
457                total_batches
458            );
459        }
460
461        let purls: Vec<String> = chunk.to_vec();
462        let mut result = api_client
463            .search_patches_batch(effective_org_slug, &purls)
464            .await;
465
466        // Fallback: a 401/403 against the authenticated endpoint can
467        // mean a stale/revoked token. Retry against the public proxy
468        // (free patches only) once, then continue the rest of the
469        // loop with the downgraded client. Only triggers on the
470        // first authenticated batch; subsequent iterations are
471        // already on the proxy.
472        if !use_public_proxy {
473            if let Err(ref e) = result {
474                if is_fallback_candidate(e) {
475                    eprintln!(
476                        "Warning: authenticated API returned {e}; \
477                         falling back to public patch API proxy (free patches only)."
478                    );
479                    api_client = build_proxy_fallback_client(&overrides);
480                    use_public_proxy = true;
481                    fallback_to_proxy = true;
482                    result = api_client
483                        .search_patches_batch(effective_org_slug, &purls)
484                        .await;
485                }
486            }
487        }
488
489        match result {
490            Ok(response) => {
491                if response.can_access_paid_patches {
492                    can_access_paid_patches = true;
493                }
494                for pkg in response.packages {
495                    if !pkg.patches.is_empty() {
496                        all_packages_with_patches.push(pkg);
497                    }
498                }
499            }
500            Err(e) => {
501                batch_error_count += 1;
502                last_batch_error = Some(e.to_string());
503                if !args.common.json {
504                    eprintln!("\nError querying batch {}: {e}", batch_idx + 1);
505                }
506            }
507        }
508    }
509
510    // If every batch errored, surface this as a full scan failure rather
511    // than silently reporting zero patches (which historically looked
512    // identical to "no patches for these packages").
513    if total_batches > 0 && batch_error_count == total_batches {
514        let err = last_batch_error
515            .unwrap_or_else(|| "all batches failed".to_string());
516        track_patch_scan_failed(
517            &err,
518            fallback_to_proxy,
519            telemetry_token.as_deref(),
520            telemetry_org.as_deref(),
521        )
522        .await;
523    }
524
525    let total_patches_found: usize = all_packages_with_patches
526        .iter()
527        .map(|p| p.patches.len())
528        .sum();
529
530    if !args.common.json {
531        if total_patches_found > 0 {
532            if show_progress {
533                eprintln!(
534                    "\rFound {total_patches_found} patches for {} packages",
535                    all_packages_with_patches.len()
536                );
537            } else {
538                eprintln!(
539                    "Found {total_patches_found} patches for {} packages",
540                    all_packages_with_patches.len()
541                );
542            }
543        } else if show_progress {
544            eprintln!("\rAPI query complete");
545        } else {
546            eprintln!("API query complete");
547        }
548    }
549
550    // Calculate patch counts
551    let mut free_patches = 0usize;
552    let mut paid_patches = 0usize;
553    for pkg in &all_packages_with_patches {
554        for patch in &pkg.patches {
555            if patch.tier == "free" {
556                free_patches += 1;
557            } else {
558                paid_patches += 1;
559            }
560        }
561    }
562    let total_patches = free_patches + paid_patches;
563
564    // Telemetry: record the scan outcome once we have the canonical
565    // per-tier counts. `fallback_to_proxy` is `true` iff the batch
566    // loop downgraded from the authenticated endpoint to the public
567    // proxy after a 401/403.
568    track_patch_scanned(
569        package_count,
570        free_patches,
571        paid_patches,
572        can_access_paid_patches,
573        args.common.ecosystems.clone().unwrap_or_default().as_slice(),
574        fallback_to_proxy,
575        telemetry_token.as_deref(),
576        telemetry_org.as_deref(),
577    )
578    .await;
579
580    // Read existing manifest once for update detection. Used by both the
581    // JSON-mode emission (always includes an `updates` array) and the
582    // non-JSON table-print path (counts `updates_available`).
583    let manifest_path = args.common.resolved_manifest_path();
584    let socket_dir = manifest_path.parent().unwrap().to_path_buf();
585    let existing_manifest = read_manifest(&manifest_path).await.ok().flatten();
586    let updates = detect_updates(existing_manifest.as_ref(), &all_packages_with_patches);
587
588    // Crawl PURLs as a set for prunable detection (manifest entries whose
589    // PURL is not in the current crawl results).
590    let scanned_purls: HashSet<String> = all_purls.iter().cloned().collect();
591
592    if args.common.json {
593        let mut result = serde_json::json!({
594            "status": "success",
595            "scannedPackages": package_count,
596            "packagesWithPatches": all_packages_with_patches.len(),
597            "totalPatches": total_patches,
598            "freePatches": free_patches,
599            "paidPatches": paid_patches,
600            "canAccessPaidPatches": can_access_paid_patches,
601            "packages": all_packages_with_patches,
602            "updates": updates.iter().map(|u| serde_json::json!({
603                "purl": u.purl,
604                "oldUuid": u.old_uuid,
605                "newUuid": u.new_uuid,
606            })).collect::<Vec<_>>(),
607        });
608
609        // `apply` and `prune` are computed once at the top of run()
610        // (factoring in --sync, which implies both). They're independent
611        // here: a bot can `--apply` without `--prune`, or `--prune`
612        // without `--apply` (just GC-sweep), or both (full sync).
613        let dry = args.common.dry_run;
614
615        // --- Apply path (if requested) -----------------------------------
616        if apply {
617            let mut all_search_results: Vec<PatchSearchResult> = Vec::new();
618            for pkg in &all_packages_with_patches {
619                match api_client
620                    .search_patches_by_package(effective_org_slug, &pkg.purl)
621                    .await
622                {
623                    Ok(response) => all_search_results.extend(response.patches),
624                    Err(_) => continue,
625                }
626            }
627
628            // For scan-driven bot workflows there's no "specify --id"
629            // option — we're scanning the whole project. Pass
630            // `is_json = false` so `select_one` auto-selects the newest
631            // patch in non-TTY mode rather than erroring with
632            // `selection_required`.
633            let selected = if all_search_results.is_empty() {
634                Vec::new()
635            } else {
636                match select_patches(&all_search_results, can_access_paid_patches, false) {
637                    Ok(s) => s,
638                    Err(code) => return code,
639                }
640            };
641
642            let mut apply_code = 0i32;
643            if dry {
644                // Synthesize the per-patch outcome without touching disk.
645                // `decide_patch_action` consults the existing manifest,
646                // so it accurately reports what `--apply` *would* do.
647                let manifest_for_preview = existing_manifest
648                    .clone()
649                    .unwrap_or_else(PatchManifest::new);
650                let patches: Vec<serde_json::Value> = selected
651                    .iter()
652                    .map(|p| {
653                        match super::get::decide_patch_action(
654                            &manifest_for_preview,
655                            &p.purl,
656                            &p.uuid,
657                        ) {
658                            super::get::PatchAction::Added => serde_json::json!({
659                                "purl": p.purl, "uuid": p.uuid, "action": "added",
660                            }),
661                            super::get::PatchAction::Updated { old_uuid } => serde_json::json!({
662                                "purl": p.purl, "uuid": p.uuid,
663                                "action": "updated", "oldUuid": old_uuid,
664                            }),
665                            super::get::PatchAction::Skipped => serde_json::json!({
666                                "purl": p.purl, "uuid": p.uuid, "action": "skipped",
667                            }),
668                        }
669                    })
670                    .collect();
671                let added = patches.iter().filter(|p| p["action"] == "added").count();
672                let updated = patches.iter().filter(|p| p["action"] == "updated").count();
673                let skipped = patches.iter().filter(|p| p["action"] == "skipped").count();
674                result["apply"] = serde_json::json!({
675                    "found": selected.len(),
676                    "downloaded": 0,
677                    "skipped": skipped,
678                    "failed": 0,
679                    "applied": 0,
680                    "updated": updated,
681                    "added": added,
682                    "patches": patches,
683                    "dryRun": true,
684                });
685            } else if selected.is_empty() {
686                // No patches selected (e.g. all paid for a free user, or
687                // no packages had patches). Emit empty `apply` so JSON
688                // shape is stable, then fall through to GC if requested.
689                result["apply"] = serde_json::json!({
690                    "found": 0, "downloaded": 0, "skipped": 0,
691                    "failed": 0, "applied": 0, "updated": 0,
692                    "patches": [],
693                });
694            } else {
695                let params = DownloadParams {
696                    cwd: args.common.cwd.clone(),
697                    org: args.common.org.clone(),
698                    save_only: false,
699                    one_off: false,
700                    global: args.common.global,
701                    global_prefix: args.common.global_prefix.clone(),
702                    json: true,
703                    silent: true,
704                    download_mode: args.common.download_mode.clone(),
705                    api_overrides: args.common.api_client_overrides(),
706                    all_releases: args.all_releases,
707                };
708                let (code, apply_json) = download_and_apply_patches(&selected, &params).await;
709                apply_code = code;
710                let mut apply_obj = apply_json;
711                if let Some(obj) = apply_obj.as_object_mut() {
712                    obj.remove("status");
713                }
714                result["apply"] = apply_obj;
715                if apply_code != 0 {
716                    result["status"] = serde_json::json!("partial_failure");
717                }
718            }
719
720            // --- GC (if requested) --------------------------------------
721            if prune {
722                let gc = if dry {
723                    preview_apply_gc(&manifest_path, &socket_dir, &scanned_purls).await
724                } else {
725                    run_apply_gc(&manifest_path, &socket_dir, &scanned_purls).await
726                };
727                result["gc"] = if dry {
728                    gc.to_preview_json()
729                } else {
730                    gc.to_apply_json()
731                };
732            }
733
734            println!("{}", serde_json::to_string_pretty(&result).unwrap());
735            return apply_code;
736        }
737
738        // --- GC-only path (no --apply, just --prune) --------------------
739        if prune {
740            let gc = if dry {
741                preview_apply_gc(&manifest_path, &socket_dir, &scanned_purls).await
742            } else {
743                run_apply_gc(&manifest_path, &socket_dir, &scanned_purls).await
744            };
745            result["gc"] = if dry {
746                gc.to_preview_json()
747            } else {
748                gc.to_apply_json()
749            };
750        }
751
752        println!("{}", serde_json::to_string_pretty(&result).unwrap());
753        return 0;
754    }
755
756    let use_color = stdout_is_tty();
757
758    if all_packages_with_patches.is_empty() {
759        println!("\nNo patches available for installed packages.");
760        return 0;
761    }
762
763    let mut updates_available = 0usize;
764
765    // Print table
766    println!("\n{}", "=".repeat(100));
767    println!(
768        "{}  {}  {}  VULNERABILITIES",
769        "PACKAGE".to_string() + &" ".repeat(33),
770        "PATCHES".to_string() + " ",
771        "SEVERITY".to_string() + &" ".repeat(8),
772    );
773    println!("{}", "=".repeat(100));
774
775    for pkg in &all_packages_with_patches {
776        // Char-safe truncation: a byte slice (`&pkg.purl[..37]`) panics
777        // when the cut lands mid-codepoint. PURLs can carry non-ASCII
778        // names/qualifiers, so route through the shared helper.
779        let display_purl = truncate_with_ellipsis(&pkg.purl, 40);
780
781        let pkg_free = pkg.patches.iter().filter(|p| p.tier == "free").count();
782        let pkg_paid = pkg.patches.iter().filter(|p| p.tier == "paid").count();
783
784        let count_str = if pkg_paid > 0 {
785            if can_access_paid_patches {
786                format!("{}+{}", pkg_free, pkg_paid)
787            } else {
788                format!("{}+{}", pkg_free, color(&pkg_paid.to_string(), "33", use_color))
789            }
790        } else {
791            format!("{}", pkg_free)
792        };
793
794        // Get highest severity
795        let severity = pkg
796            .patches
797            .iter()
798            .filter_map(|p| p.severity.as_deref())
799            .min_by_key(|s| severity_order(s))
800            .unwrap_or("unknown");
801
802        // Collect vuln IDs (deterministic: deduped, CVEs then GHSAs,
803        // each group sorted — see collect_vuln_ids).
804        let vuln_ids = collect_vuln_ids(pkg);
805        let vuln_str = if vuln_ids.len() > 2 {
806            format!(
807                "{} (+{})",
808                vuln_ids[..2].join(", "),
809                vuln_ids.len() - 2
810            )
811        } else if vuln_ids.is_empty() {
812            "-".to_string()
813        } else {
814            vuln_ids.join(", ")
815        };
816
817        // Check for updates
818        let has_update = if let Some(ref manifest) = existing_manifest {
819            if let Some(existing) = manifest.patches.get(&pkg.purl) {
820                // If any patch in the batch has a different UUID than what's in manifest, update available
821                pkg.patches.iter().any(|p| p.uuid != existing.uuid)
822            } else {
823                false
824            }
825        } else {
826            false
827        };
828        if has_update {
829            updates_available += 1;
830        }
831
832        let update_marker = if has_update {
833            color(" [UPDATE]", "33", use_color)
834        } else {
835            String::new()
836        };
837
838        println!(
839            "{:<40}  {:>8}  {:<16}  {}{}",
840            display_purl,
841            count_str,
842            format_severity(severity, use_color),
843            vuln_str,
844            update_marker,
845        );
846    }
847
848    println!("{}", "=".repeat(100));
849
850    // Summary
851    if can_access_paid_patches {
852        println!(
853            "\nSummary: {} package(s) with {} available patch(es)",
854            all_packages_with_patches.len(),
855            total_patches,
856        );
857    } else {
858        println!(
859            "\nSummary: {} package(s) with {} free patch(es)",
860            all_packages_with_patches.len(),
861            free_patches,
862        );
863        if paid_patches > 0 {
864            println!(
865                "{}",
866                color(
867                    &format!("         + {} additional patch(es) available with paid subscription", paid_patches),
868                    "33",
869                    use_color,
870                ),
871            );
872            println!(
873                "\nUpgrade to Socket's paid plan to access all patches: https://socket.dev/pricing"
874            );
875        }
876    }
877
878    if updates_available > 0 {
879        println!(
880            "\n{}",
881            color(
882                &format!("{updates_available} package(s) have newer patches available."),
883                "33",
884                use_color,
885            ),
886        );
887    }
888
889    // Count downloadable patches
890    let downloadable_count = if can_access_paid_patches {
891        all_packages_with_patches.len()
892    } else {
893        all_packages_with_patches
894            .iter()
895            .filter(|pkg| pkg.patches.iter().any(|p| p.tier == "free"))
896            .count()
897    };
898
899    if downloadable_count == 0 {
900        println!("\nNo downloadable patches (paid subscription required).");
901        return 0;
902    }
903
904    // Fetch full PatchSearchResult for each package that has patches
905    if show_progress {
906        eprint!("\nFetching patch details...");
907    }
908
909    let mut all_search_results: Vec<PatchSearchResult> = Vec::new();
910    for (i, pkg) in all_packages_with_patches.iter().enumerate() {
911        if show_progress {
912            eprint!(
913                "\rFetching patch details... ({}/{})",
914                i + 1,
915                all_packages_with_patches.len()
916            );
917        }
918        match api_client
919            .search_patches_by_package(effective_org_slug, &pkg.purl)
920            .await
921        {
922            Ok(response) => {
923                all_search_results.extend(response.patches);
924            }
925            Err(e) => {
926                eprintln!("\n  Warning: could not fetch details for {}: {e}", pkg.purl);
927            }
928        }
929    }
930
931    if show_progress {
932        eprintln!();
933    }
934
935    if all_search_results.is_empty() {
936        eprintln!("Could not fetch patch details.");
937        return 1;
938    }
939
940    // Smart selection
941    let selected: Vec<PatchSearchResult> =
942        match select_patches(&all_search_results, can_access_paid_patches, false) {
943            Ok(s) => s,
944            Err(code) => return code,
945        };
946
947    if selected.is_empty() {
948        println!("No patches selected.");
949        return 0;
950    }
951
952    // Display detailed summary of selected patches before confirming
953    println!("\nPatches to apply:\n");
954    for patch in &selected {
955        // Collect CVE/GHSA IDs and highest severity from vulnerabilities
956        let mut vuln_ids: Vec<String> = Vec::new();
957        let mut highest_severity: Option<&str> = None;
958        for (id, vuln) in &patch.vulnerabilities {
959            if vuln.cves.is_empty() {
960                vuln_ids.push(id.clone());
961            } else {
962                for cve in &vuln.cves {
963                    vuln_ids.push(cve.clone());
964                }
965            }
966            let sev = vuln.severity.as_str();
967            if highest_severity
968                .is_none_or(|cur| severity_order(sev) < severity_order(cur))
969            {
970                highest_severity = Some(sev);
971            }
972        }
973
974        let sev_display = highest_severity.unwrap_or("unknown");
975        let sev_colored = format_severity(sev_display, use_color);
976
977        // Char-safe: descriptions come straight from the API and routinely
978        // contain non-ASCII text; a `&desc[..69]` byte slice would panic.
979        let desc = truncate_with_ellipsis(&patch.description, 72);
980
981        println!(
982            "  {} [{}] {}",
983            patch.purl,
984            patch.tier.to_uppercase(),
985            sev_colored,
986        );
987        if !vuln_ids.is_empty() {
988            println!("    Fixes: {}", vuln_ids.join(", "));
989        }
990        // Show per-vulnerability summaries
991        for vuln in patch.vulnerabilities.values() {
992            if !vuln.summary.is_empty() {
993                // Char-safe: vulnerability summaries are API-sourced free
994                // text; a `&summary[..73]` byte slice would panic mid-codepoint.
995                let summary = truncate_with_ellipsis(&vuln.summary, 76);
996                let cve_label = if vuln.cves.is_empty() {
997                    String::new()
998                } else {
999                    format!("{}: ", vuln.cves.join(", "))
1000                };
1001                println!("    - {cve_label}{summary}");
1002            }
1003        }
1004        if !desc.is_empty() {
1005            println!("    {desc}");
1006        }
1007        println!();
1008    }
1009
1010    // Prompt to download
1011    let prompt = format!("Download and apply {} patch(es)?", selected.len());
1012    if !confirm(&prompt, true, args.common.yes, args.common.json) {
1013        println!("\nTo apply a patch, run:");
1014        println!("  socket-patch get <package-name-or-purl>");
1015        println!("  socket-patch get <CVE-ID>");
1016        return 0;
1017    }
1018
1019    // Download and apply
1020    let params = DownloadParams {
1021        cwd: args.common.cwd.clone(),
1022        org: args.common.org.clone(),
1023        save_only: false,
1024        one_off: false,
1025        global: args.common.global,
1026        global_prefix: args.common.global_prefix.clone(),
1027        json: false,
1028        silent: false,
1029        download_mode: args.common.download_mode.clone(),
1030        api_overrides: args.common.api_client_overrides(),
1031        all_releases: args.all_releases,
1032    };
1033
1034    let (code, _) = download_and_apply_patches(&selected, &params).await;
1035
1036    // Post-apply GC: only runs when the user opted in via `--prune` or
1037    // `--sync`. Default `scan --yes` no longer touches the manifest
1038    // beyond what `--apply` added — users wanting to clean up should
1039    // run `socket-patch gc` (or `repair`) explicitly.
1040    if prune {
1041        let gc = run_apply_gc(&manifest_path, &socket_dir, &scanned_purls).await;
1042        let total = gc.blobs.blobs_removed + gc.diffs.blobs_removed + gc.packages.blobs_removed;
1043        if !gc.pruned.is_empty() || total > 0 {
1044            println!(
1045                "\nGC: pruned {} manifest entr{} and removed {} orphan file{} ({}).",
1046                gc.pruned.len(),
1047                if gc.pruned.len() == 1 { "y" } else { "ies" },
1048                total,
1049                if total == 1 { "" } else { "s" },
1050                socket_patch_core::utils::cleanup_blobs::format_bytes(gc.total_bytes()),
1051            );
1052        }
1053    }
1054
1055    code
1056}
1057
1058pub(crate) fn severity_order(s: &str) -> u8 {
1059    match s.to_lowercase().as_str() {
1060        "critical" => 0,
1061        "high" => 1,
1062        "medium" => 2,
1063        "low" => 3,
1064        _ => 4,
1065    }
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071    use socket_patch_core::api::types::{BatchPackagePatches, BatchPatchInfo};
1072    use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord};
1073    use std::collections::HashMap;
1074
1075    // ---- severity_order ----------------------------------------------------
1076
1077    #[test]
1078    fn severity_order_critical_is_zero() {
1079        assert_eq!(severity_order("critical"), 0);
1080    }
1081
1082    #[test]
1083    fn severity_order_is_case_insensitive() {
1084        assert_eq!(severity_order("Critical"), 0);
1085        assert_eq!(severity_order("CRITICAL"), 0);
1086        assert_eq!(severity_order("High"), 1);
1087    }
1088
1089    #[test]
1090    fn severity_order_known_levels() {
1091        assert_eq!(severity_order("high"), 1);
1092        assert_eq!(severity_order("medium"), 2);
1093        assert_eq!(severity_order("low"), 3);
1094    }
1095
1096    #[test]
1097    fn severity_order_unknown_is_four() {
1098        assert_eq!(severity_order("unknown"), 4);
1099        assert_eq!(severity_order(""), 4);
1100        assert_eq!(severity_order("informational"), 4);
1101    }
1102
1103    // ---- detect_updates -----------------------------------------------------
1104
1105    fn manifest_with(entries: &[(&str, &str)]) -> PatchManifest {
1106        let mut m = PatchManifest::new();
1107        for (purl, uuid) in entries {
1108            m.patches.insert(
1109                (*purl).to_string(),
1110                PatchRecord {
1111                    uuid: (*uuid).to_string(),
1112                    exported_at: String::new(),
1113                    files: HashMap::new(),
1114                    vulnerabilities: HashMap::new(),
1115                    description: String::new(),
1116                    license: String::new(),
1117                    tier: "free".to_string(),
1118                },
1119            );
1120        }
1121        m
1122    }
1123
1124    fn batch_with(purl: &str, uuids: &[&str]) -> BatchPackagePatches {
1125        BatchPackagePatches {
1126            purl: purl.to_string(),
1127            patches: uuids
1128                .iter()
1129                .map(|u| BatchPatchInfo {
1130                    uuid: (*u).to_string(),
1131                    purl: purl.to_string(),
1132                    tier: "free".to_string(),
1133                    cve_ids: Vec::new(),
1134                    ghsa_ids: Vec::new(),
1135                    severity: None,
1136                    title: String::new(),
1137                })
1138                .collect(),
1139        }
1140    }
1141
1142    #[test]
1143    fn detect_updates_returns_empty_when_no_manifest() {
1144        let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])];
1145        assert!(detect_updates(None, &pkgs).is_empty());
1146    }
1147
1148    #[test]
1149    fn detect_updates_returns_empty_for_empty_packages() {
1150        let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]);
1151        assert!(detect_updates(Some(&m), &[]).is_empty());
1152    }
1153
1154    #[test]
1155    fn detect_updates_returns_empty_when_no_overlap() {
1156        let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]);
1157        let pkgs = vec![batch_with("pkg:npm/bar@2.0", &["uuid-z"])];
1158        assert!(detect_updates(Some(&m), &pkgs).is_empty());
1159    }
1160
1161    #[test]
1162    fn detect_updates_skips_same_uuid() {
1163        let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]);
1164        let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])];
1165        assert!(detect_updates(Some(&m), &pkgs).is_empty());
1166    }
1167
1168    #[test]
1169    fn detect_updates_flags_different_uuid() {
1170        let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]);
1171        let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-b"])];
1172        let updates = detect_updates(Some(&m), &pkgs);
1173        assert_eq!(updates.len(), 1);
1174        assert_eq!(updates[0].purl, "pkg:npm/foo@1.0");
1175        assert_eq!(updates[0].old_uuid, "uuid-a");
1176        assert_eq!(updates[0].new_uuid, "uuid-b");
1177    }
1178
1179    #[test]
1180    fn detect_updates_reports_multiple_updates() {
1181        let m = manifest_with(&[
1182            ("pkg:npm/foo@1.0", "uuid-a"),
1183            ("pkg:npm/bar@2.0", "uuid-c"),
1184        ]);
1185        let pkgs = vec![
1186            batch_with("pkg:npm/foo@1.0", &["uuid-b"]),
1187            batch_with("pkg:npm/bar@2.0", &["uuid-d"]),
1188        ];
1189        let updates = detect_updates(Some(&m), &pkgs);
1190        assert_eq!(updates.len(), 2);
1191    }
1192
1193    #[test]
1194    fn detect_updates_skips_packages_with_empty_patch_list() {
1195        let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]);
1196        // No candidate patches means we can't tell what the new UUID would
1197        // be, so there's nothing to compare against. Correct behavior is to
1198        // skip these silently.
1199        let pkgs = vec![batch_with("pkg:npm/foo@1.0", &[])];
1200        assert!(detect_updates(Some(&m), &pkgs).is_empty());
1201    }
1202
1203    #[test]
1204    fn detect_updates_uses_first_patch_as_candidate() {
1205        // `detect_updates` mirrors `select_patches` by picking the first
1206        // patch in the batch as the candidate UUID. Locking this in so a
1207        // future select_patches refactor doesn't silently drift the two.
1208        let m = manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]);
1209        let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-b", "uuid-c"])];
1210        let updates = detect_updates(Some(&m), &pkgs);
1211        assert_eq!(updates.len(), 1);
1212        assert_eq!(updates[0].new_uuid, "uuid-b");
1213    }
1214
1215    // ---- detect_prunable ---------------------------------------------------
1216
1217    fn scanned(purls: &[&str]) -> HashSet<String> {
1218        purls.iter().map(|s| (*s).to_string()).collect()
1219    }
1220
1221    #[test]
1222    fn detect_prunable_empty_manifest_empty_scanned() {
1223        let m = PatchManifest::new();
1224        assert!(detect_prunable(&m, &scanned(&[])).is_empty());
1225    }
1226
1227    #[test]
1228    fn detect_prunable_empty_manifest_nonempty_scanned() {
1229        let m = PatchManifest::new();
1230        // No manifest entries → nothing to prune even if the crawl found
1231        // packages that don't appear in the manifest.
1232        assert!(detect_prunable(&m, &scanned(&["pkg:npm/foo@1"])).is_empty());
1233    }
1234
1235    #[test]
1236    fn detect_prunable_all_entries_present_in_scan() {
1237        let m = manifest_with(&[
1238            ("pkg:npm/foo@1.0", "uuid-a"),
1239            ("pkg:npm/bar@2.0", "uuid-b"),
1240        ]);
1241        let s = scanned(&["pkg:npm/foo@1.0", "pkg:npm/bar@2.0"]);
1242        assert!(detect_prunable(&m, &s).is_empty());
1243    }
1244
1245    #[test]
1246    fn detect_prunable_returns_missing_entries() {
1247        let m = manifest_with(&[
1248            ("pkg:npm/foo@1.0", "uuid-a"),
1249            ("pkg:npm/bar@2.0", "uuid-b"),
1250        ]);
1251        // foo is still installed, bar is gone.
1252        let s = scanned(&["pkg:npm/foo@1.0"]);
1253        let mut out = detect_prunable(&m, &s);
1254        out.sort();
1255        assert_eq!(out, vec!["pkg:npm/bar@2.0".to_string()]);
1256    }
1257
1258    #[test]
1259    fn detect_prunable_returns_everything_when_scan_is_empty() {
1260        let m = manifest_with(&[
1261            ("pkg:npm/foo@1.0", "uuid-a"),
1262            ("pkg:npm/bar@2.0", "uuid-b"),
1263        ]);
1264        let mut out = detect_prunable(&m, &scanned(&[]));
1265        out.sort();
1266        assert_eq!(
1267            out,
1268            vec!["pkg:npm/bar@2.0".to_string(), "pkg:npm/foo@1.0".to_string()],
1269        );
1270    }
1271
1272    #[test]
1273    fn detect_prunable_keeps_pypi_variants_of_installed_base() {
1274        // Manifest holds three qualified release variants; the crawler
1275        // reports only the base PURL. None should be pruned — they all
1276        // belong to the installed package.
1277        let m = manifest_with(&[
1278            ("pkg:pypi/six@1.16.0?artifact_id=wheel-a", "uuid-a"),
1279            ("pkg:pypi/six@1.16.0?artifact_id=wheel-b", "uuid-b"),
1280            ("pkg:pypi/six@1.16.0?artifact_id=sdist", "uuid-c"),
1281        ]);
1282        let out = detect_prunable(&m, &scanned(&["pkg:pypi/six@1.16.0"]));
1283        assert!(
1284            out.is_empty(),
1285            "variants of an installed base must not be pruned; got {out:?}"
1286        );
1287    }
1288
1289    #[test]
1290    fn detect_prunable_removes_all_variants_of_uninstalled_base() {
1291        // The package is no longer installed (empty crawl): every
1292        // release variant is prunable.
1293        let m = manifest_with(&[
1294            ("pkg:pypi/six@1.16.0?artifact_id=wheel-a", "uuid-a"),
1295            ("pkg:pypi/six@1.16.0?artifact_id=sdist", "uuid-c"),
1296        ]);
1297        let out = detect_prunable(&m, &scanned(&[]));
1298        assert_eq!(out.len(), 2, "all variants of a gone package should prune");
1299    }
1300
1301    // ---- collect_vuln_ids --------------------------------------------------
1302
1303    /// Build a single-patch package whose patch carries the given CVE and
1304    /// GHSA identifier lists.
1305    fn batch_with_vulns(purl: &str, cves: &[&str], ghsas: &[&str]) -> BatchPackagePatches {
1306        BatchPackagePatches {
1307            purl: purl.to_string(),
1308            patches: vec![BatchPatchInfo {
1309                uuid: "uuid".to_string(),
1310                purl: purl.to_string(),
1311                tier: "free".to_string(),
1312                cve_ids: cves.iter().map(|s| (*s).to_string()).collect(),
1313                ghsa_ids: ghsas.iter().map(|s| (*s).to_string()).collect(),
1314                severity: None,
1315                title: String::new(),
1316            }],
1317        }
1318    }
1319
1320    #[test]
1321    fn collect_vuln_ids_empty_when_no_vulns() {
1322        let pkg = batch_with_vulns("pkg:npm/foo@1.0", &[], &[]);
1323        assert!(collect_vuln_ids(&pkg).is_empty());
1324    }
1325
1326    #[test]
1327    fn collect_vuln_ids_lists_cves_before_ghsas_each_sorted() {
1328        // Deliberately unsorted input; output must be CVEs (sorted) then
1329        // GHSAs (sorted) so the rendered table column is deterministic.
1330        let pkg = batch_with_vulns(
1331            "pkg:npm/foo@1.0",
1332            &["CVE-2024-2", "CVE-2024-1"],
1333            &["GHSA-zzzz-zzzz-zzzz", "GHSA-aaaa-aaaa-aaaa"],
1334        );
1335        assert_eq!(
1336            collect_vuln_ids(&pkg),
1337            vec![
1338                "CVE-2024-1".to_string(),
1339                "CVE-2024-2".to_string(),
1340                "GHSA-aaaa-aaaa-aaaa".to_string(),
1341                "GHSA-zzzz-zzzz-zzzz".to_string(),
1342            ],
1343        );
1344    }
1345
1346    #[test]
1347    fn collect_vuln_ids_dedups_across_patches() {
1348        // The same CVE appears on two patches of one package; it must be
1349        // reported once.
1350        let pkg = BatchPackagePatches {
1351            purl: "pkg:npm/foo@1.0".to_string(),
1352            patches: vec![
1353                BatchPatchInfo {
1354                    uuid: "u1".to_string(),
1355                    purl: "pkg:npm/foo@1.0".to_string(),
1356                    tier: "free".to_string(),
1357                    cve_ids: vec!["CVE-2024-1".to_string()],
1358                    ghsa_ids: vec![],
1359                    severity: None,
1360                    title: String::new(),
1361                },
1362                BatchPatchInfo {
1363                    uuid: "u2".to_string(),
1364                    purl: "pkg:npm/foo@1.0".to_string(),
1365                    tier: "free".to_string(),
1366                    cve_ids: vec!["CVE-2024-1".to_string()],
1367                    ghsa_ids: vec!["GHSA-aaaa-aaaa-aaaa".to_string()],
1368                    severity: None,
1369                    title: String::new(),
1370                },
1371            ],
1372        };
1373        assert_eq!(
1374            collect_vuln_ids(&pkg),
1375            vec![
1376                "CVE-2024-1".to_string(),
1377                "GHSA-aaaa-aaaa-aaaa".to_string(),
1378            ],
1379        );
1380    }
1381
1382    // ---- truncate_with_ellipsis (scan's display columns) -------------------
1383    // scan.rs renders PURLs, descriptions, and vulnerability summaries — all
1384    // API-sourced and potentially non-ASCII — into fixed-width columns. These
1385    // pin scan's use of the char-safe helper; a raw `&s[..n]` byte slice
1386    // would panic when the cut lands mid-codepoint.
1387
1388    #[test]
1389    fn truncate_multibyte_purl_does_not_panic() {
1390        // 30 three-byte chars (90 bytes, 30 chars). The old purl path sliced
1391        // `&purl[..37]` once `len() > 40`; byte 37 splits a codepoint here.
1392        let purl = format!("pkg:npm/{}", "日".repeat(30));
1393        let out = truncate_with_ellipsis(&purl, 40);
1394        assert!(out.chars().count() <= 40);
1395    }
1396
1397    #[test]
1398    fn truncate_multibyte_description_truncates_on_char_boundary() {
1399        // 100 two-byte chars; description column truncates at 72.
1400        let desc = "é".repeat(100);
1401        let out = truncate_with_ellipsis(&desc, 72);
1402        assert_eq!(out.chars().count(), 72);
1403        assert!(out.ends_with("..."));
1404    }
1405
1406    #[test]
1407    fn truncate_multibyte_summary_truncates_on_char_boundary() {
1408        // Summary column truncates at 76.
1409        let summary = "—".repeat(100); // em dash, 3 bytes each
1410        let out = truncate_with_ellipsis(&summary, 76);
1411        assert_eq!(out.chars().count(), 76);
1412        assert!(out.ends_with("..."));
1413    }
1414}