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 VerifyRollbackStatus {
10 Ready,
12 AlreadyOriginal,
14 HashMismatch,
16 NotFound,
18 MissingBlob,
20}
21
22#[derive(Debug, Clone)]
24pub struct VerifyRollbackResult {
25 pub file: String,
26 pub status: VerifyRollbackStatus,
27 pub message: Option<String>,
28 pub current_hash: Option<String>,
29 pub expected_hash: Option<String>,
30 pub target_hash: Option<String>,
31}
32
33#[derive(Debug, Clone)]
35pub struct RollbackResult {
36 pub package_key: String,
37 pub package_path: String,
38 pub success: bool,
39 pub files_verified: Vec<VerifyRollbackResult>,
40 pub files_rolled_back: Vec<String>,
41 pub error: Option<String>,
42}
43
44fn 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_rollback(
61 pkg_path: &Path,
62 file_name: &str,
63 file_info: &PatchFileInfo,
64 blobs_path: &Path,
65) -> VerifyRollbackResult {
66 let normalized = normalize_file_path(file_name);
67 let filepath = pkg_path.join(normalized);
68
69 let is_new_file = file_info.before_hash.is_empty();
70
71 if is_new_file {
73 if tokio::fs::metadata(&filepath).await.is_err() {
74 return VerifyRollbackResult {
76 file: file_name.to_string(),
77 status: VerifyRollbackStatus::AlreadyOriginal,
78 message: None,
79 current_hash: None,
80 expected_hash: None,
81 target_hash: None,
82 };
83 }
84 let current_hash = compute_file_git_sha256(&filepath).await.unwrap_or_default();
85 if current_hash == file_info.after_hash {
86 return VerifyRollbackResult {
87 file: file_name.to_string(),
88 status: VerifyRollbackStatus::Ready,
89 message: None,
90 current_hash: Some(current_hash),
91 expected_hash: None,
92 target_hash: None,
93 };
94 }
95 return VerifyRollbackResult {
96 file: file_name.to_string(),
97 status: VerifyRollbackStatus::HashMismatch,
98 message: Some(
99 "File has been modified after patching. Cannot safely rollback.".to_string(),
100 ),
101 current_hash: Some(current_hash),
102 expected_hash: Some(file_info.after_hash.clone()),
103 target_hash: None,
104 };
105 }
106
107 if tokio::fs::metadata(&filepath).await.is_err() {
109 return VerifyRollbackResult {
110 file: file_name.to_string(),
111 status: VerifyRollbackStatus::NotFound,
112 message: Some("File not found".to_string()),
113 current_hash: None,
114 expected_hash: None,
115 target_hash: None,
116 };
117 }
118
119 let before_blob_path = blobs_path.join(&file_info.before_hash);
121 if tokio::fs::metadata(&before_blob_path).await.is_err() {
122 return VerifyRollbackResult {
123 file: file_name.to_string(),
124 status: VerifyRollbackStatus::MissingBlob,
125 message: Some(format!(
126 "Before blob not found: {}. Re-download the patch to enable rollback.",
127 file_info.before_hash
128 )),
129 current_hash: None,
130 expected_hash: None,
131 target_hash: Some(file_info.before_hash.clone()),
132 };
133 }
134
135 let current_hash = match compute_file_git_sha256(&filepath).await {
137 Ok(h) => h,
138 Err(e) => {
139 return VerifyRollbackResult {
140 file: file_name.to_string(),
141 status: VerifyRollbackStatus::NotFound,
142 message: Some(format!("Failed to hash file: {}", e)),
143 current_hash: None,
144 expected_hash: None,
145 target_hash: None,
146 };
147 }
148 };
149
150 if current_hash == file_info.before_hash {
152 return VerifyRollbackResult {
153 file: file_name.to_string(),
154 status: VerifyRollbackStatus::AlreadyOriginal,
155 message: None,
156 current_hash: Some(current_hash),
157 expected_hash: None,
158 target_hash: None,
159 };
160 }
161
162 if current_hash != file_info.after_hash {
164 return VerifyRollbackResult {
165 file: file_name.to_string(),
166 status: VerifyRollbackStatus::HashMismatch,
167 message: Some(
168 "File has been modified after patching. Cannot safely rollback.".to_string(),
169 ),
170 current_hash: Some(current_hash),
171 expected_hash: Some(file_info.after_hash.clone()),
172 target_hash: Some(file_info.before_hash.clone()),
173 };
174 }
175
176 VerifyRollbackResult {
177 file: file_name.to_string(),
178 status: VerifyRollbackStatus::Ready,
179 message: None,
180 current_hash: Some(current_hash),
181 expected_hash: None,
182 target_hash: Some(file_info.before_hash.clone()),
183 }
184}
185
186pub async fn rollback_file_patch(
189 pkg_path: &Path,
190 file_name: &str,
191 original_content: &[u8],
192 expected_hash: &str,
193) -> Result<(), std::io::Error> {
194 let normalized = normalize_file_path(file_name);
195 let filepath = pkg_path.join(normalized);
196
197 #[cfg(unix)]
199 if let Ok(meta) = tokio::fs::metadata(&filepath).await {
200 use std::os::unix::fs::PermissionsExt;
201 let perms = meta.permissions();
202 if perms.readonly() {
203 let mode = perms.mode();
204 let mut new_perms = perms;
205 new_perms.set_mode(mode | 0o200);
206 tokio::fs::set_permissions(&filepath, new_perms).await?;
207 }
208 }
209
210 tokio::fs::write(&filepath, original_content).await?;
212
213 let verify_hash = compute_file_git_sha256(&filepath).await?;
215 if verify_hash != expected_hash {
216 return Err(std::io::Error::new(
217 std::io::ErrorKind::InvalidData,
218 format!(
219 "Hash verification failed after rollback. Expected: {}, Got: {}",
220 expected_hash, verify_hash
221 ),
222 ));
223 }
224
225 Ok(())
226}
227
228pub async fn rollback_package_patch(
235 package_key: &str,
236 pkg_path: &Path,
237 files: &HashMap<String, PatchFileInfo>,
238 blobs_path: &Path,
239 dry_run: bool,
240) -> RollbackResult {
241 let mut result = RollbackResult {
242 package_key: package_key.to_string(),
243 package_path: pkg_path.display().to_string(),
244 success: false,
245 files_verified: Vec::new(),
246 files_rolled_back: Vec::new(),
247 error: None,
248 };
249
250 for (file_name, file_info) in files {
252 let verify_result =
253 verify_file_rollback(pkg_path, file_name, file_info, blobs_path).await;
254
255 if verify_result.status != VerifyRollbackStatus::Ready
257 && verify_result.status != VerifyRollbackStatus::AlreadyOriginal
258 {
259 let msg = verify_result
260 .message
261 .clone()
262 .unwrap_or_else(|| format!("{:?}", verify_result.status));
263 result.error = Some(format!(
264 "Cannot rollback: {} - {}",
265 verify_result.file, msg
266 ));
267 result.files_verified.push(verify_result);
268 return result;
269 }
270
271 result.files_verified.push(verify_result);
272 }
273
274 let all_original = result
276 .files_verified
277 .iter()
278 .all(|v| v.status == VerifyRollbackStatus::AlreadyOriginal);
279 if all_original {
280 result.success = true;
281 return result;
282 }
283
284 if dry_run {
286 result.success = true;
287 return result;
288 }
289
290 for (file_name, file_info) in files {
292 let verify_result = result
293 .files_verified
294 .iter()
295 .find(|v| v.file == *file_name);
296 if let Some(vr) = verify_result {
297 if vr.status == VerifyRollbackStatus::AlreadyOriginal {
298 continue;
299 }
300 }
301
302 if file_info.before_hash.is_empty() {
304 let normalized = normalize_file_path(file_name);
305 let filepath = pkg_path.join(normalized);
306 if let Err(e) = tokio::fs::remove_file(&filepath).await {
307 result.error = Some(format!("Failed to delete {}: {}", file_name, e));
308 return result;
309 }
310 result.files_rolled_back.push(file_name.clone());
311 continue;
312 }
313
314 let blob_path = blobs_path.join(&file_info.before_hash);
316 let original_content = match tokio::fs::read(&blob_path).await {
317 Ok(content) => content,
318 Err(e) => {
319 result.error = Some(format!(
320 "Failed to read blob {}: {}",
321 file_info.before_hash, e
322 ));
323 return result;
324 }
325 };
326
327 if let Err(e) =
329 rollback_file_patch(pkg_path, file_name, &original_content, &file_info.before_hash)
330 .await
331 {
332 result.error = Some(e.to_string());
333 return result;
334 }
335
336 result.files_rolled_back.push(file_name.clone());
337 }
338
339 result.success = true;
340 result
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::hash::git_sha256::compute_git_sha256_from_bytes;
347
348 #[tokio::test]
349 async fn test_verify_file_rollback_not_found() {
350 let pkg_dir = tempfile::tempdir().unwrap();
351 let blobs_dir = tempfile::tempdir().unwrap();
352
353 let file_info = PatchFileInfo {
354 before_hash: "aaa".to_string(),
355 after_hash: "bbb".to_string(),
356 };
357
358 let result =
359 verify_file_rollback(pkg_dir.path(), "nonexistent.js", &file_info, blobs_dir.path())
360 .await;
361 assert_eq!(result.status, VerifyRollbackStatus::NotFound);
362 }
363
364 #[tokio::test]
365 async fn test_verify_file_rollback_missing_blob() {
366 let pkg_dir = tempfile::tempdir().unwrap();
367 let blobs_dir = tempfile::tempdir().unwrap();
368
369 let content = b"patched content";
370 tokio::fs::write(pkg_dir.path().join("index.js"), content)
371 .await
372 .unwrap();
373
374 let file_info = PatchFileInfo {
375 before_hash: "missing_blob_hash".to_string(),
376 after_hash: compute_git_sha256_from_bytes(content),
377 };
378
379 let result =
380 verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
381 assert_eq!(result.status, VerifyRollbackStatus::MissingBlob);
382 assert!(result.message.unwrap().contains("Before blob not found"));
383 }
384
385 #[tokio::test]
386 async fn test_verify_file_rollback_ready() {
387 let pkg_dir = tempfile::tempdir().unwrap();
388 let blobs_dir = tempfile::tempdir().unwrap();
389
390 let original = b"original content";
391 let patched = b"patched content";
392 let before_hash = compute_git_sha256_from_bytes(original);
393 let after_hash = compute_git_sha256_from_bytes(patched);
394
395 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
397 .await
398 .unwrap();
399
400 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
402 .await
403 .unwrap();
404
405 let file_info = PatchFileInfo {
406 before_hash: before_hash.clone(),
407 after_hash: after_hash.clone(),
408 };
409
410 let result =
411 verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
412 assert_eq!(result.status, VerifyRollbackStatus::Ready);
413 assert_eq!(result.current_hash.unwrap(), after_hash);
414 }
415
416 #[tokio::test]
417 async fn test_verify_file_rollback_already_original() {
418 let pkg_dir = tempfile::tempdir().unwrap();
419 let blobs_dir = tempfile::tempdir().unwrap();
420
421 let original = b"original content";
422 let before_hash = compute_git_sha256_from_bytes(original);
423
424 tokio::fs::write(pkg_dir.path().join("index.js"), original)
426 .await
427 .unwrap();
428
429 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
431 .await
432 .unwrap();
433
434 let file_info = PatchFileInfo {
435 before_hash: before_hash.clone(),
436 after_hash: "bbbb".to_string(),
437 };
438
439 let result =
440 verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
441 assert_eq!(result.status, VerifyRollbackStatus::AlreadyOriginal);
442 }
443
444 #[tokio::test]
445 async fn test_verify_file_rollback_hash_mismatch() {
446 let pkg_dir = tempfile::tempdir().unwrap();
447 let blobs_dir = tempfile::tempdir().unwrap();
448
449 let original = b"original content";
450 let before_hash = compute_git_sha256_from_bytes(original);
451
452 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
454 .await
455 .unwrap();
456
457 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
459 .await
460 .unwrap();
461
462 let file_info = PatchFileInfo {
463 before_hash,
464 after_hash: "expected_after_hash".to_string(),
465 };
466
467 let result =
468 verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
469 assert_eq!(result.status, VerifyRollbackStatus::HashMismatch);
470 assert!(result
471 .message
472 .unwrap()
473 .contains("modified after patching"));
474 }
475
476 #[tokio::test]
477 async fn test_rollback_file_patch_success() {
478 let dir = tempfile::tempdir().unwrap();
479 let original = b"original content";
480 let original_hash = compute_git_sha256_from_bytes(original);
481
482 tokio::fs::write(dir.path().join("index.js"), b"patched")
484 .await
485 .unwrap();
486
487 rollback_file_patch(dir.path(), "index.js", original, &original_hash)
488 .await
489 .unwrap();
490
491 let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
492 assert_eq!(written, original);
493 }
494
495 #[tokio::test]
496 async fn test_rollback_file_patch_hash_mismatch() {
497 let dir = tempfile::tempdir().unwrap();
498 tokio::fs::write(dir.path().join("index.js"), b"patched")
499 .await
500 .unwrap();
501
502 let result =
503 rollback_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await;
504 assert!(result.is_err());
505 assert!(result
506 .unwrap_err()
507 .to_string()
508 .contains("Hash verification failed"));
509 }
510
511 #[tokio::test]
512 async fn test_rollback_package_patch_success() {
513 let pkg_dir = tempfile::tempdir().unwrap();
514 let blobs_dir = tempfile::tempdir().unwrap();
515
516 let original = b"original content";
517 let patched = b"patched content";
518 let before_hash = compute_git_sha256_from_bytes(original);
519 let after_hash = compute_git_sha256_from_bytes(patched);
520
521 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
523 .await
524 .unwrap();
525
526 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
528 .await
529 .unwrap();
530
531 let mut files = HashMap::new();
532 files.insert(
533 "index.js".to_string(),
534 PatchFileInfo {
535 before_hash: before_hash.clone(),
536 after_hash,
537 },
538 );
539
540 let result = rollback_package_patch(
541 "pkg:npm/test@1.0.0",
542 pkg_dir.path(),
543 &files,
544 blobs_dir.path(),
545 false,
546 )
547 .await;
548
549 assert!(result.success);
550 assert_eq!(result.files_rolled_back.len(), 1);
551 assert!(result.error.is_none());
552
553 let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
555 assert_eq!(content, original);
556 }
557
558 #[tokio::test]
559 async fn test_rollback_package_patch_dry_run() {
560 let pkg_dir = tempfile::tempdir().unwrap();
561 let blobs_dir = tempfile::tempdir().unwrap();
562
563 let original = b"original content";
564 let patched = b"patched content";
565 let before_hash = compute_git_sha256_from_bytes(original);
566 let after_hash = compute_git_sha256_from_bytes(patched);
567
568 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
569 .await
570 .unwrap();
571 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
572 .await
573 .unwrap();
574
575 let mut files = HashMap::new();
576 files.insert(
577 "index.js".to_string(),
578 PatchFileInfo {
579 before_hash,
580 after_hash,
581 },
582 );
583
584 let result = rollback_package_patch(
585 "pkg:npm/test@1.0.0",
586 pkg_dir.path(),
587 &files,
588 blobs_dir.path(),
589 true, )
591 .await;
592
593 assert!(result.success);
594 assert_eq!(result.files_rolled_back.len(), 0); let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
598 assert_eq!(content, patched);
599 }
600
601 #[tokio::test]
602 async fn test_rollback_package_patch_all_original() {
603 let pkg_dir = tempfile::tempdir().unwrap();
604 let blobs_dir = tempfile::tempdir().unwrap();
605
606 let original = b"original content";
607 let before_hash = compute_git_sha256_from_bytes(original);
608
609 tokio::fs::write(pkg_dir.path().join("index.js"), original)
611 .await
612 .unwrap();
613 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
614 .await
615 .unwrap();
616
617 let mut files = HashMap::new();
618 files.insert(
619 "index.js".to_string(),
620 PatchFileInfo {
621 before_hash,
622 after_hash: "bbbb".to_string(),
623 },
624 );
625
626 let result = rollback_package_patch(
627 "pkg:npm/test@1.0.0",
628 pkg_dir.path(),
629 &files,
630 blobs_dir.path(),
631 false,
632 )
633 .await;
634
635 assert!(result.success);
636 assert_eq!(result.files_rolled_back.len(), 0);
637 }
638
639 #[tokio::test]
640 async fn test_rollback_package_patch_missing_blob_blocks() {
641 let pkg_dir = tempfile::tempdir().unwrap();
642 let blobs_dir = tempfile::tempdir().unwrap();
643
644 tokio::fs::write(pkg_dir.path().join("index.js"), b"patched content")
645 .await
646 .unwrap();
647
648 let mut files = HashMap::new();
649 files.insert(
650 "index.js".to_string(),
651 PatchFileInfo {
652 before_hash: "missing_hash".to_string(),
653 after_hash: "bbbb".to_string(),
654 },
655 );
656
657 let result = rollback_package_patch(
658 "pkg:npm/test@1.0.0",
659 pkg_dir.path(),
660 &files,
661 blobs_dir.path(),
662 false,
663 )
664 .await;
665
666 assert!(!result.success);
667 assert!(result.error.is_some());
668 }
669}