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(
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 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 #[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 #[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 #[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 #[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 #[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 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 #[tokio::test]
354 async fn multi_file_first_failure_short_circuits() {
355 let pkg_dir = tempfile::tempdir().unwrap();
356 tokio::fs::write(pkg_dir.path().join("a.js"), b"garbage")
358 .await
359 .unwrap();
360 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 let reason = &out.failed[0].reason;
406 assert!(
407 matches!(reason.as_str(), "hash_mismatch" | "not_applied"),
408 "unexpected reason: {reason}"
409 );
410 }
411}