Skip to main content

socket_patch_core/vex/
verify.rs

1//! On-disk verification: which manifest entries are actually applied?
2//!
3//! A patch is "applied" iff every file the manifest claims it modified
4//! currently hashes to its `afterHash`. Anything else — missing file,
5//! hash mismatch, even one file ahead of expectations — disqualifies
6//! the patch from the VEX document. Callers feed the failures into a
7//! stderr warning + `--json` envelope warning list; the spec we agreed
8//! on is "never emit `affected` or `under_investigation` — just omit".
9//!
10//! The CLI is responsible for resolving PURL → on-disk package path
11//! (it already does this for `apply` / `scan` via the ecosystem
12//! dispatcher). We accept a pre-built map so this module stays free of
13//! ecosystem-crawler dependencies.
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18use crate::manifest::schema::PatchManifest;
19use crate::patch::apply::{verify_file_patch, VerifyStatus};
20
21/// One entry per manifest PURL that did NOT pass verification. The
22/// `reason` is a short snake_case tag the CLI can route on (matches
23/// the `error_code` convention used by `json_envelope::PatchEvent`).
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct FailedPatch {
26    pub purl: String,
27    pub reason: String,
28}
29
30/// Result of partitioning the manifest into applied vs failed sets.
31#[derive(Debug, Clone, Default)]
32pub struct VerifyOutcome {
33    /// PURLs whose on-disk files all hash to their `afterHash`.
34    pub applied: Vec<String>,
35    /// PURLs whose verification failed (with a routing tag).
36    pub failed: Vec<FailedPatch>,
37}
38
39/// Walk the manifest and bucket each PURL into `applied` / `failed`.
40///
41/// `package_paths` is the CLI-supplied `purl -> on-disk package dir`
42/// map (from `find_packages_for_purls`). A PURL absent from the map is
43/// recorded as `package_not_found` and ends up in `failed`.
44pub 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
74/// Returns `Ok(())` if every file in `record.files` is `AlreadyPatched`.
75/// Otherwise returns a short routing tag describing the first failure.
76///
77/// A record with **no files** is *not* treated as applied. Verification
78/// is the strict counterpart to `--no-verify`: it must produce positive
79/// on-disk evidence before a patch is attested as `not_affected`. A
80/// zero-file record offers nothing to hash, so — per the module's
81/// "omit when unconfirmed" contract — it is reported as `no_files` and
82/// dropped from the VEX document rather than vacuously attested.
83async 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        // Two files in the patch: only one is patched on disk → patch
211        // is not "fully" applied → reported as failed (not_applied for
212        // the second file).
213        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    // ── Edge-case + degenerate-input coverage ─────────────────────
265
266    /// `VerifyOutcome::default()` is the empty outcome — defaulting
267    /// is used by the CLI's `--no-verify` path.
268    #[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    /// `FailedPatch` equality + clone for downstream consumers
276    /// (the CLI emits these in `--json` warnings).
277    #[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    /// Empty manifest → empty outcome. No iteration, no panic.
288    #[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    /// A patch with `files = {}` must NOT be treated as applied.
298    /// Verification requires positive on-disk evidence before a patch
299    /// is attested as `not_affected`; a zero-file record offers nothing
300    /// to hash, so it is omitted with reason `no_files`. Attesting it as
301    /// "fixed" would be an evidence-free claim, contradicting the
302    /// module's "omit when unconfirmed" contract. (The `--no-verify`
303    /// path, which trusts the manifest wholesale, is unaffected — it
304    /// never calls this function.)
305    #[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    /// Extra `package_paths` entries that aren't in the manifest
339    /// are ignored — we iterate manifest entries, not the map.
340    #[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        // Stray entry not in the manifest.
357        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    /// Multi-file patch where the FIRST file fails — the iteration
369    /// halts after the first failure (we don't keep going to
370    /// surface every reason). Lock this in so future refactors
371    /// don't accidentally start running the second file's check.
372    ///
373    /// The patch lists two files. `a.js` has the wrong content (no
374    /// match for before_hash or after_hash); `b.js` is fine. Order
375    /// is non-deterministic across HashMap iteration, so we only
376    /// assert "one failure reason", not which one.
377    #[tokio::test]
378    async fn multi_file_first_failure_short_circuits() {
379        let pkg_dir = tempfile::tempdir().unwrap();
380        // a.js: corrupt
381        tokio::fs::write(pkg_dir.path().join("a.js"), b"garbage")
382            .await
383            .unwrap();
384        // b.js: at the right after_hash so it would pass.
385        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        // Reason depends on iteration order, but it MUST be one of
428        // the two failure tags (not the success path).
429        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    /// A new-file patch (empty `beforeHash`) whose file exists on disk
437    /// at the `afterHash` content counts as applied. `verify_file_patch`
438    /// returns `AlreadyPatched` before its is-new-file `Ready` branch, so
439    /// the created-and-applied case is not misreported as `not_applied`.
440    #[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(), // new file
454                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    /// A new-file patch whose file is absent on disk is `not_applied`
481    /// (the creation hasn't happened yet) — NOT `file_not_found`. The
482    /// empty `beforeHash` routes through the `Ready` branch.
483    #[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(), // new file, not yet created
491                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    /// A no-op patch where `beforeHash == afterHash` and the file is at
520    /// that content is applied — `verify_file_patch` checks `afterHash`
521    /// first, so it never mistakes the file for the un-patched `Ready`
522    /// state.
523    #[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    /// A multi-file patch where EVERY file is at its `afterHash` is
564    /// applied — the loop must run to completion (no early `Ok`) and
565    /// bucket the PURL into `applied`.
566    #[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    /// A manifest with both an applied PURL and a failing PURL splits
609    /// cleanly across the two buckets. Order is HashMap-nondeterministic,
610    /// so we assert membership, not index.
611    #[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        // Failing package: file present but at the wrong content.
621        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    /// At most ONE `FailedPatch` is recorded per PURL even when several
649    /// files would fail — `verify_patch_record` returns on the first
650    /// failure. Two distinct failing files, single failure recorded.
651    #[tokio::test]
652    async fn at_most_one_failure_recorded_per_purl() {
653        let pkg_dir = tempfile::tempdir().unwrap();
654        // a.js: hash mismatch (neither before nor after).
655        tokio::fs::write(pkg_dir.path().join("a.js"), b"garbage")
656            .await
657            .unwrap();
658        // b.js: absent → would be file_not_found.
659
660        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}