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_already_patched = result
259 .files_verified
260 .iter()
261 .all(|v| v.status == VerifyStatus::AlreadyPatched);
262
263 if all_already_patched {
264 result.success = true;
265 return result;
266 }
267
268 let all_done_or_skipped = result
270 .files_verified
271 .iter()
272 .all(|v| v.status == VerifyStatus::AlreadyPatched || v.status == VerifyStatus::NotFound);
273
274 if all_done_or_skipped {
275 let not_found_count = result.files_verified.iter()
277 .filter(|v| v.status == VerifyStatus::NotFound)
278 .count();
279 result.success = true;
280 result.error = Some(format!(
281 "All patch files were skipped: {} not found on disk (--force)",
282 not_found_count
283 ));
284 return result;
285 }
286
287 if dry_run {
289 result.success = true;
290 return result;
291 }
292
293 for (file_name, file_info) in files {
295 let verify_result = result.files_verified.iter().find(|v| v.file == *file_name);
296 if let Some(vr) = verify_result {
297 if vr.status == VerifyStatus::AlreadyPatched
298 || vr.status == VerifyStatus::NotFound
299 {
300 continue;
301 }
302 }
303
304 let blob_path = blobs_path.join(&file_info.after_hash);
306 let patched_content = match tokio::fs::read(&blob_path).await {
307 Ok(content) => content,
308 Err(e) => {
309 result.error = Some(format!(
310 "Failed to read blob {}: {}",
311 file_info.after_hash, e
312 ));
313 return result;
314 }
315 };
316
317 if let Err(e) = apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await {
319 result.error = Some(e.to_string());
320 return result;
321 }
322
323 result.files_patched.push(file_name.clone());
324 }
325
326 result.success = true;
327 result
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::hash::git_sha256::compute_git_sha256_from_bytes;
334
335 #[test]
336 fn test_normalize_file_path_with_prefix() {
337 assert_eq!(normalize_file_path("package/lib/server.js"), "lib/server.js");
338 }
339
340 #[test]
341 fn test_normalize_file_path_without_prefix() {
342 assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js");
343 }
344
345 #[test]
346 fn test_normalize_file_path_just_prefix() {
347 assert_eq!(normalize_file_path("package/"), "");
348 }
349
350 #[test]
351 fn test_normalize_file_path_package_not_prefix() {
352 assert_eq!(normalize_file_path("packagefoo/bar.js"), "packagefoo/bar.js");
354 }
355
356 #[tokio::test]
357 async fn test_verify_file_patch_not_found() {
358 let dir = tempfile::tempdir().unwrap();
359 let file_info = PatchFileInfo {
360 before_hash: "aaa".to_string(),
361 after_hash: "bbb".to_string(),
362 };
363
364 let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await;
365 assert_eq!(result.status, VerifyStatus::NotFound);
366 }
367
368 #[tokio::test]
369 async fn test_verify_file_patch_ready() {
370 let dir = tempfile::tempdir().unwrap();
371 let content = b"original content";
372 let before_hash = compute_git_sha256_from_bytes(content);
373 let after_hash = "bbbbbbbb".to_string();
374
375 tokio::fs::write(dir.path().join("index.js"), content)
376 .await
377 .unwrap();
378
379 let file_info = PatchFileInfo {
380 before_hash: before_hash.clone(),
381 after_hash,
382 };
383
384 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
385 assert_eq!(result.status, VerifyStatus::Ready);
386 assert_eq!(result.current_hash.unwrap(), before_hash);
387 }
388
389 #[tokio::test]
390 async fn test_verify_file_patch_already_patched() {
391 let dir = tempfile::tempdir().unwrap();
392 let content = b"patched content";
393 let after_hash = compute_git_sha256_from_bytes(content);
394
395 tokio::fs::write(dir.path().join("index.js"), content)
396 .await
397 .unwrap();
398
399 let file_info = PatchFileInfo {
400 before_hash: "aaaa".to_string(),
401 after_hash: after_hash.clone(),
402 };
403
404 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
405 assert_eq!(result.status, VerifyStatus::AlreadyPatched);
406 }
407
408 #[tokio::test]
409 async fn test_verify_file_patch_hash_mismatch() {
410 let dir = tempfile::tempdir().unwrap();
411 tokio::fs::write(dir.path().join("index.js"), b"something else")
412 .await
413 .unwrap();
414
415 let file_info = PatchFileInfo {
416 before_hash: "aaaa".to_string(),
417 after_hash: "bbbb".to_string(),
418 };
419
420 let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
421 assert_eq!(result.status, VerifyStatus::HashMismatch);
422 }
423
424 #[tokio::test]
425 async fn test_verify_with_package_prefix() {
426 let dir = tempfile::tempdir().unwrap();
427 let content = b"original content";
428 let before_hash = compute_git_sha256_from_bytes(content);
429
430 tokio::fs::create_dir_all(dir.path().join("lib")).await.unwrap();
432 tokio::fs::write(dir.path().join("lib/server.js"), content)
433 .await
434 .unwrap();
435
436 let file_info = PatchFileInfo {
437 before_hash: before_hash.clone(),
438 after_hash: "bbbb".to_string(),
439 };
440
441 let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await;
442 assert_eq!(result.status, VerifyStatus::Ready);
443 }
444
445 #[tokio::test]
446 async fn test_apply_file_patch_success() {
447 let dir = tempfile::tempdir().unwrap();
448 let original = b"original";
449 let patched = b"patched content";
450 let patched_hash = compute_git_sha256_from_bytes(patched);
451
452 tokio::fs::write(dir.path().join("index.js"), original)
453 .await
454 .unwrap();
455
456 apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
457 .await
458 .unwrap();
459
460 let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
461 assert_eq!(written, patched);
462 }
463
464 #[tokio::test]
465 async fn test_apply_file_patch_hash_mismatch() {
466 let dir = tempfile::tempdir().unwrap();
467 tokio::fs::write(dir.path().join("index.js"), b"original")
468 .await
469 .unwrap();
470
471 let result =
472 apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await;
473 assert!(result.is_err());
474 let err = result.unwrap_err();
475 assert!(err.to_string().contains("Hash verification failed"));
476 }
477
478 #[tokio::test]
479 async fn test_apply_package_patch_success() {
480 let pkg_dir = tempfile::tempdir().unwrap();
481 let blobs_dir = tempfile::tempdir().unwrap();
482
483 let original = b"original content";
484 let patched = b"patched content";
485 let before_hash = compute_git_sha256_from_bytes(original);
486 let after_hash = compute_git_sha256_from_bytes(patched);
487
488 tokio::fs::write(pkg_dir.path().join("index.js"), original)
490 .await
491 .unwrap();
492
493 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
495 .await
496 .unwrap();
497
498 let mut files = HashMap::new();
499 files.insert(
500 "index.js".to_string(),
501 PatchFileInfo {
502 before_hash,
503 after_hash: after_hash.clone(),
504 },
505 );
506
507 let result =
508 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
509 .await;
510
511 assert!(result.success);
512 assert_eq!(result.files_patched.len(), 1);
513 assert!(result.error.is_none());
514 }
515
516 #[tokio::test]
517 async fn test_apply_package_patch_dry_run() {
518 let pkg_dir = tempfile::tempdir().unwrap();
519 let blobs_dir = tempfile::tempdir().unwrap();
520
521 let original = b"original content";
522 let before_hash = compute_git_sha256_from_bytes(original);
523
524 tokio::fs::write(pkg_dir.path().join("index.js"), original)
525 .await
526 .unwrap();
527
528 let mut files = HashMap::new();
529 files.insert(
530 "index.js".to_string(),
531 PatchFileInfo {
532 before_hash,
533 after_hash: "bbbb".to_string(),
534 },
535 );
536
537 let result =
538 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), true, false)
539 .await;
540
541 assert!(result.success);
542 assert_eq!(result.files_patched.len(), 0); let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
546 assert_eq!(content, original);
547 }
548
549 #[tokio::test]
550 async fn test_apply_package_patch_all_already_patched() {
551 let pkg_dir = tempfile::tempdir().unwrap();
552 let blobs_dir = tempfile::tempdir().unwrap();
553
554 let patched = b"patched content";
555 let after_hash = compute_git_sha256_from_bytes(patched);
556
557 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
558 .await
559 .unwrap();
560
561 let mut files = HashMap::new();
562 files.insert(
563 "index.js".to_string(),
564 PatchFileInfo {
565 before_hash: "aaaa".to_string(),
566 after_hash,
567 },
568 );
569
570 let result =
571 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
572 .await;
573
574 assert!(result.success);
575 assert_eq!(result.files_patched.len(), 0);
576 }
577
578 #[tokio::test]
579 async fn test_apply_package_patch_hash_mismatch_blocks() {
580 let pkg_dir = tempfile::tempdir().unwrap();
581 let blobs_dir = tempfile::tempdir().unwrap();
582
583 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
584 .await
585 .unwrap();
586
587 let mut files = HashMap::new();
588 files.insert(
589 "index.js".to_string(),
590 PatchFileInfo {
591 before_hash: "aaaa".to_string(),
592 after_hash: "bbbb".to_string(),
593 },
594 );
595
596 let result =
597 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
598 .await;
599
600 assert!(!result.success);
601 assert!(result.error.is_some());
602 }
603
604 #[tokio::test]
605 async fn test_apply_package_patch_force_hash_mismatch() {
606 let pkg_dir = tempfile::tempdir().unwrap();
607 let blobs_dir = tempfile::tempdir().unwrap();
608
609 let patched = b"patched content";
610 let after_hash = compute_git_sha256_from_bytes(patched);
611
612 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
614 .await
615 .unwrap();
616
617 tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
619 .await
620 .unwrap();
621
622 let mut files = HashMap::new();
623 files.insert(
624 "index.js".to_string(),
625 PatchFileInfo {
626 before_hash: "aaaa".to_string(),
627 after_hash: after_hash.clone(),
628 },
629 );
630
631 let result =
633 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
634 .await;
635 assert!(!result.success);
636
637 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
639 .await
640 .unwrap();
641
642 let result =
644 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, true)
645 .await;
646 assert!(result.success);
647 assert_eq!(result.files_patched.len(), 1);
648
649 let written = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
650 assert_eq!(written, patched);
651 }
652
653 #[tokio::test]
654 async fn test_apply_package_patch_force_not_found_skips() {
655 let pkg_dir = tempfile::tempdir().unwrap();
656 let blobs_dir = tempfile::tempdir().unwrap();
657
658 let mut files = HashMap::new();
659 files.insert(
660 "missing.js".to_string(),
661 PatchFileInfo {
662 before_hash: "aaaa".to_string(),
663 after_hash: "bbbb".to_string(),
664 },
665 );
666
667 let result =
669 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
670 .await;
671 assert!(!result.success);
672
673 let result =
675 apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, true)
676 .await;
677 assert!(result.success);
678 assert_eq!(result.files_patched.len(), 0);
679 }
680}