1use clap::Args;
2use socket_patch_core::api::blob_fetcher::{
3 fetch_missing_blobs, fetch_missing_sources, format_fetch_result, get_missing_archives,
4 get_missing_blobs, DownloadMode,
5};
6use socket_patch_core::api::client::get_api_client_with_overrides;
7use socket_patch_core::crawlers::{
8 detect_npm_pkg_manager, CrawlerOptions, Ecosystem, NpmPkgManager,
9};
10use socket_patch_core::manifest::operations::read_manifest;
11use socket_patch_core::patch::apply::{
12 apply_package_patch, verify_file_patch, ApplyResult, PatchSources, VerifyStatus,
13};
14
15use crate::commands::lock_cli::{acquire_or_emit, lock_broken_event};
16use socket_patch_core::utils::purl::strip_purl_qualifiers;
17use socket_patch_core::utils::telemetry::{track_patch_applied, track_patch_apply_failed};
18use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21use tempfile::TempDir;
22
23use crate::args::{apply_env_toggles, GlobalArgs};
24use crate::json_envelope::{
25 AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status,
26};
27
28async fn overlay_dir(src: &Path, dst: &Path) {
38 let mut entries = match tokio::fs::read_dir(src).await {
39 Ok(e) => e,
40 Err(_) => return,
41 };
42 while let Ok(Some(entry)) = entries.next_entry().await {
43 let file_type = match entry.file_type().await {
44 Ok(t) => t,
45 Err(_) => continue,
46 };
47 if !file_type.is_file() {
48 continue;
49 }
50 let from = entry.path();
51 let to = dst.join(entry.file_name());
52 if tokio::fs::metadata(&to).await.is_ok() {
53 continue;
54 }
55 if tokio::fs::hard_link(&from, &to).await.is_err() {
56 let _ = tokio::fs::copy(&from, &to).await;
57 }
58 }
59}
60
61use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls};
62
63#[derive(Args)]
64pub struct ApplyArgs {
65 #[command(flatten)]
66 pub common: GlobalArgs,
67
68 #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)]
70 pub force: bool,
71}
72
73pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent {
86 let purl = result.package_key.clone();
87 if !result.success {
88 return PatchEvent::new(PatchAction::Failed, purl).with_error(
89 "apply_failed",
90 result
91 .error
92 .clone()
93 .unwrap_or_else(|| "unknown error".to_string()),
94 );
95 }
96
97 let all_already_patched = !result.files_verified.is_empty()
98 && result
99 .files_verified
100 .iter()
101 .all(|f| f.status == VerifyStatus::AlreadyPatched);
102
103 if all_already_patched {
104 return PatchEvent::new(PatchAction::Skipped, purl)
105 .with_reason("already_patched", "All files already match afterHash");
106 }
107
108 if dry_run {
109 let files = result
110 .files_verified
111 .iter()
112 .filter(|f| {
113 f.status == VerifyStatus::Ready || f.status == VerifyStatus::AlreadyPatched
114 })
115 .map(|f| PatchEventFile {
116 path: f.file.clone(),
117 verified: true,
118 applied_via: None,
119 })
120 .collect();
121 return PatchEvent::new(PatchAction::Verified, purl).with_files(files);
122 }
123
124 let files = result
125 .files_patched
126 .iter()
127 .map(|f| PatchEventFile {
128 path: f.clone(),
129 verified: true,
130 applied_via: result
131 .applied_via
132 .get(f)
133 .copied()
134 .map(AppliedVia::from_core),
135 })
136 .collect();
137 PatchEvent::new(PatchAction::Applied, purl).with_files(files)
143}
144
145pub async fn run(args: ApplyArgs) -> i32 {
146 apply_env_toggles(&args.common);
147 let (telemetry_client, _) =
148 get_api_client_with_overrides(args.common.api_client_overrides()).await;
149 let api_token = telemetry_client.api_token().cloned();
150 let org_slug = telemetry_client.org_slug().cloned();
151
152 let manifest_path = args.common.resolved_manifest_path();
153
154 if tokio::fs::metadata(&manifest_path).await.is_err() {
156 if args.common.json {
157 let mut env = Envelope::new(Command::Apply);
158 env.status = Status::NoManifest;
159 env.dry_run = args.common.dry_run;
160 println!("{}", env.to_pretty_json());
161 } else if !args.common.silent {
162 println!("No .socket folder found, skipping patch application.");
163 }
164 return 0;
165 }
166
167 let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
171 let acquired = match acquire_or_emit(
172 socket_dir,
173 Command::Apply,
174 args.common.json,
175 args.common.silent,
176 args.common.dry_run,
177 Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
178 args.common.break_lock,
179 ) {
180 Ok(acquired) => acquired,
181 Err(code) => return code,
182 };
183 let _lock = acquired.guard;
184 let lock_was_broken = acquired.broke_lock;
185
186 let pkg_manager = detect_npm_pkg_manager(&args.common.cwd);
193 match pkg_manager {
194 NpmPkgManager::YarnBerryPnP => {
195 if args.common.json {
196 let mut env = Envelope::new(Command::Apply);
197 env.dry_run = args.common.dry_run;
198 env.mark_error(EnvelopeError::new(
199 "yarn_pnp_unsupported",
200 "yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
201 ));
202 println!("{}", env.to_pretty_json());
203 } else if !args.common.silent {
204 eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
205 eprintln!(
206 " Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
207 );
208 eprintln!(" Use `yarn patch <pkg>` instead.");
209 }
210 return 1;
211 }
212 NpmPkgManager::Pnpm => {
213 if !args.common.json && !args.common.silent {
214 eprintln!(
215 "Note: pnpm layout detected. Copy-on-write will keep the global store untouched."
216 );
217 }
218 }
222 NpmPkgManager::Bun => {
223 if !args.common.json && !args.common.silent {
224 eprintln!(
225 "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched."
226 );
227 }
228 }
232 _ => {}
233 }
234
235 match apply_patches_inner(&args, &manifest_path).await {
236 Ok((success, results, unmatched)) => {
237 let patched_count = results
238 .iter()
239 .filter(|r| r.success && !r.files_patched.is_empty())
240 .count();
241
242 if args.common.json {
243 let mut env = Envelope::new(Command::Apply);
244 env.dry_run = args.common.dry_run;
245 if lock_was_broken {
246 env.record(lock_broken_event(socket_dir));
247 }
248 for result in &results {
249 env.record(result_to_event(result, args.common.dry_run));
250 if let Some(ref sidecar) = result.sidecar {
255 env.record_sidecar(sidecar.clone());
256 }
257 }
258 for purl in &unmatched {
262 env.record(
263 PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason(
264 "package_not_installed",
265 "No installed package matches this PURL",
266 ),
267 );
268 }
269 if !success {
270 env.mark_partial_failure();
271 }
272 println!("{}", env.to_pretty_json());
273 } else if !args.common.silent && !results.is_empty() {
274 let patched: Vec<_> = results.iter().filter(|r| r.success).collect();
275 let already_patched: Vec<_> = results
276 .iter()
277 .filter(|r| {
278 r.files_verified
279 .iter()
280 .all(|f| f.status == VerifyStatus::AlreadyPatched)
281 })
282 .collect();
283
284 if args.common.dry_run {
285 println!("\nPatch verification complete:");
286 println!(" {} package(s) can be patched", patched.len());
287 if !already_patched.is_empty() {
288 println!(" {} package(s) already patched", already_patched.len());
289 }
290 } else {
291 println!("\nPatched packages:");
292 for result in &patched {
293 if !result.files_patched.is_empty() {
294 let mut tags: Vec<&'static str> = result
299 .applied_via
300 .values()
301 .map(|v| v.as_tag())
302 .collect();
303 tags.sort_unstable();
304 tags.dedup();
305 let suffix = if tags.is_empty() {
306 String::new()
307 } else {
308 format!(" (via {})", tags.join("+"))
309 };
310 println!(" {}{}", result.package_key, suffix);
311 } else if result.files_verified.iter().all(|f| {
312 f.status == VerifyStatus::AlreadyPatched
313 }) {
314 println!(" {} (already patched)", result.package_key);
315 }
316 }
317 }
318
319 if args.common.verbose {
320 println!("\nDetailed verification:");
321 for result in &results {
322 println!(" {}:", result.package_key);
323 for f in &result.files_verified {
324 let status_str = match f.status {
325 VerifyStatus::Ready => "ready",
326 VerifyStatus::AlreadyPatched => "already patched",
327 VerifyStatus::HashMismatch => "hash mismatch",
328 VerifyStatus::NotFound => "not found",
329 };
330 println!(" {} [{}]", f.file, status_str);
331 if let Some(ref msg) = f.message {
332 println!(" message: {msg}");
333 }
334 if args.common.verbose {
335 if let Some(ref h) = f.current_hash {
336 println!(" current: {h}");
337 }
338 if let Some(ref h) = f.expected_hash {
339 println!(" expected: {h}");
340 }
341 if let Some(ref h) = f.target_hash {
342 println!(" target: {h}");
343 }
344 }
345 }
346 }
347 }
348 }
349
350 if success {
352 track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
353 } else {
354 track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
355 }
356
357 if success { 0 } else { 1 }
358 }
359 Err(e) => {
360 track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
361 if args.common.json {
362 let mut env = Envelope::new(Command::Apply);
363 env.dry_run = args.common.dry_run;
364 env.mark_error(EnvelopeError::new("apply_failed", e.clone()));
365 println!("{}", env.to_pretty_json());
366 } else if !args.common.silent {
367 eprintln!("Error: {e}");
368 }
369 1
370 }
371 }
372}
373
374async fn apply_patches_inner(
375 args: &ApplyArgs,
376 manifest_path: &Path,
377) -> Result<(bool, Vec<ApplyResult>, Vec<String>), String> {
378 let manifest = read_manifest(manifest_path)
379 .await
380 .map_err(|e| e.to_string())?
381 .ok_or_else(|| "Invalid manifest".to_string())?;
382
383 let socket_dir = manifest_path.parent().unwrap();
387 let socket_blobs_path = socket_dir.join("blobs");
388 let socket_diffs_path = socket_dir.join("diffs");
389 let socket_packages_path = socket_dir.join("packages");
390
391 let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?;
392
393 let missing_blobs = get_missing_blobs(&manifest, &socket_blobs_path).await;
397 let missing_diff_archives = get_missing_archives(&manifest, &socket_diffs_path).await;
398 let missing_package_archives = get_missing_archives(&manifest, &socket_packages_path).await;
399
400 let patches_without_source: Vec<&str> = manifest
406 .patches
407 .iter()
408 .filter_map(|(purl, record)| {
409 let all_blobs_present = record
410 .files
411 .values()
412 .all(|f| !missing_blobs.contains(&f.after_hash));
413 let diff_present = !missing_diff_archives.contains(&record.uuid);
414 let pkg_present = !missing_package_archives.contains(&record.uuid);
415 if all_blobs_present || diff_present || pkg_present {
416 None
417 } else {
418 Some(purl.as_str())
419 }
420 })
421 .collect();
422
423 if args.common.offline {
424 if !patches_without_source.is_empty() {
429 if !args.common.silent && !args.common.json {
430 eprintln!(
431 "Error: {} patch(es) have no local source and --offline is set:",
432 patches_without_source.len()
433 );
434 for purl in patches_without_source.iter().take(5) {
435 eprintln!(" - {}", purl);
436 }
437 if patches_without_source.len() > 5 {
438 eprintln!(" ... and {} more", patches_without_source.len() - 5);
439 }
440 eprintln!("Run \"socket-patch repair\" to download missing artifacts.");
441 }
442 return Ok((false, Vec::new(), Vec::new()));
443 }
444 }
445
446 let download_needed = !args.common.offline
455 && match download_mode {
456 DownloadMode::File => !missing_blobs.is_empty(),
457 DownloadMode::Diff | DownloadMode::Package if missing_blobs.is_empty() => false,
458 DownloadMode::Diff => !missing_diff_archives.is_empty(),
459 DownloadMode::Package => !missing_package_archives.is_empty(),
460 };
461
462 let (blobs_path, diffs_path, packages_path, _stage_dir): (
475 PathBuf,
476 PathBuf,
477 PathBuf,
478 Option<TempDir>,
479 ) = if download_needed {
480 let stage = tempfile::tempdir().map_err(|e| e.to_string())?;
481 let stage_blobs = stage.path().join("blobs");
482 let stage_diffs = stage.path().join("diffs");
483 let stage_packages = stage.path().join("packages");
484 for dir in [&stage_blobs, &stage_diffs, &stage_packages] {
485 tokio::fs::create_dir_all(dir)
486 .await
487 .map_err(|e| e.to_string())?;
488 }
489 overlay_dir(&socket_blobs_path, &stage_blobs).await;
490 overlay_dir(&socket_diffs_path, &stage_diffs).await;
491 overlay_dir(&socket_packages_path, &stage_packages).await;
492
493 if !args.common.silent && !args.common.json {
494 println!(
495 "Downloading missing patch artifacts (mode: {})...",
496 download_mode.as_tag()
497 );
498 }
499
500 let (client, _) =
501 get_api_client_with_overrides(args.common.api_client_overrides()).await;
502 let sources = PatchSources {
503 blobs_path: &stage_blobs,
504 packages_path: Some(&stage_packages),
505 diffs_path: Some(&stage_diffs),
506 };
507 let fetch_result =
508 fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await;
509
510 if !args.common.silent && !args.common.json {
511 println!("{}", format_fetch_result(&fetch_result));
512 }
513
514 if download_mode != DownloadMode::File {
518 let still_missing_blobs = get_missing_blobs(&manifest, &stage_blobs).await;
519 if !still_missing_blobs.is_empty() {
520 if !args.common.silent && !args.common.json {
521 println!(
522 "Falling back to per-file blob downloads for {} blob(s)...",
523 still_missing_blobs.len()
524 );
525 }
526 let blob_result =
527 fetch_missing_blobs(&manifest, &stage_blobs, &client, None).await;
528 if !args.common.silent && !args.common.json {
529 println!("{}", format_fetch_result(&blob_result));
530 }
531 if blob_result.failed > 0 && fetch_result.failed > 0 {
532 if !args.common.silent && !args.common.json {
533 eprintln!("Some artifacts could not be downloaded. Cannot apply patches.");
534 }
535 return Ok((false, Vec::new(), Vec::new()));
536 }
537 }
538 } else if fetch_result.failed > 0 {
539 if !args.common.silent && !args.common.json {
540 eprintln!("Some blobs could not be downloaded. Cannot apply patches.");
541 }
542 return Ok((false, Vec::new(), Vec::new()));
543 }
544
545 (stage_blobs, stage_diffs, stage_packages, Some(stage))
546 } else {
547 (
548 socket_blobs_path.clone(),
549 socket_diffs_path.clone(),
550 socket_packages_path.clone(),
551 None,
552 )
553 };
554
555 let manifest_purls: Vec<String> = manifest.patches.keys().cloned().collect();
557 let partitioned =
558 partition_purls(&manifest_purls, args.common.ecosystems.as_deref());
559
560 let target_manifest_purls: HashSet<String> = partitioned
561 .values()
562 .flat_map(|purls| purls.iter().cloned())
563 .collect();
564
565 let crawler_options = CrawlerOptions {
566 cwd: args.common.cwd.clone(),
567 global: args.common.global,
568 global_prefix: args.common.global_prefix.clone(),
569 batch_size: 100,
570 };
571
572 let all_packages =
573 find_packages_for_purls(&partitioned, &crawler_options, args.common.silent || args.common.json).await;
574
575 let has_any_purls = !partitioned.is_empty();
576
577 if all_packages.is_empty() && !has_any_purls {
578 if !args.common.silent && !args.common.json {
579 if args.common.global || args.common.global_prefix.is_some() {
580 eprintln!("No global packages found");
581 } else {
582 eprintln!("No package directories found");
583 }
584 }
585 return Ok((false, Vec::new(), Vec::new()));
586 }
587
588 if all_packages.is_empty() {
589 if !args.common.silent && !args.common.json {
590 eprintln!("Warning: No packages found that match available patches");
591 eprintln!(
592 " {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.",
593 target_manifest_purls.len()
594 );
595 eprintln!(" Check that packages are installed and --cwd points to the right directory.");
596 }
597 let unmatched: Vec<String> = target_manifest_purls.iter().cloned().collect();
598 return Ok((false, Vec::new(), unmatched));
599 }
600
601 let mut results: Vec<ApplyResult> = Vec::new();
603 let mut has_errors = false;
604
605 let mut pypi_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
607 if let Some(pypi_purls) = partitioned.get(&Ecosystem::Pypi) {
608 for purl in pypi_purls {
609 let base = strip_purl_qualifiers(purl).to_string();
610 pypi_qualified_groups
611 .entry(base)
612 .or_default()
613 .push(purl.clone());
614 }
615 }
616
617 let mut applied_base_purls: HashSet<String> = HashSet::new();
618 let mut matched_manifest_purls: HashSet<String> = HashSet::new();
619
620 for (purl, pkg_path) in &all_packages {
621 if Ecosystem::from_purl(purl) == Some(Ecosystem::Pypi) {
622 let base_purl = strip_purl_qualifiers(purl).to_string();
623 if applied_base_purls.contains(&base_purl) {
624 continue;
625 }
626
627 let variants = pypi_qualified_groups
628 .get(&base_purl)
629 .cloned()
630 .unwrap_or_else(|| vec![base_purl.clone()]);
631 let mut applied = false;
632
633 for variant_purl in &variants {
634 let patch = match manifest.patches.get(variant_purl) {
635 Some(p) => p,
636 None => continue,
637 };
638
639 if !args.force {
641 if let Some((file_name, file_info)) = patch.files.iter().next() {
642 let verify = verify_file_patch(pkg_path, file_name, file_info).await;
643 if verify.status == VerifyStatus::HashMismatch {
644 continue;
645 }
646 }
647 }
648
649 let sources = PatchSources {
650 blobs_path: &blobs_path,
651 packages_path: Some(&packages_path),
652 diffs_path: Some(&diffs_path),
653 };
654 let result = apply_package_patch(
655 variant_purl,
656 pkg_path,
657 &patch.files,
658 &sources,
659 Some(&patch.uuid),
660 args.common.dry_run,
661 args.force,
662 )
663 .await;
664
665 if result.success {
666 applied = true;
667 applied_base_purls.insert(base_purl.clone());
668 results.push(result);
669 matched_manifest_purls.insert(variant_purl.clone());
670 break;
671 } else {
672 results.push(result);
673 }
674 }
675
676 if !applied {
677 has_errors = true;
678 if !args.common.silent && !args.common.json {
679 eprintln!("Failed to patch {base_purl}: no matching variant found");
680 }
681 }
682 } else {
683 let patch = match manifest.patches.get(purl) {
685 Some(p) => p,
686 None => continue,
687 };
688
689 let sources = PatchSources {
690 blobs_path: &blobs_path,
691 packages_path: Some(&packages_path),
692 diffs_path: Some(&diffs_path),
693 };
694 let result = apply_package_patch(
695 purl,
696 pkg_path,
697 &patch.files,
698 &sources,
699 Some(&patch.uuid),
700 args.common.dry_run,
701 args.force,
702 )
703 .await;
704
705 if !result.success {
706 has_errors = true;
707 if !args.common.silent && !args.common.json {
708 eprintln!(
709 "Failed to patch {}: {}",
710 purl,
711 result.error.as_deref().unwrap_or("unknown error")
712 );
713 }
714 }
715 results.push(result);
716 matched_manifest_purls.insert(purl.clone());
717 }
718 }
719
720 let unmatched: Vec<String> = target_manifest_purls
722 .iter()
723 .filter(|p| !matched_manifest_purls.contains(*p))
724 .cloned()
725 .collect();
726
727 if !unmatched.is_empty() && !args.common.silent && !args.common.json {
728 eprintln!("\nWarning: {} manifest patch(es) had no matching installed package:", unmatched.len());
729 for purl in &unmatched {
730 eprintln!(" - {}", purl);
731 }
732 }
733
734 if !target_manifest_purls.is_empty() && matched_manifest_purls.is_empty() && !all_packages.is_empty() {
735 if !args.common.silent && !args.common.json {
736 eprintln!("Warning: None of the targeted manifest patches matched installed packages.");
737 }
738 has_errors = true;
739 }
740
741 if !args.common.silent && !args.common.json {
743 let applied_count = results.iter().filter(|r| r.success && !r.files_patched.is_empty()).count();
744 let already_count = results.iter().filter(|r| {
745 r.files_verified.iter().all(|f| f.status == VerifyStatus::AlreadyPatched)
746 }).count();
747 println!(
748 "\nSummary: {}/{} targeted patches applied, {} already patched, {} not found on disk",
749 applied_count,
750 target_manifest_purls.len(),
751 already_count,
752 unmatched.len()
753 );
754 }
755
756 Ok((!has_errors, results, unmatched))
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
772 use socket_patch_core::patch::apply::{
773 AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
774 };
775
776 fn sample_applied(status: VerifyStatus) -> ApplyResult {
779 let mut applied_via = HashMap::new();
780 applied_via.insert("package/index.js".to_string(), CoreAppliedVia::Diff);
781 ApplyResult {
782 package_key: "pkg:npm/minimist@1.2.2".to_string(),
783 package_path: "/tmp/node_modules/minimist".to_string(),
784 success: true,
785 files_verified: vec![VerifyResult {
786 file: "package/index.js".to_string(),
787 status,
788 message: None,
789 current_hash: None,
790 expected_hash: None,
791 target_hash: None,
792 }],
793 files_patched: vec!["package/index.js".to_string()],
794 applied_via,
795 error: None,
796 sidecar: None,
797 }
798 }
799
800 #[test]
801 fn failed_result_maps_to_failed_action() {
802 let mut result = sample_applied(VerifyStatus::Ready);
803 result.success = false;
804 result.error = Some("hash mismatch".into());
805
806 let event = result_to_event(&result, false);
807 let v: serde_json::Value =
808 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
809 assert_eq!(v["action"], "failed");
810 assert_eq!(v["errorCode"], "apply_failed");
811 assert_eq!(v["error"], "hash mismatch");
812 }
813
814 #[test]
815 fn all_already_patched_maps_to_skipped() {
816 let result = sample_applied(VerifyStatus::AlreadyPatched);
817 let event = result_to_event(&result, false);
818 let v: serde_json::Value =
819 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
820 assert_eq!(v["action"], "skipped");
821 assert_eq!(v["errorCode"], "already_patched");
822 }
823
824 #[test]
825 fn dry_run_maps_to_verified() {
826 let result = sample_applied(VerifyStatus::Ready);
827 let event = result_to_event(&result, true);
828 let v: serde_json::Value =
829 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
830 assert_eq!(v["action"], "verified");
831 assert_eq!(v["files"][0]["path"], "package/index.js");
834 assert!(v["files"][0].as_object().unwrap().get("appliedVia").is_none());
835 }
836
837 #[test]
838 fn successful_apply_maps_to_applied_with_files() {
839 let result = sample_applied(VerifyStatus::Ready);
840 let event = result_to_event(&result, false);
841 let v: serde_json::Value =
842 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
843 assert_eq!(v["action"], "applied");
844 assert_eq!(v["purl"], "pkg:npm/minimist@1.2.2");
845 let files = v["files"].as_array().unwrap();
846 assert_eq!(files.len(), 1);
847 assert_eq!(files[0]["path"], "package/index.js");
848 assert_eq!(files[0]["verified"], true);
849 assert_eq!(files[0]["appliedVia"], "diff");
851 }
852
853 #[test]
854 fn applied_event_emits_one_file_entry_per_patched_file() {
855 let mut applied_via = HashMap::new();
856 applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff);
857 applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Package);
858 applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob);
859 let result = ApplyResult {
860 package_key: "pkg:npm/foo@1.0.0".to_string(),
861 package_path: "/tmp/foo".to_string(),
862 success: true,
863 files_verified: Vec::new(),
864 files_patched: vec![
865 "package/a.js".to_string(),
866 "package/b.js".to_string(),
867 "package/c.js".to_string(),
868 ],
869 applied_via,
870 error: None,
871 sidecar: None,
872 };
873
874 let event = result_to_event(&result, false);
875 let v: serde_json::Value =
876 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
877 let files = v["files"].as_array().unwrap();
878 assert_eq!(files.len(), 3);
879 let by_path: std::collections::HashMap<String, &serde_json::Value> = files
880 .iter()
881 .map(|f| (f["path"].as_str().unwrap().to_string(), f))
882 .collect();
883 assert_eq!(by_path["package/a.js"]["appliedVia"], "diff");
884 assert_eq!(by_path["package/b.js"]["appliedVia"], "package");
885 assert_eq!(by_path["package/c.js"]["appliedVia"], "blob");
886 }
887}