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
73fn all_files_already_patched(result: &ApplyResult) -> bool {
86 !result.files_verified.is_empty()
87 && result
88 .files_verified
89 .iter()
90 .all(|f| f.status == VerifyStatus::AlreadyPatched)
91}
92
93fn variant_matches_installed(first_file_status: Option<&VerifyStatus>) -> bool {
114 match first_file_status {
115 None => true,
116 Some(status) => {
117 *status == VerifyStatus::Ready || *status == VerifyStatus::AlreadyPatched
118 }
119 }
120}
121
122pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent {
135 let purl = result.package_key.clone();
136 if !result.success {
137 return PatchEvent::new(PatchAction::Failed, purl).with_error(
138 "apply_failed",
139 result
140 .error
141 .clone()
142 .unwrap_or_else(|| "unknown error".to_string()),
143 );
144 }
145
146 if all_files_already_patched(result) {
147 return PatchEvent::new(PatchAction::Skipped, purl)
148 .with_reason("already_patched", "All files already match afterHash");
149 }
150
151 if dry_run {
152 let files = result
153 .files_verified
154 .iter()
155 .filter(|f| {
156 f.status == VerifyStatus::Ready || f.status == VerifyStatus::AlreadyPatched
157 })
158 .map(|f| PatchEventFile {
159 path: f.file.clone(),
160 verified: true,
161 applied_via: None,
162 })
163 .collect();
164 return PatchEvent::new(PatchAction::Verified, purl).with_files(files);
165 }
166
167 let files = result
168 .files_patched
169 .iter()
170 .map(|f| PatchEventFile {
171 path: f.clone(),
172 verified: true,
173 applied_via: result
174 .applied_via
175 .get(f)
176 .copied()
177 .map(AppliedVia::from_core),
178 })
179 .collect();
180 PatchEvent::new(PatchAction::Applied, purl).with_files(files)
186}
187
188pub async fn run(args: ApplyArgs) -> i32 {
189 apply_env_toggles(&args.common);
190 let (telemetry_client, _) =
191 get_api_client_with_overrides(args.common.api_client_overrides()).await;
192 let api_token = telemetry_client.api_token().cloned();
193 let org_slug = telemetry_client.org_slug().cloned();
194
195 let manifest_path = args.common.resolved_manifest_path();
196
197 if tokio::fs::metadata(&manifest_path).await.is_err() {
199 if args.common.json {
200 let mut env = Envelope::new(Command::Apply);
201 env.status = Status::NoManifest;
202 env.dry_run = args.common.dry_run;
203 println!("{}", env.to_pretty_json());
204 } else if !args.common.silent {
205 println!("No .socket folder found, skipping patch application.");
206 }
207 return 0;
208 }
209
210 let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
214 let acquired = match acquire_or_emit(
215 socket_dir,
216 Command::Apply,
217 args.common.json,
218 args.common.silent,
219 args.common.dry_run,
220 Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
221 args.common.break_lock,
222 ) {
223 Ok(acquired) => acquired,
224 Err(code) => return code,
225 };
226 let _lock = acquired.guard;
227 let lock_was_broken = acquired.broke_lock;
228
229 let pkg_manager = detect_npm_pkg_manager(&args.common.cwd);
236 match pkg_manager {
237 NpmPkgManager::YarnBerryPnP => {
238 if args.common.json {
239 let mut env = Envelope::new(Command::Apply);
240 env.dry_run = args.common.dry_run;
241 env.mark_error(EnvelopeError::new(
242 "yarn_pnp_unsupported",
243 "yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
244 ));
245 println!("{}", env.to_pretty_json());
246 } else if !args.common.silent {
247 eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
248 eprintln!(
249 " Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
250 );
251 eprintln!(" Use `yarn patch <pkg>` instead.");
252 }
253 return 1;
254 }
255 NpmPkgManager::Pnpm => {
256 if !args.common.json && !args.common.silent {
257 eprintln!(
258 "Note: pnpm layout detected. Copy-on-write will keep the global store untouched."
259 );
260 }
261 }
265 NpmPkgManager::Bun => {
266 if !args.common.json && !args.common.silent {
267 eprintln!(
268 "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched."
269 );
270 }
271 }
275 _ => {}
276 }
277
278 match apply_patches_inner(&args, &manifest_path).await {
279 Ok((success, results, unmatched)) => {
280 let patched_count = results
281 .iter()
282 .filter(|r| r.success && !r.files_patched.is_empty())
283 .count();
284
285 if args.common.json {
286 let mut env = Envelope::new(Command::Apply);
287 env.dry_run = args.common.dry_run;
288 if lock_was_broken {
289 env.record(lock_broken_event(socket_dir));
290 }
291 for result in &results {
292 env.record(result_to_event(result, args.common.dry_run));
293 if let Some(ref sidecar) = result.sidecar {
298 env.record_sidecar(sidecar.clone());
299 }
300 }
301 for purl in &unmatched {
305 env.record(
306 PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason(
307 "package_not_installed",
308 "No installed package matches this PURL",
309 ),
310 );
311 }
312 if !success {
313 env.mark_partial_failure();
314 }
315 println!("{}", env.to_pretty_json());
316 } else if !args.common.silent && !results.is_empty() {
317 let patched: Vec<_> = results.iter().filter(|r| r.success).collect();
318 let already_patched: Vec<_> = results
319 .iter()
320 .filter(|r| all_files_already_patched(r))
321 .collect();
322
323 if args.common.dry_run {
324 let can_be_patched = patched.len().saturating_sub(already_patched.len());
329 println!("\nPatch verification complete:");
330 println!(" {} package(s) can be patched", can_be_patched);
331 if !already_patched.is_empty() {
332 println!(" {} package(s) already patched", already_patched.len());
333 }
334 } else {
335 println!("\nPatched packages:");
336 for result in &patched {
337 if !result.files_patched.is_empty() {
338 let mut tags: Vec<&'static str> = result
343 .applied_via
344 .values()
345 .map(|v| v.as_tag())
346 .collect();
347 tags.sort_unstable();
348 tags.dedup();
349 let suffix = if tags.is_empty() {
350 String::new()
351 } else {
352 format!(" (via {})", tags.join("+"))
353 };
354 println!(" {}{}", result.package_key, suffix);
355 } else if all_files_already_patched(result) {
356 println!(" {} (already patched)", result.package_key);
357 }
358 }
359 }
360
361 if args.common.verbose {
362 println!("\nDetailed verification:");
363 for result in &results {
364 println!(" {}:", result.package_key);
365 for f in &result.files_verified {
366 let status_str = match f.status {
367 VerifyStatus::Ready => "ready",
368 VerifyStatus::AlreadyPatched => "already patched",
369 VerifyStatus::HashMismatch => "hash mismatch",
370 VerifyStatus::NotFound => "not found",
371 };
372 println!(" {} [{}]", f.file, status_str);
373 if let Some(ref msg) = f.message {
374 println!(" message: {msg}");
375 }
376 if args.common.verbose {
377 if let Some(ref h) = f.current_hash {
378 println!(" current: {h}");
379 }
380 if let Some(ref h) = f.expected_hash {
381 println!(" expected: {h}");
382 }
383 if let Some(ref h) = f.target_hash {
384 println!(" target: {h}");
385 }
386 }
387 }
388 }
389 }
390 }
391
392 if success {
394 track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
395 } else {
396 track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
397 }
398
399 if success { 0 } else { 1 }
400 }
401 Err(e) => {
402 track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
403 if args.common.json {
404 let mut env = Envelope::new(Command::Apply);
405 env.dry_run = args.common.dry_run;
406 env.mark_error(EnvelopeError::new("apply_failed", e.clone()));
407 println!("{}", env.to_pretty_json());
408 } else if !args.common.silent {
409 eprintln!("Error: {e}");
410 }
411 1
412 }
413 }
414}
415
416async fn apply_patches_inner(
417 args: &ApplyArgs,
418 manifest_path: &Path,
419) -> Result<(bool, Vec<ApplyResult>, Vec<String>), String> {
420 let manifest = read_manifest(manifest_path)
421 .await
422 .map_err(|e| e.to_string())?
423 .ok_or_else(|| "Invalid manifest".to_string())?;
424
425 let socket_dir = manifest_path.parent().unwrap();
429 let socket_blobs_path = socket_dir.join("blobs");
430 let socket_diffs_path = socket_dir.join("diffs");
431 let socket_packages_path = socket_dir.join("packages");
432
433 let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?;
434
435 let missing_blobs = get_missing_blobs(&manifest, &socket_blobs_path).await;
439 let missing_diff_archives = get_missing_archives(&manifest, &socket_diffs_path).await;
440 let missing_package_archives = get_missing_archives(&manifest, &socket_packages_path).await;
441
442 let patches_without_source: Vec<&str> = manifest
448 .patches
449 .iter()
450 .filter_map(|(purl, record)| {
451 let all_blobs_present = record
452 .files
453 .values()
454 .all(|f| !missing_blobs.contains(&f.after_hash));
455 let diff_present = !missing_diff_archives.contains(&record.uuid);
456 let pkg_present = !missing_package_archives.contains(&record.uuid);
457 if all_blobs_present || diff_present || pkg_present {
458 None
459 } else {
460 Some(purl.as_str())
461 }
462 })
463 .collect();
464
465 if args.common.offline {
466 if !patches_without_source.is_empty() {
471 if !args.common.silent && !args.common.json {
472 eprintln!(
473 "Error: {} patch(es) have no local source and --offline is set:",
474 patches_without_source.len()
475 );
476 for purl in patches_without_source.iter().take(5) {
477 eprintln!(" - {}", purl);
478 }
479 if patches_without_source.len() > 5 {
480 eprintln!(" ... and {} more", patches_without_source.len() - 5);
481 }
482 eprintln!("Run \"socket-patch repair\" to download missing artifacts.");
483 }
484 return Ok((false, Vec::new(), Vec::new()));
485 }
486 }
487
488 let download_needed = !args.common.offline
497 && match download_mode {
498 DownloadMode::File => !missing_blobs.is_empty(),
499 DownloadMode::Diff | DownloadMode::Package if missing_blobs.is_empty() => false,
500 DownloadMode::Diff => !missing_diff_archives.is_empty(),
501 DownloadMode::Package => !missing_package_archives.is_empty(),
502 };
503
504 let (blobs_path, diffs_path, packages_path, _stage_dir): (
517 PathBuf,
518 PathBuf,
519 PathBuf,
520 Option<TempDir>,
521 ) = if download_needed {
522 let stage = tempfile::tempdir().map_err(|e| e.to_string())?;
523 let stage_blobs = stage.path().join("blobs");
524 let stage_diffs = stage.path().join("diffs");
525 let stage_packages = stage.path().join("packages");
526 for dir in [&stage_blobs, &stage_diffs, &stage_packages] {
527 tokio::fs::create_dir_all(dir)
528 .await
529 .map_err(|e| e.to_string())?;
530 }
531 overlay_dir(&socket_blobs_path, &stage_blobs).await;
532 overlay_dir(&socket_diffs_path, &stage_diffs).await;
533 overlay_dir(&socket_packages_path, &stage_packages).await;
534
535 if !args.common.silent && !args.common.json {
536 println!(
537 "Downloading missing patch artifacts (mode: {})...",
538 download_mode.as_tag()
539 );
540 }
541
542 let (client, _) =
543 get_api_client_with_overrides(args.common.api_client_overrides()).await;
544 let sources = PatchSources {
545 blobs_path: &stage_blobs,
546 packages_path: Some(&stage_packages),
547 diffs_path: Some(&stage_diffs),
548 };
549 let fetch_result =
550 fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await;
551
552 if !args.common.silent && !args.common.json {
553 println!("{}", format_fetch_result(&fetch_result));
554 }
555
556 if download_mode != DownloadMode::File {
560 let still_missing_blobs = get_missing_blobs(&manifest, &stage_blobs).await;
561 if !still_missing_blobs.is_empty() {
562 if !args.common.silent && !args.common.json {
563 println!(
564 "Falling back to per-file blob downloads for {} blob(s)...",
565 still_missing_blobs.len()
566 );
567 }
568 let blob_result =
569 fetch_missing_blobs(&manifest, &stage_blobs, &client, None).await;
570 if !args.common.silent && !args.common.json {
571 println!("{}", format_fetch_result(&blob_result));
572 }
573 if blob_result.failed > 0 && fetch_result.failed > 0 {
574 if !args.common.silent && !args.common.json {
575 eprintln!("Some artifacts could not be downloaded. Cannot apply patches.");
576 }
577 return Ok((false, Vec::new(), Vec::new()));
578 }
579 }
580 } else if fetch_result.failed > 0 {
581 if !args.common.silent && !args.common.json {
582 eprintln!("Some blobs could not be downloaded. Cannot apply patches.");
583 }
584 return Ok((false, Vec::new(), Vec::new()));
585 }
586
587 (stage_blobs, stage_diffs, stage_packages, Some(stage))
588 } else {
589 (
590 socket_blobs_path.clone(),
591 socket_diffs_path.clone(),
592 socket_packages_path.clone(),
593 None,
594 )
595 };
596
597 let manifest_purls: Vec<String> = manifest.patches.keys().cloned().collect();
599 let partitioned =
600 partition_purls(&manifest_purls, args.common.ecosystems.as_deref());
601
602 let target_manifest_purls: HashSet<String> = partitioned
603 .values()
604 .flat_map(|purls| purls.iter().cloned())
605 .collect();
606
607 let crawler_options = CrawlerOptions {
608 cwd: args.common.cwd.clone(),
609 global: args.common.global,
610 global_prefix: args.common.global_prefix.clone(),
611 batch_size: 100,
612 };
613
614 let all_packages =
615 find_packages_for_purls(&partitioned, &crawler_options, args.common.silent || args.common.json).await;
616
617 let has_any_purls = !partitioned.is_empty();
618
619 if all_packages.is_empty() && !has_any_purls {
620 if !args.common.silent && !args.common.json {
621 if args.common.global || args.common.global_prefix.is_some() {
622 eprintln!("No global packages found");
623 } else {
624 eprintln!("No package directories found");
625 }
626 }
627 return Ok((false, Vec::new(), Vec::new()));
628 }
629
630 if all_packages.is_empty() {
631 if !args.common.silent && !args.common.json {
632 eprintln!("Warning: No packages found that match available patches");
633 eprintln!(
634 " {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.",
635 target_manifest_purls.len()
636 );
637 eprintln!(" Check that packages are installed and --cwd points to the right directory.");
638 }
639 let unmatched: Vec<String> = target_manifest_purls.iter().cloned().collect();
640 return Ok((false, Vec::new(), unmatched));
641 }
642
643 let mut results: Vec<ApplyResult> = Vec::new();
645 let mut has_errors = false;
646
647 let mut variant_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
653 for (eco, purls) in &partitioned {
654 if eco.supports_release_variants() {
655 for purl in purls {
656 variant_qualified_groups
657 .entry(strip_purl_qualifiers(purl).to_string())
658 .or_default()
659 .push(purl.clone());
660 }
661 }
662 }
663
664 let mut applied_base_purls: HashSet<String> = HashSet::new();
665 let mut matched_manifest_purls: HashSet<String> = HashSet::new();
666
667 for (purl, pkg_path) in &all_packages {
668 if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) {
669 let base_purl = strip_purl_qualifiers(purl).to_string();
670 if applied_base_purls.contains(&base_purl) {
671 continue;
672 }
673
674 let variants = variant_qualified_groups
675 .get(&base_purl)
676 .cloned()
677 .unwrap_or_else(|| vec![base_purl.clone()]);
678 let mut applied = false;
679
680 for variant_purl in &variants {
681 let patch = match manifest.patches.get(variant_purl) {
682 Some(p) => p,
683 None => continue,
684 };
685
686 if !args.force {
692 let first_status = match patch.files.iter().next() {
693 Some((file_name, file_info)) => {
694 Some(verify_file_patch(pkg_path, file_name, file_info).await.status)
695 }
696 None => None,
697 };
698 if !variant_matches_installed(first_status.as_ref()) {
699 continue;
700 }
701 }
702
703 let sources = PatchSources {
704 blobs_path: &blobs_path,
705 packages_path: Some(&packages_path),
706 diffs_path: Some(&diffs_path),
707 };
708 let result = apply_package_patch(
709 variant_purl,
710 pkg_path,
711 &patch.files,
712 &sources,
713 Some(&patch.uuid),
714 args.common.dry_run,
715 args.force,
716 )
717 .await;
718
719 if result.success {
720 applied = true;
721 results.push(result);
722 matched_manifest_purls.insert(variant_purl.clone());
723 } else {
729 results.push(result);
730 }
731 }
732
733 if applied {
734 applied_base_purls.insert(base_purl.clone());
735 } else {
736 has_errors = true;
737 if !args.common.silent && !args.common.json {
738 eprintln!("Failed to patch {base_purl}: no matching variant found");
739 }
740 }
741 } else {
742 let patch = match manifest.patches.get(purl) {
744 Some(p) => p,
745 None => continue,
746 };
747
748 let sources = PatchSources {
749 blobs_path: &blobs_path,
750 packages_path: Some(&packages_path),
751 diffs_path: Some(&diffs_path),
752 };
753 let result = apply_package_patch(
754 purl,
755 pkg_path,
756 &patch.files,
757 &sources,
758 Some(&patch.uuid),
759 args.common.dry_run,
760 args.force,
761 )
762 .await;
763
764 if !result.success {
765 has_errors = true;
766 if !args.common.silent && !args.common.json {
767 eprintln!(
768 "Failed to patch {}: {}",
769 purl,
770 result.error.as_deref().unwrap_or("unknown error")
771 );
772 }
773 }
774 results.push(result);
775 matched_manifest_purls.insert(purl.clone());
776 }
777 }
778
779 let unmatched: Vec<String> = target_manifest_purls
781 .iter()
782 .filter(|p| !matched_manifest_purls.contains(*p))
783 .cloned()
784 .collect();
785
786 if !unmatched.is_empty() && !args.common.silent && !args.common.json {
787 eprintln!("\nWarning: {} manifest patch(es) had no matching installed package:", unmatched.len());
788 for purl in &unmatched {
789 eprintln!(" - {}", purl);
790 }
791 }
792
793 if !target_manifest_purls.is_empty() && matched_manifest_purls.is_empty() && !all_packages.is_empty() {
794 if !args.common.silent && !args.common.json {
795 eprintln!("Warning: None of the targeted manifest patches matched installed packages.");
796 }
797 has_errors = true;
798 }
799
800 if !args.common.silent && !args.common.json {
802 let applied_count = results.iter().filter(|r| r.success && !r.files_patched.is_empty()).count();
803 let already_count = results.iter().filter(|r| all_files_already_patched(r)).count();
804 println!(
805 "\nSummary: {}/{} targeted patches applied, {} already patched, {} not found on disk",
806 applied_count,
807 target_manifest_purls.len(),
808 already_count,
809 unmatched.len()
810 );
811 }
812
813 Ok((!has_errors, results, unmatched))
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
829 use socket_patch_core::patch::apply::{
830 AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
831 };
832
833 fn sample_applied(status: VerifyStatus) -> ApplyResult {
836 let mut applied_via = HashMap::new();
837 applied_via.insert("package/index.js".to_string(), CoreAppliedVia::Diff);
838 ApplyResult {
839 package_key: "pkg:npm/minimist@1.2.2".to_string(),
840 package_path: "/tmp/node_modules/minimist".to_string(),
841 success: true,
842 files_verified: vec![VerifyResult {
843 file: "package/index.js".to_string(),
844 status,
845 message: None,
846 current_hash: None,
847 expected_hash: None,
848 target_hash: None,
849 }],
850 files_patched: vec!["package/index.js".to_string()],
851 applied_via,
852 error: None,
853 sidecar: None,
854 }
855 }
856
857 #[test]
858 fn failed_result_maps_to_failed_action() {
859 let mut result = sample_applied(VerifyStatus::Ready);
860 result.success = false;
861 result.error = Some("hash mismatch".into());
862
863 let event = result_to_event(&result, false);
864 let v: serde_json::Value =
865 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
866 assert_eq!(v["action"], "failed");
867 assert_eq!(v["errorCode"], "apply_failed");
868 assert_eq!(v["error"], "hash mismatch");
869 }
870
871 #[test]
872 fn all_already_patched_maps_to_skipped() {
873 let result = sample_applied(VerifyStatus::AlreadyPatched);
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 assert_eq!(v["action"], "skipped");
878 assert_eq!(v["errorCode"], "already_patched");
879 }
880
881 #[test]
882 fn dry_run_maps_to_verified() {
883 let result = sample_applied(VerifyStatus::Ready);
884 let event = result_to_event(&result, true);
885 let v: serde_json::Value =
886 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
887 assert_eq!(v["action"], "verified");
888 assert_eq!(v["files"][0]["path"], "package/index.js");
891 assert!(v["files"][0].as_object().unwrap().get("appliedVia").is_none());
892 }
893
894 #[test]
895 fn successful_apply_maps_to_applied_with_files() {
896 let result = sample_applied(VerifyStatus::Ready);
897 let event = result_to_event(&result, false);
898 let v: serde_json::Value =
899 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
900 assert_eq!(v["action"], "applied");
901 assert_eq!(v["purl"], "pkg:npm/minimist@1.2.2");
902 let files = v["files"].as_array().unwrap();
903 assert_eq!(files.len(), 1);
904 assert_eq!(files[0]["path"], "package/index.js");
905 assert_eq!(files[0]["verified"], true);
906 assert_eq!(files[0]["appliedVia"], "diff");
908 }
909
910 #[test]
911 fn applied_event_emits_one_file_entry_per_patched_file() {
912 let mut applied_via = HashMap::new();
913 applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff);
914 applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Package);
915 applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob);
916 let result = ApplyResult {
917 package_key: "pkg:npm/foo@1.0.0".to_string(),
918 package_path: "/tmp/foo".to_string(),
919 success: true,
920 files_verified: Vec::new(),
921 files_patched: vec![
922 "package/a.js".to_string(),
923 "package/b.js".to_string(),
924 "package/c.js".to_string(),
925 ],
926 applied_via,
927 error: None,
928 sidecar: None,
929 };
930
931 let event = result_to_event(&result, false);
932 let v: serde_json::Value =
933 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
934 let files = v["files"].as_array().unwrap();
935 assert_eq!(files.len(), 3);
936 let by_path: std::collections::HashMap<String, &serde_json::Value> = files
937 .iter()
938 .map(|f| (f["path"].as_str().unwrap().to_string(), f))
939 .collect();
940 assert_eq!(by_path["package/a.js"]["appliedVia"], "diff");
941 assert_eq!(by_path["package/b.js"]["appliedVia"], "package");
942 assert_eq!(by_path["package/c.js"]["appliedVia"], "blob");
943 }
944
945 fn sample_verified(statuses: &[VerifyStatus]) -> ApplyResult {
949 let files_verified = statuses
950 .iter()
951 .enumerate()
952 .map(|(i, status)| VerifyResult {
953 file: format!("package/f{i}.js"),
954 status: status.clone(),
955 message: None,
956 current_hash: None,
957 expected_hash: None,
958 target_hash: None,
959 })
960 .collect();
961 ApplyResult {
962 package_key: "pkg:npm/foo@1.0.0".to_string(),
963 package_path: "/tmp/foo".to_string(),
964 success: true,
965 files_verified,
966 files_patched: Vec::new(),
967 applied_via: HashMap::new(),
968 error: None,
969 sidecar: None,
970 }
971 }
972
973 #[test]
974 fn all_files_already_patched_true_when_every_file_matches() {
975 let result = sample_verified(&[
976 VerifyStatus::AlreadyPatched,
977 VerifyStatus::AlreadyPatched,
978 ]);
979 assert!(all_files_already_patched(&result));
980 }
981
982 #[test]
983 fn all_files_already_patched_false_when_any_file_differs() {
984 let result = sample_verified(&[
985 VerifyStatus::AlreadyPatched,
986 VerifyStatus::Ready,
987 ]);
988 assert!(!all_files_already_patched(&result));
989 }
990
991 #[test]
996 fn all_files_already_patched_false_when_no_verified_files() {
997 let mut result = sample_verified(&[]);
998 assert!(result.files_verified.is_empty());
999 assert!(!all_files_already_patched(&result));
1000
1001 result.files_patched = vec!["package/a.js".to_string()];
1004 assert!(!all_files_already_patched(&result));
1005 }
1006
1007 #[test]
1016 fn variant_matches_only_when_first_file_ready_or_already_patched() {
1017 assert!(variant_matches_installed(Some(&VerifyStatus::Ready)));
1020 assert!(variant_matches_installed(Some(&VerifyStatus::AlreadyPatched)));
1021
1022 assert!(!variant_matches_installed(Some(&VerifyStatus::HashMismatch)));
1025 assert!(!variant_matches_installed(Some(&VerifyStatus::NotFound)));
1026
1027 assert!(variant_matches_installed(None));
1030 }
1031
1032 #[test]
1037 fn applied_with_empty_verified_is_not_skipped() {
1038 let mut applied_via = HashMap::new();
1039 applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Blob);
1040 let result = ApplyResult {
1041 package_key: "pkg:npm/foo@1.0.0".to_string(),
1042 package_path: "/tmp/foo".to_string(),
1043 success: true,
1044 files_verified: Vec::new(),
1045 files_patched: vec!["package/a.js".to_string()],
1046 applied_via,
1047 error: None,
1048 sidecar: None,
1049 };
1050 let event = result_to_event(&result, false);
1051 let v: serde_json::Value =
1052 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
1053 assert_eq!(v["action"], "applied");
1054 }
1055}