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.
76async fn verify_patch_record(
77    pkg_path: &Path,
78    record: &crate::manifest::schema::PatchRecord,
79) -> Result<(), String> {
80    for (file_name, file_info) in &record.files {
81        let result = verify_file_patch(pkg_path, file_name, file_info).await;
82        match result.status {
83            VerifyStatus::AlreadyPatched => continue,
84            VerifyStatus::Ready => return Err("not_applied".to_string()),
85            VerifyStatus::HashMismatch => return Err("hash_mismatch".to_string()),
86            VerifyStatus::NotFound => return Err("file_not_found".to_string()),
87        }
88    }
89    Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::hash::git_sha256::compute_git_sha256_from_bytes;
96    use crate::manifest::schema::{PatchFileInfo, PatchRecord};
97    use std::collections::HashMap;
98
99    fn record_with_one_file(after_hash: &str) -> PatchRecord {
100        let mut files = HashMap::new();
101        files.insert(
102            "index.js".to_string(),
103            PatchFileInfo {
104                before_hash: "aaaa".to_string(),
105                after_hash: after_hash.to_string(),
106            },
107        );
108        PatchRecord {
109            uuid: "u".to_string(),
110            exported_at: "2024-01-01T00:00:00Z".to_string(),
111            files,
112            vulnerabilities: HashMap::new(),
113            description: String::new(),
114            license: String::new(),
115            tier: String::new(),
116        }
117    }
118
119    #[tokio::test]
120    async fn applied_when_all_files_match_after_hash() {
121        let pkg_dir = tempfile::tempdir().unwrap();
122        let patched = b"patched-content";
123        let hash = compute_git_sha256_from_bytes(patched);
124        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
125            .await
126            .unwrap();
127
128        let mut manifest = PatchManifest::new();
129        manifest
130            .patches
131            .insert("pkg:npm/x@1.0.0".to_string(), record_with_one_file(&hash));
132
133        let mut paths = HashMap::new();
134        paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
135
136        let out = applied_patches(&manifest, &paths).await;
137        assert_eq!(out.applied, vec!["pkg:npm/x@1.0.0".to_string()]);
138        assert!(out.failed.is_empty());
139    }
140
141    #[tokio::test]
142    async fn missing_path_falls_into_failed() {
143        let mut manifest = PatchManifest::new();
144        manifest
145            .patches
146            .insert("pkg:npm/x@1.0.0".to_string(), record_with_one_file("deadbeef"));
147
148        let paths: HashMap<String, PathBuf> = HashMap::new();
149        let out = applied_patches(&manifest, &paths).await;
150        assert!(out.applied.is_empty());
151        assert_eq!(out.failed.len(), 1);
152        assert_eq!(out.failed[0].reason, "package_not_found");
153    }
154
155    #[tokio::test]
156    async fn hash_mismatch_falls_into_failed() {
157        let pkg_dir = tempfile::tempdir().unwrap();
158        tokio::fs::write(pkg_dir.path().join("index.js"), b"not the right content")
159            .await
160            .unwrap();
161
162        let mut manifest = PatchManifest::new();
163        manifest.patches.insert(
164            "pkg:npm/x@1.0.0".to_string(),
165            record_with_one_file("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
166        );
167
168        let mut paths = HashMap::new();
169        paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
170
171        let out = applied_patches(&manifest, &paths).await;
172        assert!(out.applied.is_empty());
173        assert_eq!(out.failed[0].reason, "hash_mismatch");
174    }
175
176    #[tokio::test]
177    async fn missing_file_falls_into_failed() {
178        let pkg_dir = tempfile::tempdir().unwrap();
179        let mut manifest = PatchManifest::new();
180        manifest.patches.insert(
181            "pkg:npm/x@1.0.0".to_string(),
182            record_with_one_file("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
183        );
184
185        let mut paths = HashMap::new();
186        paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
187
188        let out = applied_patches(&manifest, &paths).await;
189        assert_eq!(out.failed[0].reason, "file_not_found");
190    }
191
192    #[tokio::test]
193    async fn partial_apply_still_fails() {
194        // Two files in the patch: only one is patched on disk → patch
195        // is not "fully" applied → reported as failed (not_applied for
196        // the second file).
197        let pkg_dir = tempfile::tempdir().unwrap();
198        let patched_a = b"AAA";
199        let hash_a = compute_git_sha256_from_bytes(patched_a);
200        let original_b = b"original-b";
201        let before_b = compute_git_sha256_from_bytes(original_b);
202
203        tokio::fs::write(pkg_dir.path().join("a.js"), patched_a)
204            .await
205            .unwrap();
206        tokio::fs::write(pkg_dir.path().join("b.js"), original_b)
207            .await
208            .unwrap();
209
210        let mut files = HashMap::new();
211        files.insert(
212            "a.js".to_string(),
213            PatchFileInfo {
214                before_hash: "aaaa".to_string(),
215                after_hash: hash_a,
216            },
217        );
218        files.insert(
219            "b.js".to_string(),
220            PatchFileInfo {
221                before_hash: before_b,
222                after_hash: "deadbeef".to_string(),
223            },
224        );
225
226        let mut manifest = PatchManifest::new();
227        manifest.patches.insert(
228            "pkg:npm/x@1.0.0".to_string(),
229            PatchRecord {
230                uuid: "u".to_string(),
231                exported_at: String::new(),
232                files,
233                vulnerabilities: HashMap::new(),
234                description: String::new(),
235                license: String::new(),
236                tier: String::new(),
237            },
238        );
239
240        let mut paths = HashMap::new();
241        paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
242
243        let out = applied_patches(&manifest, &paths).await;
244        assert!(out.applied.is_empty());
245        assert_eq!(out.failed[0].reason, "not_applied");
246    }
247
248    // ── Edge-case + degenerate-input coverage ─────────────────────
249
250    /// `VerifyOutcome::default()` is the empty outcome — defaulting
251    /// is used by the CLI's `--no-verify` path.
252    #[test]
253    fn outcome_default_is_empty() {
254        let o = VerifyOutcome::default();
255        assert!(o.applied.is_empty());
256        assert!(o.failed.is_empty());
257    }
258
259    /// `FailedPatch` equality + clone for downstream consumers
260    /// (the CLI emits these in `--json` warnings).
261    #[test]
262    fn failed_patch_value_semantics() {
263        let a = FailedPatch {
264            purl: "pkg:npm/x@1".to_string(),
265            reason: "hash_mismatch".to_string(),
266        };
267        let b = a.clone();
268        assert_eq!(a, b);
269    }
270
271    /// Empty manifest → empty outcome. No iteration, no panic.
272    #[tokio::test]
273    async fn empty_manifest_returns_empty_outcome() {
274        let manifest = PatchManifest::new();
275        let paths: HashMap<String, PathBuf> = HashMap::new();
276        let out = applied_patches(&manifest, &paths).await;
277        assert!(out.applied.is_empty());
278        assert!(out.failed.is_empty());
279    }
280
281    /// A patch with `files = {}` is vacuously applied — the
282    /// "all files match" predicate is `true` over an empty set.
283    /// This is intentional behavior: a "patch" that touches no
284    /// files is always-applied. Documented here so a future
285    /// refactor that flips the predicate is forced to revisit it.
286    #[tokio::test]
287    async fn patch_record_with_zero_files_is_vacuously_applied() {
288        let pkg_dir = tempfile::tempdir().unwrap();
289        let mut manifest = PatchManifest::new();
290        manifest.patches.insert(
291            "pkg:npm/empty@1.0.0".to_string(),
292            PatchRecord {
293                uuid: "u".to_string(),
294                exported_at: String::new(),
295                files: HashMap::new(),
296                vulnerabilities: HashMap::new(),
297                description: String::new(),
298                license: String::new(),
299                tier: String::new(),
300            },
301        );
302
303        let mut paths = HashMap::new();
304        paths.insert(
305            "pkg:npm/empty@1.0.0".to_string(),
306            pkg_dir.path().to_path_buf(),
307        );
308
309        let out = applied_patches(&manifest, &paths).await;
310        assert_eq!(out.applied, vec!["pkg:npm/empty@1.0.0".to_string()]);
311        assert!(out.failed.is_empty());
312    }
313
314    /// Extra `package_paths` entries that aren't in the manifest
315    /// are ignored — we iterate manifest entries, not the map.
316    #[tokio::test]
317    async fn extra_package_paths_are_ignored() {
318        let pkg_dir = tempfile::tempdir().unwrap();
319        let patched = b"patched";
320        let hash = compute_git_sha256_from_bytes(patched);
321        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
322            .await
323            .unwrap();
324
325        let mut manifest = PatchManifest::new();
326        manifest
327            .patches
328            .insert("pkg:npm/x@1.0.0".to_string(), record_with_one_file(&hash));
329
330        let mut paths = HashMap::new();
331        paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
332        // Stray entry not in the manifest.
333        paths.insert(
334            "pkg:npm/stray@9.9.9".to_string(),
335            pkg_dir.path().to_path_buf(),
336        );
337
338        let out = applied_patches(&manifest, &paths).await;
339        assert_eq!(out.applied.len(), 1);
340        assert_eq!(out.applied[0], "pkg:npm/x@1.0.0");
341        assert!(out.failed.is_empty());
342    }
343
344    /// Multi-file patch where the FIRST file fails — the iteration
345    /// halts after the first failure (we don't keep going to
346    /// surface every reason). Lock this in so future refactors
347    /// don't accidentally start running the second file's check.
348    ///
349    /// The patch lists two files. `a.js` has the wrong content (no
350    /// match for before_hash or after_hash); `b.js` is fine. Order
351    /// is non-deterministic across HashMap iteration, so we only
352    /// assert "one failure reason", not which one.
353    #[tokio::test]
354    async fn multi_file_first_failure_short_circuits() {
355        let pkg_dir = tempfile::tempdir().unwrap();
356        // a.js: corrupt
357        tokio::fs::write(pkg_dir.path().join("a.js"), b"garbage")
358            .await
359            .unwrap();
360        // b.js: at the right after_hash so it would pass.
361        let patched_b = b"patched-b";
362        let hash_b = compute_git_sha256_from_bytes(patched_b);
363        tokio::fs::write(pkg_dir.path().join("b.js"), patched_b)
364            .await
365            .unwrap();
366
367        let mut files = HashMap::new();
368        files.insert(
369            "a.js".to_string(),
370            PatchFileInfo {
371                before_hash: "aaaa".to_string(),
372                after_hash: "deadbeef".to_string(),
373            },
374        );
375        files.insert(
376            "b.js".to_string(),
377            PatchFileInfo {
378                before_hash: "cccc".to_string(),
379                after_hash: hash_b,
380            },
381        );
382
383        let mut manifest = PatchManifest::new();
384        manifest.patches.insert(
385            "pkg:npm/x@1.0.0".to_string(),
386            PatchRecord {
387                uuid: "u".to_string(),
388                exported_at: String::new(),
389                files,
390                vulnerabilities: HashMap::new(),
391                description: String::new(),
392                license: String::new(),
393                tier: String::new(),
394            },
395        );
396
397        let mut paths = HashMap::new();
398        paths.insert("pkg:npm/x@1.0.0".to_string(), pkg_dir.path().to_path_buf());
399
400        let out = applied_patches(&manifest, &paths).await;
401        assert!(out.applied.is_empty());
402        assert_eq!(out.failed.len(), 1, "first failure must short-circuit");
403        // Reason depends on iteration order, but it MUST be one of
404        // the two failure tags (not the success path).
405        let reason = &out.failed[0].reason;
406        assert!(
407            matches!(reason.as_str(), "hash_mismatch" | "not_applied"),
408            "unexpected reason: {reason}"
409        );
410    }
411}