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#[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#[derive(Debug, Default)]
41pub(crate) struct GcSummary {
42 pub pruned: Vec<String>,
45 pub blobs: CleanupResult,
46 pub diffs: CleanupResult,
47 pub packages: CleanupResult,
48 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 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 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
87async 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
115async fn run_apply_gc(
121 manifest_path: &Path,
122 socket_dir: &Path,
123 scanned_purls: &HashSet<String>,
124) -> GcSummary {
125 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 let _ = write_manifest(manifest_path, &manifest).await;
139 }
140 run_gc(&manifest, prunable, socket_dir, false).await
141}
142
143async 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, true).await
157}
158
159pub(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
186pub(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 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
219pub(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 #[arg(long = "batch-size", env = "SOCKET_BATCH_SIZE", default_value_t = DEFAULT_BATCH_SIZE)]
249 pub batch_size: usize,
250
251 #[arg(long, default_value_t = false)]
259 pub apply: bool,
260
261 #[arg(long, default_value_t = false)]
268 pub prune: bool,
269
270 #[arg(long, default_value_t = false)]
275 pub sync: bool,
276
277 #[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 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 let mut fallback_to_proxy = false;
314
315 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 let (all_crawled, eco_counts) = crawl_all_ecosystems(&crawler_options).await;
339
340 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 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 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 let mut eco_parts = Vec::new();
416 for eco in Ecosystem::all() {
417 let count = if args.common.ecosystems.is_some() {
418 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 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 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 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 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 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 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 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 let dry = args.common.dry_run;
614
615 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 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 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 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, ¶ms).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 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 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 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 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 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 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 let has_update = if let Some(ref manifest) = existing_manifest {
819 if let Some(existing) = manifest.patches.get(&pkg.purl) {
820 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 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 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 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 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 println!("\nPatches to apply:\n");
954 for patch in &selected {
955 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 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 for vuln in patch.vulnerabilities.values() {
992 if !vuln.summary.is_empty() {
993 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 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 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, ¶ms).await;
1035
1036 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 #[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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1389 fn truncate_multibyte_purl_does_not_panic() {
1390 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 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 let summary = "—".repeat(100); let out = truncate_with_ellipsis(&summary, 76);
1411 assert_eq!(out.chars().count(), 76);
1412 assert!(out.ends_with("..."));
1413 }
1414}