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#[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#[derive(Debug, Default)]
38pub(crate) struct GcSummary {
39 pub pruned: Vec<String>,
42 pub blobs: CleanupResult,
43 pub diffs: CleanupResult,
44 pub packages: CleanupResult,
45 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 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 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
84async 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
112async fn run_apply_gc(
118 manifest_path: &Path,
119 socket_dir: &Path,
120 scanned_purls: &HashSet<String>,
121) -> GcSummary {
122 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 let _ = write_manifest(manifest_path, &manifest).await;
136 }
137 run_gc(&manifest, prunable, socket_dir, false).await
138}
139
140async 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, true).await
154}
155
156pub(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
173pub(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 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 #[arg(long = "batch-size", env = "SOCKET_BATCH_SIZE", default_value_t = DEFAULT_BATCH_SIZE)]
213 pub batch_size: usize,
214
215 #[arg(long, default_value_t = false)]
223 pub apply: bool,
224
225 #[arg(long, default_value_t = false)]
232 pub prune: bool,
233
234 #[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 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 let mut fallback_to_proxy = false;
262
263 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 let (all_crawled, eco_counts) = crawl_all_ecosystems(&crawler_options).await;
287
288 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 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 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 let mut eco_parts = Vec::new();
364 for eco in Ecosystem::all() {
365 let count = if args.common.ecosystems.is_some() {
366 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 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 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 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 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 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 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 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 let dry = args.common.dry_run;
562
563 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 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 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 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, ¶ms).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 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 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 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 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 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 let has_update = if let Some(ref manifest) = existing_manifest {
777 if let Some(existing) = manifest.patches.get(&pkg.purl) {
778 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 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 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 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 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 println!("\nPatches to apply:\n");
912 for patch in &selected {
913 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 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 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 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, ¶ms).await;
996
997 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 #[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 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 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 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 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 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 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}