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