1use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18use crate::manifest::schema::PatchManifest;
19use crate::patch::apply::{verify_file_patch, VerifyStatus};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct FailedPatch {
26 pub purl: String,
27 pub reason: String,
28}
29
30#[derive(Debug, Clone, Default)]
32pub struct VerifyOutcome {
33 pub applied: Vec<String>,
35 pub failed: Vec<FailedPatch>,
37}
38
39pub async fn applied_patches(
45 manifest: &PatchManifest,
46 package_paths: &HashMap<String, PathBuf>,
47) -> VerifyOutcome {
48 let mut out = VerifyOutcome::default();
49
50 for (purl, record) in &manifest.patches {
51 let pkg_path = match package_paths.get(purl) {
52 Some(p) => p,
53 None => {
54 out.failed.push(FailedPatch {
55 purl: purl.clone(),
56 reason: "package_not_found".to_string(),
57 });
58 continue;
59 }
60 };
61
62 match verify_patch_record(pkg_path, record).await {
63 Ok(()) => out.applied.push(purl.clone()),
64 Err(reason) => out.failed.push(FailedPatch {
65 purl: purl.clone(),
66 reason,
67 }),
68 }
69 }
70
71 out
72}
73
74async fn verify_patch_record(
84 pkg_path: &Path,
85 record: &crate::manifest::schema::PatchRecord,
86) -> Result<(), String> {
87 if record.files.is_empty() {
88 return Err("no_files".to_string());
89 }
90
91 for (file_name, file_info) in &record.files {
92 let result = verify_file_patch(pkg_path, file_name, file_info).await;
93 match result.status {
94 VerifyStatus::AlreadyPatched => continue,
95 VerifyStatus::Ready => return Err("not_applied".to_string()),
96 VerifyStatus::HashMismatch => return Err("hash_mismatch".to_string()),
97 VerifyStatus::NotFound => return Err("file_not_found".to_string()),
98 }
99 }
100 Ok(())
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::hash::git_sha256::compute_git_sha256_from_bytes;
107 use crate::manifest::schema::{PatchFileInfo, PatchRecord};
108 use std::collections::HashMap;
109
110 fn record_with_one_file(after_hash: &str) -> PatchRecord {
111 let mut files = HashMap::new();
112 files.insert(
113 "index.js".to_string(),
114 PatchFileInfo {
115 before_hash: "aaaa".to_string(),
116 after_hash: after_hash.to_string(),
117 },
118 );
119 PatchRecord {
120 uuid: "u".to_string(),
121 exported_at: "2024-01-01T00:00:00Z".to_string(),
122 files,
123 vulnerabilities: HashMap::new(),
124 description: String::new(),
125 license: String::new(),
126 tier: String::new(),
127 }
128 }
129
130 #[tokio::test]
131 async fn applied_when_all_files_match_after_hash() {
132 let pkg_dir = tempfile::tempdir().unwrap();
133 let patched = b"patched-content";
134 let hash = compute_git_sha256_from_bytes(patched);
135 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
136 .await
137 .unwrap();
138
139 let mut manifest = PatchManifest::new();
140 manifest
141 .patches
142 .insert("pkg:npm/x@1.0.0".to_string(), record_with_one_file(&hash));
143
144 let mut paths = HashMap::new();
145 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
146
147 let out = applied_patches(&manifest, &paths).await;
148 assert_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
149 assert!(out.failed.is_empty());
150 }
151
152 #[tokio::test]
153 async fn missing_path_falls_into_failed() {
154 let mut manifest = PatchManifest::new();
155 manifest.patches.insert(
156 "pkg:npm/x@1.0.0".to_string(),
157 record_with_one_file("deadbeef"),
158 );
159
160 let paths: HashMap<String, PathBuf> = HashMap::new();
161 let out = applied_patches(&manifest, &paths).await;
162 assert!(out.applied.is_empty());
163 assert_eq!(out.failed.len(), 1);
164 assert_eq!(out.failed[0].reason, "package_not_found");
165 }
166
167 #[tokio::test]
168 async fn hash_mismatch_falls_into_failed() {
169 let pkg_dir = tempfile::tempdir().unwrap();
170 tokio::fs::write(pkg_dir.path().join("index.js"), b"not the right content")
171 .await
172 .unwrap();
173
174 let mut manifest = PatchManifest::new();
175 manifest.patches.insert(
176 "pkg:npm/x@1.0.0".to_string(),
177 record_with_one_file(
178 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
179 ),
180 );
181
182 let mut paths = HashMap::new();
183 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
184
185 let out = applied_patches(&manifest, &paths).await;
186 assert!(out.applied.is_empty());
187 assert_eq!(out.failed[0].reason, "hash_mismatch");
188 }
189
190 #[tokio::test]
191 async fn missing_file_falls_into_failed() {
192 let pkg_dir = tempfile::tempdir().unwrap();
193 let mut manifest = PatchManifest::new();
194 manifest.patches.insert(
195 "pkg:npm/x@1.0.0".to_string(),
196 record_with_one_file(
197 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
198 ),
199 );
200
201 let mut paths = HashMap::new();
202 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
203
204 let out = applied_patches(&manifest, &paths).await;
205 assert_eq!(out.failed[0].reason, "file_not_found");
206 }
207
208 #[tokio::test]
209 async fn partial_apply_still_fails() {
210 let pkg_dir = tempfile::tempdir().unwrap();
214 let patched_a = b"AAA";
215 let hash_a = compute_git_sha256_from_bytes(patched_a);
216 let original_b = b"original-b";
217 let before_b = compute_git_sha256_from_bytes(original_b);
218
219 tokio::fs::write(pkg_dir.path().join("a.js"), patched_a)
220 .await
221 .unwrap();
222 tokio::fs::write(pkg_dir.path().join("b.js"), original_b)
223 .await
224 .unwrap();
225
226 let mut files = HashMap::new();
227 files.insert(
228 "a.js".to_string(),
229 PatchFileInfo {
230 before_hash: "aaaa".to_string(),
231 after_hash: hash_a,
232 },
233 );
234 files.insert(
235 "b.js".to_string(),
236 PatchFileInfo {
237 before_hash: before_b,
238 after_hash: "deadbeef".to_string(),
239 },
240 );
241
242 let mut manifest = PatchManifest::new();
243 manifest.patches.insert(
244 "pkg:npm/x@1.0.0".to_string(),
245 PatchRecord {
246 uuid: "u".to_string(),
247 exported_at: String::new(),
248 files,
249 vulnerabilities: HashMap::new(),
250 description: String::new(),
251 license: String::new(),
252 tier: String::new(),
253 },
254 );
255
256 let mut paths = HashMap::new();
257 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
258
259 let out = applied_patches(&manifest, &paths).await;
260 assert!(out.applied.is_empty());
261 assert_eq!(out.failed[0].reason, "not_applied");
262 }
263
264 #[test]
269 fn outcome_default_is_empty() {
270 let o = VerifyOutcome::default();
271 assert!(o.applied.is_empty());
272 assert!(o.failed.is_empty());
273 }
274
275 #[test]
278 fn failed_patch_value_semantics() {
279 let a = FailedPatch {
280 purl: "pkg:npm/x@1".to_string(),
281 reason: "hash_mismatch".to_string(),
282 };
283 let b = a.clone();
284 assert_eq!(a, b);
285 }
286
287 #[tokio::test]
289 async fn empty_manifest_returns_empty_outcome() {
290 let manifest = PatchManifest::new();
291 let paths: HashMap<String, PathBuf> = HashMap::new();
292 let out = applied_patches(&manifest, &paths).await;
293 assert!(out.applied.is_empty());
294 assert!(out.failed.is_empty());
295 }
296
297 #[tokio::test]
306 async fn patch_record_with_zero_files_is_not_applied() {
307 let pkg_dir = tempfile::tempdir().unwrap();
308 let mut manifest = PatchManifest::new();
309 manifest.patches.insert(
310 "pkg:npm/empty@1.0.0".to_string(),
311 PatchRecord {
312 uuid: "u".to_string(),
313 exported_at: String::new(),
314 files: HashMap::new(),
315 vulnerabilities: HashMap::new(),
316 description: String::new(),
317 license: String::new(),
318 tier: String::new(),
319 },
320 );
321
322 let mut paths = HashMap::new();
323 paths.insert(
324 "pkg:npm/empty@1.0.0".to_string(),
325 pkg_dir.path().to_path_buf(),
326 );
327
328 let out = applied_patches(&manifest, &paths).await;
329 assert!(
330 out.applied.is_empty(),
331 "a zero-file patch must not be attested as applied"
332 );
333 assert_eq!(out.failed.len(), 1);
334 assert_eq!(out.failed[0].purl, "pkg:npm/empty@1.0.0");
335 assert_eq!(out.failed[0].reason, "no_files");
336 }
337
338 #[tokio::test]
341 async fn extra_package_paths_are_ignored() {
342 let pkg_dir = tempfile::tempdir().unwrap();
343 let patched = b"patched";
344 let hash = compute_git_sha256_from_bytes(patched);
345 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
346 .await
347 .unwrap();
348
349 let mut manifest = PatchManifest::new();
350 manifest
351 .patches
352 .insert("pkg:npm/x@1.0.0".to_string(), record_with_one_file(&hash));
353
354 let mut paths = HashMap::new();
355 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
356 paths.insert(
358 "pkg:npm/stray@9.9.9".to_string(),
359 pkg_dir.path().to_path_buf(),
360 );
361
362 let out = applied_patches(&manifest, &paths).await;
363 assert_eq!(out.applied.len(), 1);
364 assert_eq!(out.applied[0], "pkg:npm/x@1.0.0");
365 assert!(out.failed.is_empty());
366 }
367
368 #[tokio::test]
378 async fn multi_file_first_failure_short_circuits() {
379 let pkg_dir = tempfile::tempdir().unwrap();
380 tokio::fs::write(pkg_dir.path().join("a.js"), b"garbage")
382 .await
383 .unwrap();
384 let patched_b = b"patched-b";
386 let hash_b = compute_git_sha256_from_bytes(patched_b);
387 tokio::fs::write(pkg_dir.path().join("b.js"), patched_b)
388 .await
389 .unwrap();
390
391 let mut files = HashMap::new();
392 files.insert(
393 "a.js".to_string(),
394 PatchFileInfo {
395 before_hash: "aaaa".to_string(),
396 after_hash: "deadbeef".to_string(),
397 },
398 );
399 files.insert(
400 "b.js".to_string(),
401 PatchFileInfo {
402 before_hash: "cccc".to_string(),
403 after_hash: hash_b,
404 },
405 );
406
407 let mut manifest = PatchManifest::new();
408 manifest.patches.insert(
409 "pkg:npm/x@1.0.0".to_string(),
410 PatchRecord {
411 uuid: "u".to_string(),
412 exported_at: String::new(),
413 files,
414 vulnerabilities: HashMap::new(),
415 description: String::new(),
416 license: String::new(),
417 tier: String::new(),
418 },
419 );
420
421 let mut paths = HashMap::new();
422 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
423
424 let out = applied_patches(&manifest, &paths).await;
425 assert!(out.applied.is_empty());
426 assert_eq!(out.failed.len(), 1, "first failure must short-circuit");
427 let reason = &out.failed[0].reason;
430 assert!(
431 matches!(reason.as_str(), "hash_mismatch" | "not_applied"),
432 "unexpected reason: {reason}"
433 );
434 }
435
436 #[tokio::test]
441 async fn new_file_present_at_after_hash_is_applied() {
442 let pkg_dir = tempfile::tempdir().unwrap();
443 let created = b"freshly-created-file";
444 let hash = compute_git_sha256_from_bytes(created);
445 tokio::fs::write(pkg_dir.path().join("new.js"), created)
446 .await
447 .unwrap();
448
449 let mut files = HashMap::new();
450 files.insert(
451 "new.js".to_string(),
452 PatchFileInfo {
453 before_hash: String::new(), after_hash: hash,
455 },
456 );
457
458 let mut manifest = PatchManifest::new();
459 manifest.patches.insert(
460 "pkg:npm/x@1.0.0".to_string(),
461 PatchRecord {
462 uuid: "u".to_string(),
463 exported_at: String::new(),
464 files,
465 vulnerabilities: HashMap::new(),
466 description: String::new(),
467 license: String::new(),
468 tier: String::new(),
469 },
470 );
471
472 let mut paths = HashMap::new();
473 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
474
475 let out = applied_patches(&manifest, &paths).await;
476 assert_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
477 assert!(out.failed.is_empty());
478 }
479
480 #[tokio::test]
484 async fn new_file_absent_is_not_applied() {
485 let pkg_dir = tempfile::tempdir().unwrap();
486 let mut files = HashMap::new();
487 files.insert(
488 "new.js".to_string(),
489 PatchFileInfo {
490 before_hash: String::new(), after_hash:
492 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
493 .to_string(),
494 },
495 );
496
497 let mut manifest = PatchManifest::new();
498 manifest.patches.insert(
499 "pkg:npm/x@1.0.0".to_string(),
500 PatchRecord {
501 uuid: "u".to_string(),
502 exported_at: String::new(),
503 files,
504 vulnerabilities: HashMap::new(),
505 description: String::new(),
506 license: String::new(),
507 tier: String::new(),
508 },
509 );
510
511 let mut paths = HashMap::new();
512 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
513
514 let out = applied_patches(&manifest, &paths).await;
515 assert!(out.applied.is_empty());
516 assert_eq!(out.failed[0].reason, "not_applied");
517 }
518
519 #[tokio::test]
524 async fn noop_patch_before_equals_after_is_applied() {
525 let pkg_dir = tempfile::tempdir().unwrap();
526 let content = b"unchanged-content";
527 let hash = compute_git_sha256_from_bytes(content);
528 tokio::fs::write(pkg_dir.path().join("index.js"), content)
529 .await
530 .unwrap();
531
532 let mut files = HashMap::new();
533 files.insert(
534 "index.js".to_string(),
535 PatchFileInfo {
536 before_hash: hash.clone(),
537 after_hash: hash,
538 },
539 );
540
541 let mut manifest = PatchManifest::new();
542 manifest.patches.insert(
543 "pkg:npm/x@1.0.0".to_string(),
544 PatchRecord {
545 uuid: "u".to_string(),
546 exported_at: String::new(),
547 files,
548 vulnerabilities: HashMap::new(),
549 description: String::new(),
550 license: String::new(),
551 tier: String::new(),
552 },
553 );
554
555 let mut paths = HashMap::new();
556 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
557
558 let out = applied_patches(&manifest, &paths).await;
559 assert_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
560 assert!(out.failed.is_empty());
561 }
562
563 #[tokio::test]
567 async fn multi_file_all_patched_is_applied() {
568 let pkg_dir = tempfile::tempdir().unwrap();
569 let a = b"patched-a";
570 let b = b"patched-b";
571 let hash_a = compute_git_sha256_from_bytes(a);
572 let hash_b = compute_git_sha256_from_bytes(b);
573 tokio::fs::write(pkg_dir.path().join("a.js"), a).await.unwrap();
574 tokio::fs::write(pkg_dir.path().join("b.js"), b).await.unwrap();
575
576 let mut files = HashMap::new();
577 files.insert(
578 "a.js".to_string(),
579 PatchFileInfo { before_hash: "aaaa".to_string(), after_hash: hash_a },
580 );
581 files.insert(
582 "b.js".to_string(),
583 PatchFileInfo { before_hash: "bbbb".to_string(), after_hash: hash_b },
584 );
585
586 let mut manifest = PatchManifest::new();
587 manifest.patches.insert(
588 "pkg:npm/x@1.0.0".to_string(),
589 PatchRecord {
590 uuid: "u".to_string(),
591 exported_at: String::new(),
592 files,
593 vulnerabilities: HashMap::new(),
594 description: String::new(),
595 license: String::new(),
596 tier: String::new(),
597 },
598 );
599
600 let mut paths = HashMap::new();
601 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
602
603 let out = applied_patches(&manifest, &paths).await;
604 assert_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
605 assert!(out.failed.is_empty());
606 }
607
608 #[tokio::test]
612 async fn mixed_manifest_splits_into_both_buckets() {
613 let ok_dir = tempfile::tempdir().unwrap();
614 let patched = b"patched-content";
615 let hash = compute_git_sha256_from_bytes(patched);
616 tokio::fs::write(ok_dir.path().join("index.js"), patched)
617 .await
618 .unwrap();
619
620 let bad_dir = tempfile::tempdir().unwrap();
622 tokio::fs::write(bad_dir.path().join("index.js"), b"wrong")
623 .await
624 .unwrap();
625
626 let mut manifest = PatchManifest::new();
627 manifest
628 .patches
629 .insert("pkg:npm/ok@1.0.0".to_string(), record_with_one_file(&hash));
630 manifest.patches.insert(
631 "pkg:npm/bad@1.0.0".to_string(),
632 record_with_one_file(
633 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
634 ),
635 );
636
637 let mut paths = HashMap::new();
638 paths.insert("pkg:npm/ok@1.0.0".to_string(), ok_dir.path().to_path_buf());
639 paths.insert("pkg:npm/bad@1.0.0".to_string(), bad_dir.path().to_path_buf());
640
641 let out = applied_patches(&manifest, &paths).await;
642 assert_eq!(out.applied, vec!["pkg:npm/ok@1.0.0".to_string()]);
643 assert_eq!(out.failed.len(), 1);
644 assert_eq!(out.failed[0].purl, "pkg:npm/bad@1.0.0");
645 assert_eq!(out.failed[0].reason, "hash_mismatch");
646 }
647
648 #[tokio::test]
652 async fn at_most_one_failure_recorded_per_purl() {
653 let pkg_dir = tempfile::tempdir().unwrap();
654 tokio::fs::write(pkg_dir.path().join("a.js"), b"garbage")
656 .await
657 .unwrap();
658 let mut files = HashMap::new();
661 files.insert(
662 "a.js".to_string(),
663 PatchFileInfo { before_hash: "aaaa".to_string(), after_hash: "deadbeef".to_string() },
664 );
665 files.insert(
666 "b.js".to_string(),
667 PatchFileInfo { before_hash: "bbbb".to_string(), after_hash: "deadbeef".to_string() },
668 );
669
670 let mut manifest = PatchManifest::new();
671 manifest.patches.insert(
672 "pkg:npm/x@1.0.0".to_string(),
673 PatchRecord {
674 uuid: "u".to_string(),
675 exported_at: String::new(),
676 files,
677 vulnerabilities: HashMap::new(),
678 description: String::new(),
679 license: String::new(),
680 tier: String::new(),
681 },
682 );
683
684 let mut paths = HashMap::new();
685 paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
686
687 let out = applied_patches(&manifest, &paths).await;
688 assert!(out.applied.is_empty());
689 assert_eq!(out.failed.len(), 1, "one FailedPatch per PURL, not per file");
690 assert!(
691 matches!(out.failed[0].reason.as_str(), "hash_mismatch" | "file_not_found"),
692 "unexpected reason: {}",
693 out.failed[0].reason
694 );
695 }
696}