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::commands::vex::{generate_vex_from_manifest_path, VexEmbedArgs};
25use crate::json_envelope::{
26 AppliedVia, Command, Envelope, EnvelopeError, PatchAction, PatchEvent, PatchEventFile, Status,
27 VexSummary,
28};
29
30async fn overlay_dir(src: &Path, dst: &Path) {
40 let mut entries = match tokio::fs::read_dir(src).await {
41 Ok(e) => e,
42 Err(_) => return,
43 };
44 while let Ok(Some(entry)) = entries.next_entry().await {
45 let file_type = match entry.file_type().await {
46 Ok(t) => t,
47 Err(_) => continue,
48 };
49 if !file_type.is_file() {
50 continue;
51 }
52 let from = entry.path();
53 let to = dst.join(entry.file_name());
54 if tokio::fs::metadata(&to).await.is_ok() {
55 continue;
56 }
57 if tokio::fs::hard_link(&from, &to).await.is_err() {
58 let _ = tokio::fs::copy(&from, &to).await;
59 }
60 }
61}
62
63use crate::ecosystem_dispatch::{find_packages_for_purls, partition_purls};
64
65#[derive(Args)]
66pub struct ApplyArgs {
67 #[command(flatten)]
68 pub common: GlobalArgs,
69
70 #[arg(short = 'f', long, env = "SOCKET_FORCE", default_value_t = false)]
72 pub force: bool,
73
74 #[command(flatten)]
79 pub vex: VexEmbedArgs,
80}
81
82fn all_files_already_patched(result: &ApplyResult) -> bool {
95 !result.files_verified.is_empty()
96 && result
97 .files_verified
98 .iter()
99 .all(|f| f.status == VerifyStatus::AlreadyPatched)
100}
101
102fn variant_matches_installed(first_file_status: Option<&VerifyStatus>) -> bool {
123 match first_file_status {
124 None => true,
125 Some(status) => {
126 *status == VerifyStatus::Ready || *status == VerifyStatus::AlreadyPatched
127 }
128 }
129}
130
131pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent {
144 let purl = result.package_key.clone();
145 if !result.success {
146 return PatchEvent::new(PatchAction::Failed, purl).with_error(
147 "apply_failed",
148 result
149 .error
150 .clone()
151 .unwrap_or_else(|| "unknown error".to_string()),
152 );
153 }
154
155 if all_files_already_patched(result) {
156 return PatchEvent::new(PatchAction::Skipped, purl)
157 .with_reason("already_patched", "All files already match afterHash");
158 }
159
160 if dry_run {
161 let files = result
162 .files_verified
163 .iter()
164 .filter(|f| {
165 f.status == VerifyStatus::Ready || f.status == VerifyStatus::AlreadyPatched
166 })
167 .map(|f| PatchEventFile {
168 path: f.file.clone(),
169 verified: true,
170 applied_via: None,
171 })
172 .collect();
173 return PatchEvent::new(PatchAction::Verified, purl).with_files(files);
174 }
175
176 let files = result
177 .files_patched
178 .iter()
179 .map(|f| PatchEventFile {
180 path: f.clone(),
181 verified: true,
182 applied_via: result
183 .applied_via
184 .get(f)
185 .copied()
186 .map(AppliedVia::from_core),
187 })
188 .collect();
189 PatchEvent::new(PatchAction::Applied, purl).with_files(files)
195}
196
197pub async fn run(args: ApplyArgs) -> i32 {
198 apply_env_toggles(&args.common);
199 let (telemetry_client, _) =
200 get_api_client_with_overrides(args.common.api_client_overrides()).await;
201 let api_token = telemetry_client.api_token().cloned();
202 let org_slug = telemetry_client.org_slug().cloned();
203
204 let manifest_path = args.common.resolved_manifest_path();
205
206 if tokio::fs::metadata(&manifest_path).await.is_err() {
208 if args.common.json {
209 let mut env = Envelope::new(Command::Apply);
210 env.status = Status::NoManifest;
211 env.dry_run = args.common.dry_run;
212 println!("{}", env.to_pretty_json());
213 } else if !args.common.silent {
214 println!("No .socket folder found, skipping patch application.");
215 }
216 return 0;
217 }
218
219 let socket_dir = manifest_path.parent().unwrap_or(Path::new("."));
223 let acquired = match acquire_or_emit(
224 socket_dir,
225 Command::Apply,
226 args.common.json,
227 args.common.silent,
228 args.common.dry_run,
229 Duration::from_secs(args.common.lock_timeout.unwrap_or(0)),
230 args.common.break_lock,
231 ) {
232 Ok(acquired) => acquired,
233 Err(code) => return code,
234 };
235 let _lock = acquired.guard;
236 let lock_was_broken = acquired.broke_lock;
237
238 let pkg_manager = detect_npm_pkg_manager(&args.common.cwd);
245 match pkg_manager {
246 NpmPkgManager::YarnBerryPnP => {
247 if args.common.json {
248 let mut env = Envelope::new(Command::Apply);
249 env.dry_run = args.common.dry_run;
250 env.mark_error(EnvelopeError::new(
251 "yarn_pnp_unsupported",
252 "yarn-berry Plug'n'Play layout is not supported by socket-patch (packages live inside .yarn/cache zips). Use `yarn patch <pkg>` instead.",
253 ));
254 println!("{}", env.to_pretty_json());
255 } else if !args.common.silent {
256 eprintln!("Error: yarn-berry Plug'n'Play layout is not supported.");
257 eprintln!(
258 " Packages live inside .yarn/cache/*.zip — socket-patch cannot rewrite them in place."
259 );
260 eprintln!(" Use `yarn patch <pkg>` instead.");
261 }
262 return 1;
263 }
264 NpmPkgManager::Pnpm => {
265 if !args.common.json && !args.common.silent {
266 eprintln!(
267 "Note: pnpm layout detected. Copy-on-write will keep the global store untouched."
268 );
269 }
270 }
274 NpmPkgManager::Bun => {
275 if !args.common.json && !args.common.silent {
276 eprintln!(
277 "Note: bun layout detected. Copy-on-write will keep ~/.bun/install/cache/ untouched."
278 );
279 }
280 }
284 _ => {}
285 }
286
287 match apply_patches_inner(&args, &manifest_path).await {
288 Ok((success, results, unmatched)) => {
289 let patched_count = results
290 .iter()
291 .filter(|r| r.success && !r.files_patched.is_empty())
292 .count();
293
294 let vex_result = if success && args.vex.vex.is_some() {
301 let params = args.vex.to_build_params();
302 Some(
303 generate_vex_from_manifest_path(&args.common, ¶ms, &manifest_path)
304 .await,
305 )
306 } else {
307 None
308 };
309 let vex_failed = matches!(vex_result, Some(Err(_)));
310
311 if args.common.json {
312 let mut env = Envelope::new(Command::Apply);
313 env.dry_run = args.common.dry_run;
314 if lock_was_broken {
315 env.record(lock_broken_event(socket_dir));
316 }
317 for result in &results {
318 env.record(result_to_event(result, args.common.dry_run));
319 if let Some(ref sidecar) = result.sidecar {
324 env.record_sidecar(sidecar.clone());
325 }
326 }
327 for purl in &unmatched {
331 env.record(
332 PatchEvent::new(PatchAction::Skipped, purl.clone()).with_reason(
333 "package_not_installed",
334 "No installed package matches this PURL",
335 ),
336 );
337 }
338 if !success {
339 env.mark_partial_failure();
340 }
341 match &vex_result {
342 Some(Ok(summary)) => {
343 env.vex = Some(VexSummary {
344 path: args.vex.vex.as_ref().unwrap().display().to_string(),
345 statements: summary.statements,
346 format: "openvex-0.2.0".to_string(),
347 });
348 }
349 Some(Err(e)) => {
350 env.mark_error(EnvelopeError::new(e.code, e.message.clone()));
351 }
352 None => {}
353 }
354 println!("{}", env.to_pretty_json());
355 } else if !args.common.silent && !results.is_empty() {
356 let patched: Vec<_> = results.iter().filter(|r| r.success).collect();
357 let already_patched: Vec<_> = results
358 .iter()
359 .filter(|r| all_files_already_patched(r))
360 .collect();
361
362 if args.common.dry_run {
363 let can_be_patched = patched.len().saturating_sub(already_patched.len());
368 println!("\nPatch verification complete:");
369 println!(" {} package(s) can be patched", can_be_patched);
370 if !already_patched.is_empty() {
371 println!(" {} package(s) already patched", already_patched.len());
372 }
373 } else {
374 println!("\nPatched packages:");
375 for result in &patched {
376 if !result.files_patched.is_empty() {
377 let mut tags: Vec<&'static str> = result
382 .applied_via
383 .values()
384 .map(|v| v.as_tag())
385 .collect();
386 tags.sort_unstable();
387 tags.dedup();
388 let suffix = if tags.is_empty() {
389 String::new()
390 } else {
391 format!(" (via {})", tags.join("+"))
392 };
393 println!(" {}{}", result.package_key, suffix);
394 } else if all_files_already_patched(result) {
395 println!(" {} (already patched)", result.package_key);
396 }
397 }
398 }
399
400 if args.common.verbose {
401 println!("\nDetailed verification:");
402 for result in &results {
403 println!(" {}:", result.package_key);
404 for f in &result.files_verified {
405 let status_str = match f.status {
406 VerifyStatus::Ready => "ready",
407 VerifyStatus::AlreadyPatched => "already patched",
408 VerifyStatus::HashMismatch => "hash mismatch",
409 VerifyStatus::NotFound => "not found",
410 };
411 println!(" {} [{}]", f.file, status_str);
412 if let Some(ref msg) = f.message {
413 println!(" message: {msg}");
414 }
415 if args.common.verbose {
416 if let Some(ref h) = f.current_hash {
417 println!(" current: {h}");
418 }
419 if let Some(ref h) = f.expected_hash {
420 println!(" expected: {h}");
421 }
422 if let Some(ref h) = f.target_hash {
423 println!(" target: {h}");
424 }
425 }
426 }
427 }
428 }
429 }
430
431 if !args.common.json && !args.common.silent {
434 match &vex_result {
435 Some(Ok(summary)) => {
436 println!(
437 "Wrote OpenVEX document with {} statement(s) to {}",
438 summary.statements,
439 args.vex.vex.as_ref().unwrap().display(),
440 );
441 }
442 Some(Err(e)) => {
443 eprintln!("Error: VEX generation failed: {}", e.message);
444 }
445 None => {}
446 }
447 }
448
449 if success {
451 track_patch_applied(patched_count, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
452 } else {
453 track_patch_apply_failed("One or more patches failed to apply", args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
454 }
455
456 if success && !vex_failed {
459 0
460 } else {
461 1
462 }
463 }
464 Err(e) => {
465 track_patch_apply_failed(&e, args.common.dry_run, api_token.as_deref(), org_slug.as_deref()).await;
466 if args.common.json {
467 let mut env = Envelope::new(Command::Apply);
468 env.dry_run = args.common.dry_run;
469 env.mark_error(EnvelopeError::new("apply_failed", e.clone()));
470 println!("{}", env.to_pretty_json());
471 } else if !args.common.silent {
472 eprintln!("Error: {e}");
473 }
474 1
475 }
476 }
477}
478
479async fn apply_patches_inner(
480 args: &ApplyArgs,
481 manifest_path: &Path,
482) -> Result<(bool, Vec<ApplyResult>, Vec<String>), String> {
483 let manifest = read_manifest(manifest_path)
484 .await
485 .map_err(|e| e.to_string())?
486 .ok_or_else(|| "Invalid manifest".to_string())?;
487
488 let socket_dir = manifest_path.parent().unwrap();
492 let socket_blobs_path = socket_dir.join("blobs");
493 let socket_diffs_path = socket_dir.join("diffs");
494 let socket_packages_path = socket_dir.join("packages");
495
496 let download_mode = DownloadMode::parse(&args.common.download_mode).map_err(|e| e.to_string())?;
497
498 let missing_blobs = get_missing_blobs(&manifest, &socket_blobs_path).await;
502 let missing_diff_archives = get_missing_archives(&manifest, &socket_diffs_path).await;
503 let missing_package_archives = get_missing_archives(&manifest, &socket_packages_path).await;
504
505 let patches_without_source: Vec<&str> = manifest
511 .patches
512 .iter()
513 .filter_map(|(purl, record)| {
514 let all_blobs_present = record
515 .files
516 .values()
517 .all(|f| !missing_blobs.contains(&f.after_hash));
518 let diff_present = !missing_diff_archives.contains(&record.uuid);
519 let pkg_present = !missing_package_archives.contains(&record.uuid);
520 if all_blobs_present || diff_present || pkg_present {
521 None
522 } else {
523 Some(purl.as_str())
524 }
525 })
526 .collect();
527
528 if args.common.offline {
529 if !patches_without_source.is_empty() {
534 if !args.common.silent && !args.common.json {
535 eprintln!(
536 "Error: {} patch(es) have no local source and --offline is set:",
537 patches_without_source.len()
538 );
539 for purl in patches_without_source.iter().take(5) {
540 eprintln!(" - {}", purl);
541 }
542 if patches_without_source.len() > 5 {
543 eprintln!(" ... and {} more", patches_without_source.len() - 5);
544 }
545 eprintln!("Run \"socket-patch repair\" to download missing artifacts.");
546 }
547 return Ok((false, Vec::new(), Vec::new()));
548 }
549 }
550
551 let download_needed = !args.common.offline
560 && match download_mode {
561 DownloadMode::File => !missing_blobs.is_empty(),
562 DownloadMode::Diff | DownloadMode::Package if missing_blobs.is_empty() => false,
563 DownloadMode::Diff => !missing_diff_archives.is_empty(),
564 DownloadMode::Package => !missing_package_archives.is_empty(),
565 };
566
567 let (blobs_path, diffs_path, packages_path, _stage_dir): (
580 PathBuf,
581 PathBuf,
582 PathBuf,
583 Option<TempDir>,
584 ) = if download_needed {
585 let stage = tempfile::tempdir().map_err(|e| e.to_string())?;
586 let stage_blobs = stage.path().join("blobs");
587 let stage_diffs = stage.path().join("diffs");
588 let stage_packages = stage.path().join("packages");
589 for dir in [&stage_blobs, &stage_diffs, &stage_packages] {
590 tokio::fs::create_dir_all(dir)
591 .await
592 .map_err(|e| e.to_string())?;
593 }
594 overlay_dir(&socket_blobs_path, &stage_blobs).await;
595 overlay_dir(&socket_diffs_path, &stage_diffs).await;
596 overlay_dir(&socket_packages_path, &stage_packages).await;
597
598 if !args.common.silent && !args.common.json {
599 println!(
600 "Downloading missing patch artifacts (mode: {})...",
601 download_mode.as_tag()
602 );
603 }
604
605 let (client, _) =
606 get_api_client_with_overrides(args.common.api_client_overrides()).await;
607 let sources = PatchSources {
608 blobs_path: &stage_blobs,
609 packages_path: Some(&stage_packages),
610 diffs_path: Some(&stage_diffs),
611 };
612 let fetch_result =
613 fetch_missing_sources(&manifest, &sources, download_mode, &client, None).await;
614
615 if !args.common.silent && !args.common.json {
616 println!("{}", format_fetch_result(&fetch_result));
617 }
618
619 if download_mode != DownloadMode::File {
623 let still_missing_blobs = get_missing_blobs(&manifest, &stage_blobs).await;
624 if !still_missing_blobs.is_empty() {
625 if !args.common.silent && !args.common.json {
626 println!(
627 "Falling back to per-file blob downloads for {} blob(s)...",
628 still_missing_blobs.len()
629 );
630 }
631 let blob_result =
632 fetch_missing_blobs(&manifest, &stage_blobs, &client, None).await;
633 if !args.common.silent && !args.common.json {
634 println!("{}", format_fetch_result(&blob_result));
635 }
636 if blob_result.failed > 0 && fetch_result.failed > 0 {
637 if !args.common.silent && !args.common.json {
638 eprintln!("Some artifacts could not be downloaded. Cannot apply patches.");
639 }
640 return Ok((false, Vec::new(), Vec::new()));
641 }
642 }
643 } else if fetch_result.failed > 0 {
644 if !args.common.silent && !args.common.json {
645 eprintln!("Some blobs could not be downloaded. Cannot apply patches.");
646 }
647 return Ok((false, Vec::new(), Vec::new()));
648 }
649
650 (stage_blobs, stage_diffs, stage_packages, Some(stage))
651 } else {
652 (
653 socket_blobs_path.clone(),
654 socket_diffs_path.clone(),
655 socket_packages_path.clone(),
656 None,
657 )
658 };
659
660 let manifest_purls: Vec<String> = manifest.patches.keys().cloned().collect();
662 let partitioned =
663 partition_purls(&manifest_purls, args.common.ecosystems.as_deref());
664
665 let target_manifest_purls: HashSet<String> = partitioned
666 .values()
667 .flat_map(|purls| purls.iter().cloned())
668 .collect();
669
670 let crawler_options = CrawlerOptions {
671 cwd: args.common.cwd.clone(),
672 global: args.common.global,
673 global_prefix: args.common.global_prefix.clone(),
674 batch_size: 100,
675 };
676
677 let all_packages =
678 find_packages_for_purls(&partitioned, &crawler_options, args.common.silent || args.common.json).await;
679
680 let has_any_purls = !partitioned.is_empty();
681
682 if all_packages.is_empty() && !has_any_purls {
683 if !args.common.silent && !args.common.json {
684 if args.common.global || args.common.global_prefix.is_some() {
685 eprintln!("No global packages found");
686 } else {
687 eprintln!("No package directories found");
688 }
689 }
690 return Ok((false, Vec::new(), Vec::new()));
691 }
692
693 if all_packages.is_empty() {
694 if !args.common.silent && !args.common.json {
695 eprintln!("Warning: No packages found that match available patches");
696 eprintln!(
697 " {} targeted manifest patch(es) were in scope, but no matching packages were found on disk.",
698 target_manifest_purls.len()
699 );
700 eprintln!(" Check that packages are installed and --cwd points to the right directory.");
701 }
702 let unmatched: Vec<String> = target_manifest_purls.iter().cloned().collect();
703 return Ok((false, Vec::new(), unmatched));
704 }
705
706 let mut results: Vec<ApplyResult> = Vec::new();
708 let mut has_errors = false;
709
710 let mut variant_qualified_groups: HashMap<String, Vec<String>> = HashMap::new();
716 for (eco, purls) in &partitioned {
717 if eco.supports_release_variants() {
718 for purl in purls {
719 variant_qualified_groups
720 .entry(strip_purl_qualifiers(purl).to_string())
721 .or_default()
722 .push(purl.clone());
723 }
724 }
725 }
726
727 let mut applied_base_purls: HashSet<String> = HashSet::new();
728 let mut matched_manifest_purls: HashSet<String> = HashSet::new();
729
730 for (purl, pkg_path) in &all_packages {
731 if Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants()) {
732 let base_purl = strip_purl_qualifiers(purl).to_string();
733 if applied_base_purls.contains(&base_purl) {
734 continue;
735 }
736
737 let variants = variant_qualified_groups
738 .get(&base_purl)
739 .cloned()
740 .unwrap_or_else(|| vec![base_purl.clone()]);
741 let mut applied = false;
742
743 for variant_purl in &variants {
744 let patch = match manifest.patches.get(variant_purl) {
745 Some(p) => p,
746 None => continue,
747 };
748
749 if !args.force {
755 let first_status = match patch.files.iter().next() {
756 Some((file_name, file_info)) => {
757 Some(verify_file_patch(pkg_path, file_name, file_info).await.status)
758 }
759 None => None,
760 };
761 if !variant_matches_installed(first_status.as_ref()) {
762 continue;
763 }
764 }
765
766 let sources = PatchSources {
767 blobs_path: &blobs_path,
768 packages_path: Some(&packages_path),
769 diffs_path: Some(&diffs_path),
770 };
771 let result = apply_package_patch(
772 variant_purl,
773 pkg_path,
774 &patch.files,
775 &sources,
776 Some(&patch.uuid),
777 args.common.dry_run,
778 args.force,
779 )
780 .await;
781
782 if result.success {
783 applied = true;
784 results.push(result);
785 matched_manifest_purls.insert(variant_purl.clone());
786 } else {
792 results.push(result);
793 }
794 }
795
796 if applied {
797 applied_base_purls.insert(base_purl.clone());
798 } else {
799 has_errors = true;
800 if !args.common.silent && !args.common.json {
801 eprintln!("Failed to patch {base_purl}: no matching variant found");
802 }
803 }
804 } else {
805 let patch = match manifest.patches.get(purl) {
807 Some(p) => p,
808 None => continue,
809 };
810
811 let sources = PatchSources {
812 blobs_path: &blobs_path,
813 packages_path: Some(&packages_path),
814 diffs_path: Some(&diffs_path),
815 };
816 let result = apply_package_patch(
817 purl,
818 pkg_path,
819 &patch.files,
820 &sources,
821 Some(&patch.uuid),
822 args.common.dry_run,
823 args.force,
824 )
825 .await;
826
827 if !result.success {
828 has_errors = true;
829 if !args.common.silent && !args.common.json {
830 eprintln!(
831 "Failed to patch {}: {}",
832 purl,
833 result.error.as_deref().unwrap_or("unknown error")
834 );
835 }
836 }
837 results.push(result);
838 matched_manifest_purls.insert(purl.clone());
839 }
840 }
841
842 let unmatched: Vec<String> = target_manifest_purls
844 .iter()
845 .filter(|p| !matched_manifest_purls.contains(*p))
846 .cloned()
847 .collect();
848
849 if !unmatched.is_empty() && !args.common.silent && !args.common.json {
850 eprintln!("\nWarning: {} manifest patch(es) had no matching installed package:", unmatched.len());
851 for purl in &unmatched {
852 eprintln!(" - {}", purl);
853 }
854 }
855
856 if !target_manifest_purls.is_empty() && matched_manifest_purls.is_empty() && !all_packages.is_empty() {
857 if !args.common.silent && !args.common.json {
858 eprintln!("Warning: None of the targeted manifest patches matched installed packages.");
859 }
860 has_errors = true;
861 }
862
863 if !args.common.silent && !args.common.json {
865 let applied_count = results.iter().filter(|r| r.success && !r.files_patched.is_empty()).count();
866 let already_count = results.iter().filter(|r| all_files_already_patched(r)).count();
867 println!(
868 "\nSummary: {}/{} targeted patches applied, {} already patched, {} not found on disk",
869 applied_count,
870 target_manifest_purls.len(),
871 already_count,
872 unmatched.len()
873 );
874 }
875
876 Ok((!has_errors, results, unmatched))
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
892 use socket_patch_core::patch::apply::{
893 AppliedVia as CoreAppliedVia, ApplyResult, VerifyResult, VerifyStatus,
894 };
895
896 fn sample_applied(status: VerifyStatus) -> ApplyResult {
899 let mut applied_via = HashMap::new();
900 applied_via.insert("package/index.js".to_string(), CoreAppliedVia::Diff);
901 ApplyResult {
902 package_key: "pkg:npm/minimist@1.2.2".to_string(),
903 package_path: "/tmp/node_modules/minimist".to_string(),
904 success: true,
905 files_verified: vec![VerifyResult {
906 file: "package/index.js".to_string(),
907 status,
908 message: None,
909 current_hash: None,
910 expected_hash: None,
911 target_hash: None,
912 }],
913 files_patched: vec!["package/index.js".to_string()],
914 applied_via,
915 error: None,
916 sidecar: None,
917 }
918 }
919
920 #[test]
921 fn failed_result_maps_to_failed_action() {
922 let mut result = sample_applied(VerifyStatus::Ready);
923 result.success = false;
924 result.error = Some("hash mismatch".into());
925
926 let event = result_to_event(&result, false);
927 let v: serde_json::Value =
928 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
929 assert_eq!(v["action"], "failed");
930 assert_eq!(v["errorCode"], "apply_failed");
931 assert_eq!(v["error"], "hash mismatch");
932 }
933
934 #[test]
935 fn all_already_patched_maps_to_skipped() {
936 let result = sample_applied(VerifyStatus::AlreadyPatched);
937 let event = result_to_event(&result, false);
938 let v: serde_json::Value =
939 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
940 assert_eq!(v["action"], "skipped");
941 assert_eq!(v["errorCode"], "already_patched");
942 }
943
944 #[test]
945 fn dry_run_maps_to_verified() {
946 let result = sample_applied(VerifyStatus::Ready);
947 let event = result_to_event(&result, true);
948 let v: serde_json::Value =
949 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
950 assert_eq!(v["action"], "verified");
951 assert_eq!(v["files"][0]["path"], "package/index.js");
954 assert!(v["files"][0].as_object().unwrap().get("appliedVia").is_none());
955 }
956
957 #[test]
958 fn successful_apply_maps_to_applied_with_files() {
959 let result = sample_applied(VerifyStatus::Ready);
960 let event = result_to_event(&result, false);
961 let v: serde_json::Value =
962 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
963 assert_eq!(v["action"], "applied");
964 assert_eq!(v["purl"], "pkg:npm/minimist@1.2.2");
965 let files = v["files"].as_array().unwrap();
966 assert_eq!(files.len(), 1);
967 assert_eq!(files[0]["path"], "package/index.js");
968 assert_eq!(files[0]["verified"], true);
969 assert_eq!(files[0]["appliedVia"], "diff");
971 }
972
973 #[test]
974 fn applied_event_emits_one_file_entry_per_patched_file() {
975 let mut applied_via = HashMap::new();
976 applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Diff);
977 applied_via.insert("package/b.js".to_string(), CoreAppliedVia::Package);
978 applied_via.insert("package/c.js".to_string(), CoreAppliedVia::Blob);
979 let result = ApplyResult {
980 package_key: "pkg:npm/foo@1.0.0".to_string(),
981 package_path: "/tmp/foo".to_string(),
982 success: true,
983 files_verified: Vec::new(),
984 files_patched: vec![
985 "package/a.js".to_string(),
986 "package/b.js".to_string(),
987 "package/c.js".to_string(),
988 ],
989 applied_via,
990 error: None,
991 sidecar: None,
992 };
993
994 let event = result_to_event(&result, false);
995 let v: serde_json::Value =
996 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
997 let files = v["files"].as_array().unwrap();
998 assert_eq!(files.len(), 3);
999 let by_path: std::collections::HashMap<String, &serde_json::Value> = files
1000 .iter()
1001 .map(|f| (f["path"].as_str().unwrap().to_string(), f))
1002 .collect();
1003 assert_eq!(by_path["package/a.js"]["appliedVia"], "diff");
1004 assert_eq!(by_path["package/b.js"]["appliedVia"], "package");
1005 assert_eq!(by_path["package/c.js"]["appliedVia"], "blob");
1006 }
1007
1008 fn sample_verified(statuses: &[VerifyStatus]) -> ApplyResult {
1012 let files_verified = statuses
1013 .iter()
1014 .enumerate()
1015 .map(|(i, status)| VerifyResult {
1016 file: format!("package/f{i}.js"),
1017 status: status.clone(),
1018 message: None,
1019 current_hash: None,
1020 expected_hash: None,
1021 target_hash: None,
1022 })
1023 .collect();
1024 ApplyResult {
1025 package_key: "pkg:npm/foo@1.0.0".to_string(),
1026 package_path: "/tmp/foo".to_string(),
1027 success: true,
1028 files_verified,
1029 files_patched: Vec::new(),
1030 applied_via: HashMap::new(),
1031 error: None,
1032 sidecar: None,
1033 }
1034 }
1035
1036 #[test]
1037 fn all_files_already_patched_true_when_every_file_matches() {
1038 let result = sample_verified(&[
1039 VerifyStatus::AlreadyPatched,
1040 VerifyStatus::AlreadyPatched,
1041 ]);
1042 assert!(all_files_already_patched(&result));
1043 }
1044
1045 #[test]
1046 fn all_files_already_patched_false_when_any_file_differs() {
1047 let result = sample_verified(&[
1048 VerifyStatus::AlreadyPatched,
1049 VerifyStatus::Ready,
1050 ]);
1051 assert!(!all_files_already_patched(&result));
1052 }
1053
1054 #[test]
1059 fn all_files_already_patched_false_when_no_verified_files() {
1060 let mut result = sample_verified(&[]);
1061 assert!(result.files_verified.is_empty());
1062 assert!(!all_files_already_patched(&result));
1063
1064 result.files_patched = vec!["package/a.js".to_string()];
1067 assert!(!all_files_already_patched(&result));
1068 }
1069
1070 #[test]
1079 fn variant_matches_only_when_first_file_ready_or_already_patched() {
1080 assert!(variant_matches_installed(Some(&VerifyStatus::Ready)));
1083 assert!(variant_matches_installed(Some(&VerifyStatus::AlreadyPatched)));
1084
1085 assert!(!variant_matches_installed(Some(&VerifyStatus::HashMismatch)));
1088 assert!(!variant_matches_installed(Some(&VerifyStatus::NotFound)));
1089
1090 assert!(variant_matches_installed(None));
1093 }
1094
1095 #[test]
1100 fn applied_with_empty_verified_is_not_skipped() {
1101 let mut applied_via = HashMap::new();
1102 applied_via.insert("package/a.js".to_string(), CoreAppliedVia::Blob);
1103 let result = ApplyResult {
1104 package_key: "pkg:npm/foo@1.0.0".to_string(),
1105 package_path: "/tmp/foo".to_string(),
1106 success: true,
1107 files_verified: Vec::new(),
1108 files_patched: vec!["package/a.js".to_string()],
1109 applied_via,
1110 error: None,
1111 sidecar: None,
1112 };
1113 let event = result_to_event(&result, false);
1114 let v: serde_json::Value =
1115 serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
1116 assert_eq!(v["action"], "applied");
1117 }
1118}