1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::manifest::schema::PatchFileInfo;
5use crate::patch::file_hash::compute_file_git_sha256;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum VerifyStatus {
10 Ready,
12 AlreadyPatched,
14 HashMismatch,
16 NotFound,
18}
19
20#[derive(Debug, Clone)]
22pub struct VerifyResult {
23 pub file: String,
24 pub status: VerifyStatus,
25 pub message: Option<String>,
26 pub current_hash: Option<String>,
27 pub expected_hash: Option<String>,
28 pub target_hash: Option<String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct ApplyResult {
34 pub package_key: String,
35 pub package_path: String,
36 pub success: bool,
37 pub files_verified: Vec<VerifyResult>,
38 pub files_patched: Vec<String>,
39 pub error: Option<String>,
40}
41
42pub fn normalize_file_path(file_name: &str) -> &str {
46 const PACKAGE_PREFIX: &str = "package/";
47 if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) {
48 stripped
49 } else {
50 file_name
51 }
52}
53
54pub async fn verify_file_patch(
56 pkg_path: &Path,
57 file_name: &str,
58 file_info: &PatchFileInfo,
59) -> VerifyResult {
60 let normalized = normalize_file_path(file_name);
61 let filepath = pkg_path.join(normalized);
62
63 let is_new_file = file_info.before_hash.is_empty();
64
65 if tokio::fs::metadata(&filepath).await.is_err() {
67 if is_new_file {
69 return VerifyResult {
70 file: file_name.to_string(),
71 status: VerifyStatus::Ready,
72 message: None,
73 current_hash: None,
74 expected_hash: None,
75 target_hash: Some(file_info.after_hash.clone()),
76 };
77 }
78 return VerifyResult {
79 file: file_name.to_string(),
80 status: VerifyStatus::NotFound,
81 message: Some("File not found".to_string()),
82 current_hash: None,
83 expected_hash: None,
84 target_hash: None,
85 };
86 }
87
88 let current_hash = match compute_file_git_sha256(&filepath).await {
90 Ok(h) => h,
91 Err(e) => {
92 return VerifyResult {
93 file: file_name.to_string(),
94 status: VerifyStatus::NotFound,
95 message: Some(format!("Failed to hash file: {}", e)),
96 current_hash: None,
97 expected_hash: None,
98 target_hash: None,
99 };
100 }
101 };
102
103 if current_hash == file_info.after_hash {
105 return VerifyResult {
106 file: file_name.to_string(),
107 status: VerifyStatus::AlreadyPatched,
108 message: None,
109 current_hash: Some(current_hash),
110 expected_hash: None,
111 target_hash: None,
112 };
113 }
114
115 if is_new_file {
118 return VerifyResult {
119 file: file_name.to_string(),
120 status: VerifyStatus::Ready,
121 message: None,
122 current_hash: Some(current_hash),
123 expected_hash: None,
124 target_hash: Some(file_info.after_hash.clone()),
125 };
126 }
127
128 if current_hash != file_info.before_hash {
130 return VerifyResult {
131 file: file_name.to_string(),
132 status: VerifyStatus::HashMismatch,
133 message: Some("File hash does not match expected value".to_string()),
134 current_hash: Some(current_hash),
135 expected_hash: Some(file_info.before_hash.clone()),
136 target_hash: Some(file_info.after_hash.clone()),
137 };
138 }
139
140 VerifyResult {
141 file: file_name.to_string(),
142 status: VerifyStatus::Ready,
143 message: None,
144 current_hash: Some(current_hash),
145 expected_hash: None,
146 target_hash: Some(file_info.after_hash.clone()),
147 }
148}
149
150pub async fn apply_file_patch(
153 pkg_path: &Path,
154 file_name: &str,
155 patched_content: &[u8],
156 expected_hash: &str,
157) -> Result<(), std::io::Error> {
158 let normalized = normalize_file_path(file_name);
159 let filepath = pkg_path.join(normalized);
160
161 if let Some(parent) = filepath.parent() {
163 tokio::fs::create_dir_all(parent).await?;
164 }
165
166 #[cfg(unix)]
168 if let Ok(meta) = tokio::fs::metadata(&filepath).await {
169 use std::os::unix::fs::PermissionsExt;
170 let perms = meta.permissions();
171 if perms.readonly() {
172 let mode = perms.mode();
173 let mut new_perms = perms;
174 new_perms.set_mode(mode | 0o200);
175 tokio::fs::set_permissions(&filepath, new_perms).await?;
176 }
177 }
178
179 tokio::fs::write(&filepath, patched_content).await?;
181
182 let verify_hash = compute_file_git_sha256(&filepath).await?;
184 if verify_hash != expected_hash {
185 return Err(std::io::Error::new(
186 std::io::ErrorKind::InvalidData,
187 format!(
188 "Hash verification failed after patch. Expected: {}, Got: {}",
189 expected_hash, verify_hash
190 ),
191 ));
192 }
193
194 Ok(())
195}
196
197pub async fn apply_package_patch(
204 package_key: &str,
205 pkg_path: &Path,
206 files: &HashMap<String, PatchFileInfo>,
207 blobs_path: &Path,
208 dry_run: bool,
209 force: bool,
210) -> ApplyResult {
211 let mut result = ApplyResult {
212 package_key: package_key.to_string(),
213 package_path: pkg_path.display().to_string(),
214 success: false,
215 files_verified: Vec::new(),
216 files_patched: Vec::new(),
217 error: None,
218 };
219
220 for (file_name, file_info) in files {
222 let mut verify_result = verify_file_patch(pkg_path, file_name, file_info).await;
223
224 if verify_result.status != VerifyStatus::Ready
225 && verify_result.status != VerifyStatus::AlreadyPatched
226 {
227 if force {
228 match verify_result.status {
229 VerifyStatus::HashMismatch => {
230 verify_result.status = VerifyStatus::Ready;
232 }
233 VerifyStatus::NotFound => {
234 result.files_verified.push(verify_result);
236 continue;
237 }
238 _ => {}
239 }
240 } else {
241 let msg = verify_result
242 .message
243 .clone()
244 .unwrap_or_else(|| format!("{:?}", verify_result.status));
245 result.error = Some(format!(
246 "Cannot apply patch: {} - {}",
247 verify_result.file, msg
248 ));
249 result.files_verified.push(verify_result);
250 return result;
251 }
252 }
253
254 result.files_verified.push(verify_result);
255 }
256
257 let all_patched = result
259 .files_verified
260 .iter()
261 .all(|v| v.status == VerifyStatus::AlreadyPatched || v.status == VerifyStatus::NotFound);
262 if all_patched {
263 result.success = true;
264 return result;
265 }
266
267 if dry_run {
269 result.success = true;
270 return result;
271 }
272
273 for (file_name, file_info) in files {
275 let verify_result = result.files_verified.iter().find(|v| v.file == *file_name);
276 if let Some(vr) = verify_result {
277 if vr.status == VerifyStatus::AlreadyPatched
278 || vr.status == VerifyStatus::NotFound
279 {
280 continue;
281 }
282 }
283
284 let blob_path = blobs_path.join(&file_info.after_hash);
286 let patched_content = match tokio::fs::read(&blob_path).await {
287 Ok(content) => content,
288 Err(e) => {
289 result.error = Some(format!(
290 "Failed to read blob {}: {}",
291 file_info.after_hash, e
292 ));
293 return result;
294 }
295 };
296
297 if let Err(e) = apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await {
299 result.error = Some(e.to_string());
300 return result;
301 }
302
303 result.files_patched.push(file_name.clone());
304 }
305
306 result.success = true;
307 result
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::hash::git_sha256::compute_git_sha256_from_bytes;
314
315 #[test]
316 fn test_normalize_file_path_with_prefix() {
317 assert_eq!(normalize_file_path("package/lib/server.js"), "lib/server.js");
318 }
319
320 #[test]
321 fn test_normalize_file_path_without_prefix() {
322 assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js");
323 }
324
325 #[test]
326 fn test_normalize_file_path_just_prefix() {
327 assert_eq!(normalize_file_path("package/"), "");
328 }
329
330 #[test]
331 fn test_normalize_file_path_package_not_prefix() {
332 assert_eq!(normalize_file_path("packagefoo/bar.js"), "packagefoo/bar.js");
334 }
335
336 #[tokio::test]
337 async fn test_verify_file_patch_not_found() {
338 let dir = tempfile::tempdir().unwrap();
339 let file_info = PatchFileInfo {
340 before_hash: "aaa".to_string(),
341 after_hash: "bbb".to_string(),
342 };
343
344 let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await;
345 assert_eq!(result.status, VerifyStatus::NotFound);
346 }
347
348 #[tokio::test]
349 async fn test_verify_file_patch_ready() {
350 let dir = tempfile::tempdir().unwrap();
351 let content = b"original content";
352 let before_hash = compute_git_sha256_from_bytes(content);
353 let after_hash = "bbbbbbbb".to_string();
354
355 tokio::fs::write(dir.path().join("index.js"), content)
356 .await
357 .unwrap();
358
359 let file_info = PatchFileInfo {
360 before_hash: before_hash.clone(),
361 after_hash,
362 };
363
364 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
365 assert_eq!(result.status, VerifyStatus::Ready);
366 assert_eq!(result.current_hash.unwrap(), before_hash);
367 }
368
369 #[tokio::test]
370 async fn test_verify_file_patch_already_patched() {
371 let dir = tempfile::tempdir().unwrap();
372 let content = b"patched content";
373 let after_hash = compute_git_sha256_from_bytes(content);
374
375 tokio::fs::write(dir.path().join("index.js"), content)
376 .await
377 .unwrap();
378
379 let file_info = PatchFileInfo {
380 before_hash: "aaaa".to_string(),
381 after_hash: after_hash.clone(),
382 };
383
384 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
385 assert_eq!(result.status, VerifyStatus::AlreadyPatched);
386 }
387
388 #[tokio::test]
389 async fn test_verify_file_patch_hash_mismatch() {
390 let dir = tempfile::tempdir().unwrap();
391 tokio::fs::write(dir.path().join("index.js"), b"something else")
392 .await
393 .unwrap();
394
395 let file_info = PatchFileInfo {
396 before_hash: "aaaa".to_string(),
397 after_hash: "bbbb".to_string(),
398 };
399
400 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
401 assert_eq!(result.status, VerifyStatus::HashMismatch);
402 }
403
404 #[tokio::test]
405 async fn test_verify_with_package_prefix() {
406 let dir = tempfile::tempdir().unwrap();
407 let content = b"original content";
408 let before_hash = compute_git_sha256_from_bytes(content);
409
410 tokio::fs::create_dir_all(dir.path().join("lib")).await.unwrap();
412 tokio::fs::write(dir.path().join("lib/server.js"), content)
413 .await
414 .unwrap();
415
416 let file_info = PatchFileInfo {
417 before_hash: before_hash.clone(),
418 after_hash: "bbbb".to_string(),
419 };
420
421 let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await;
422 assert_eq!(result.status, VerifyStatus::Ready);
423 }
424
425 #[tokio::test]
426 async fn test_apply_file_patch_success() {
427 let dir = tempfile::tempdir().unwrap();
428 let original = b"original";
429 let patched = b"patched content";
430 let patched_hash = compute_git_sha256_from_bytes(patched);
431
432 tokio::fs::write(dir.path().join("index.js"), original)
433 .await
434 .unwrap();
435
436 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
437 .await
438 .unwrap();
439
440 let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
441 assert_eq!(written, patched);
442 }
443
444 #[tokio::test]
445 async fn test_apply_file_patch_hash_mismatch() {
446 let dir = tempfile::tempdir().unwrap();
447 tokio::fs::write(dir.path().join("index.js"), b"original")
448 .await
449 .unwrap();
450
451 let result =
452 apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await;
453 assert!(result.is_err());
454 let err = result.unwrap_err();
455 assert!(err.to_string().contains("Hash verification failed"));
456 }
457
458 #[tokio::test]
459 async fn test_apply_package_patch_success() {
460 let pkg_dir = tempfile::tempdir().unwrap();
461 let blobs_dir = tempfile::tempdir().unwrap();
462
463 let original = b"original content";
464 let patched = b"patched content";
465 let before_hash = compute_git_sha256_from_bytes(original);
466 let after_hash = compute_git_sha256_from_bytes(patched);
467
468 tokio::fs::write(pkg_dir.path().join("index.js"), original)
470 .await
471 .unwrap();
472
473 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
475 .await
476 .unwrap();
477
478 let mut files = HashMap::new();
479 files.insert(
480 "index.js".to_string(),
481 PatchFileInfo {
482 before_hash,
483 after_hash: after_hash.clone(),
484 },
485 );
486
487 let result =
488 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
489 .await;
490
491 assert!(result.success);
492 assert_eq!(result.files_patched.len(), 1);
493 assert!(result.error.is_none());
494 }
495
496 #[tokio::test]
497 async fn test_apply_package_patch_dry_run() {
498 let pkg_dir = tempfile::tempdir().unwrap();
499 let blobs_dir = tempfile::tempdir().unwrap();
500
501 let original = b"original content";
502 let before_hash = compute_git_sha256_from_bytes(original);
503
504 tokio::fs::write(pkg_dir.path().join("index.js"), original)
505 .await
506 .unwrap();
507
508 let mut files = HashMap::new();
509 files.insert(
510 "index.js".to_string(),
511 PatchFileInfo {
512 before_hash,
513 after_hash: "bbbb".to_string(),
514 },
515 );
516
517 let result =
518 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), true, false)
519 .await;
520
521 assert!(result.success);
522 assert_eq!(result.files_patched.len(), 0); let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
526 assert_eq!(content, original);
527 }
528
529 #[tokio::test]
530 async fn test_apply_package_patch_all_already_patched() {
531 let pkg_dir = tempfile::tempdir().unwrap();
532 let blobs_dir = tempfile::tempdir().unwrap();
533
534 let patched = b"patched content";
535 let after_hash = compute_git_sha256_from_bytes(patched);
536
537 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
538 .await
539 .unwrap();
540
541 let mut files = HashMap::new();
542 files.insert(
543 "index.js".to_string(),
544 PatchFileInfo {
545 before_hash: "aaaa".to_string(),
546 after_hash,
547 },
548 );
549
550 let result =
551 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
552 .await;
553
554 assert!(result.success);
555 assert_eq!(result.files_patched.len(), 0);
556 }
557
558 #[tokio::test]
559 async fn test_apply_package_patch_hash_mismatch_blocks() {
560 let pkg_dir = tempfile::tempdir().unwrap();
561 let blobs_dir = tempfile::tempdir().unwrap();
562
563 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
564 .await
565 .unwrap();
566
567 let mut files = HashMap::new();
568 files.insert(
569 "index.js".to_string(),
570 PatchFileInfo {
571 before_hash: "aaaa".to_string(),
572 after_hash: "bbbb".to_string(),
573 },
574 );
575
576 let result =
577 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
578 .await;
579
580 assert!(!result.success);
581 assert!(result.error.is_some());
582 }
583
584 #[tokio::test]
585 async fn test_apply_package_patch_force_hash_mismatch() {
586 let pkg_dir = tempfile::tempdir().unwrap();
587 let blobs_dir = tempfile::tempdir().unwrap();
588
589 let patched = b"patched content";
590 let after_hash = compute_git_sha256_from_bytes(patched);
591
592 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
594 .await
595 .unwrap();
596
597 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
599 .await
600 .unwrap();
601
602 let mut files = HashMap::new();
603 files.insert(
604 "index.js".to_string(),
605 PatchFileInfo {
606 before_hash: "aaaa".to_string(),
607 after_hash: after_hash.clone(),
608 },
609 );
610
611 let result =
613 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
614 .await;
615 assert!(!result.success);
616
617 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
619 .await
620 .unwrap();
621
622 let result =
624 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, true)
625 .await;
626 assert!(result.success);
627 assert_eq!(result.files_patched.len(), 1);
628
629 let written = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
630 assert_eq!(written, patched);
631 }
632
633 #[tokio::test]
634 async fn test_apply_package_patch_force_not_found_skips() {
635 let pkg_dir = tempfile::tempdir().unwrap();
636 let blobs_dir = tempfile::tempdir().unwrap();
637
638 let mut files = HashMap::new();
639 files.insert(
640 "missing.js".to_string(),
641 PatchFileInfo {
642 before_hash: "aaaa".to_string(),
643 after_hash: "bbbb".to_string(),
644 },
645 );
646
647 let result =
649 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
650 .await;
651 assert!(!result.success);
652
653 let result =
655 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, true)
656 .await;
657 assert!(result.success);
658 assert_eq!(result.files_patched.len(), 0);
659 }
660}