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 tokio::fs::write(&filepath, original_content).await?;
199
200 let verify_hash = compute_file_git_sha256(&filepath).await?;
202 if verify_hash != expected_hash {
203 return Err(std::io::Error::new(
204 std::io::ErrorKind::InvalidData,
205 format!(
206 "Hash verification failed after rollback. Expected: {}, Got: {}",
207 expected_hash, verify_hash
208 ),
209 ));
210 }
211
212 Ok(())
213}
214
215pub async fn rollback_package_patch(
222 package_key: &str,
223 pkg_path: &Path,
224 files: &HashMap<String, PatchFileInfo>,
225 blobs_path: &Path,
226 dry_run: bool,
227) -> RollbackResult {
228 let mut result = RollbackResult {
229 package_key: package_key.to_string(),
230 package_path: pkg_path.display().to_string(),
231 success: false,
232 files_verified: Vec::new(),
233 files_rolled_back: Vec::new(),
234 error: None,
235 };
236
237 for (file_name, file_info) in files {
239 let verify_result =
240 verify_file_rollback(pkg_path, file_name, file_info, blobs_path).await;
241
242 if verify_result.status != VerifyRollbackStatus::Ready
244 && verify_result.status != VerifyRollbackStatus::AlreadyOriginal
245 {
246 let msg = verify_result
247 .message
248 .clone()
249 .unwrap_or_else(|| format!("{:?}", verify_result.status));
250 result.error = Some(format!(
251 "Cannot rollback: {} - {}",
252 verify_result.file, msg
253 ));
254 result.files_verified.push(verify_result);
255 return result;
256 }
257
258 result.files_verified.push(verify_result);
259 }
260
261 let all_original = result
263 .files_verified
264 .iter()
265 .all(|v| v.status == VerifyRollbackStatus::AlreadyOriginal);
266 if all_original {
267 result.success = true;
268 return result;
269 }
270
271 if dry_run {
273 result.success = true;
274 return result;
275 }
276
277 for (file_name, file_info) in files {
279 let verify_result = result
280 .files_verified
281 .iter()
282 .find(|v| v.file == *file_name);
283 if let Some(vr) = verify_result {
284 if vr.status == VerifyRollbackStatus::AlreadyOriginal {
285 continue;
286 }
287 }
288
289 if file_info.before_hash.is_empty() {
291 let normalized = normalize_file_path(file_name);
292 let filepath = pkg_path.join(normalized);
293 if let Err(e) = tokio::fs::remove_file(&filepath).await {
294 result.error = Some(format!("Failed to delete {}: {}", file_name, e));
295 return result;
296 }
297 result.files_rolled_back.push(file_name.clone());
298 continue;
299 }
300
301 let blob_path = blobs_path.join(&file_info.before_hash);
303 let original_content = match tokio::fs::read(&blob_path).await {
304 Ok(content) => content,
305 Err(e) => {
306 result.error = Some(format!(
307 "Failed to read blob {}: {}",
308 file_info.before_hash, e
309 ));
310 return result;
311 }
312 };
313
314 if let Err(e) =
316 rollback_file_patch(pkg_path, file_name, &original_content, &file_info.before_hash)
317 .await
318 {
319 result.error = Some(e.to_string());
320 return result;
321 }
322
323 result.files_rolled_back.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 #[tokio::test]
336 async fn test_verify_file_rollback_not_found() {
337 let pkg_dir = tempfile::tempdir().unwrap();
338 let blobs_dir = tempfile::tempdir().unwrap();
339
340 let file_info = PatchFileInfo {
341 before_hash: "aaa".to_string(),
342 after_hash: "bbb".to_string(),
343 };
344
345 let result =
346 verify_file_rollback(pkg_dir.path(), "nonexistent.js", &file_info, blobs_dir.path())
347 .await;
348 assert_eq!(result.status, VerifyRollbackStatus::NotFound);
349 }
350
351 #[tokio::test]
352 async fn test_verify_file_rollback_missing_blob() {
353 let pkg_dir = tempfile::tempdir().unwrap();
354 let blobs_dir = tempfile::tempdir().unwrap();
355
356 let content = b"patched content";
357 tokio::fs::write(pkg_dir.path().join("index.js"), content)
358 .await
359 .unwrap();
360
361 let file_info = PatchFileInfo {
362 before_hash: "missing_blob_hash".to_string(),
363 after_hash: compute_git_sha256_from_bytes(content),
364 };
365
366 let result =
367 verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
368 assert_eq!(result.status, VerifyRollbackStatus::MissingBlob);
369 assert!(result.message.unwrap().contains("Before blob not found"));
370 }
371
372 #[tokio::test]
373 async fn test_verify_file_rollback_ready() {
374 let pkg_dir = tempfile::tempdir().unwrap();
375 let blobs_dir = tempfile::tempdir().unwrap();
376
377 let original = b"original content";
378 let patched = b"patched content";
379 let before_hash = compute_git_sha256_from_bytes(original);
380 let after_hash = compute_git_sha256_from_bytes(patched);
381
382 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
384 .await
385 .unwrap();
386
387 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
389 .await
390 .unwrap();
391
392 let file_info = PatchFileInfo {
393 before_hash: before_hash.clone(),
394 after_hash: after_hash.clone(),
395 };
396
397 let result =
398 verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
399 assert_eq!(result.status, VerifyRollbackStatus::Ready);
400 assert_eq!(result.current_hash.unwrap(), after_hash);
401 }
402
403 #[tokio::test]
404 async fn test_verify_file_rollback_already_original() {
405 let pkg_dir = tempfile::tempdir().unwrap();
406 let blobs_dir = tempfile::tempdir().unwrap();
407
408 let original = b"original content";
409 let before_hash = compute_git_sha256_from_bytes(original);
410
411 tokio::fs::write(pkg_dir.path().join("index.js"), original)
413 .await
414 .unwrap();
415
416 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
418 .await
419 .unwrap();
420
421 let file_info = PatchFileInfo {
422 before_hash: before_hash.clone(),
423 after_hash: "bbbb".to_string(),
424 };
425
426 let result =
427 verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
428 assert_eq!(result.status, VerifyRollbackStatus::AlreadyOriginal);
429 }
430
431 #[tokio::test]
432 async fn test_verify_file_rollback_hash_mismatch() {
433 let pkg_dir = tempfile::tempdir().unwrap();
434 let blobs_dir = tempfile::tempdir().unwrap();
435
436 let original = b"original content";
437 let before_hash = compute_git_sha256_from_bytes(original);
438
439 tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
441 .await
442 .unwrap();
443
444 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
446 .await
447 .unwrap();
448
449 let file_info = PatchFileInfo {
450 before_hash,
451 after_hash: "expected_after_hash".to_string(),
452 };
453
454 let result =
455 verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
456 assert_eq!(result.status, VerifyRollbackStatus::HashMismatch);
457 assert!(result
458 .message
459 .unwrap()
460 .contains("modified after patching"));
461 }
462
463 #[tokio::test]
464 async fn test_rollback_file_patch_success() {
465 let dir = tempfile::tempdir().unwrap();
466 let original = b"original content";
467 let original_hash = compute_git_sha256_from_bytes(original);
468
469 tokio::fs::write(dir.path().join("index.js"), b"patched")
471 .await
472 .unwrap();
473
474 rollback_file_patch(dir.path(), "index.js", original, &original_hash)
475 .await
476 .unwrap();
477
478 let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
479 assert_eq!(written, original);
480 }
481
482 #[tokio::test]
483 async fn test_rollback_file_patch_hash_mismatch() {
484 let dir = tempfile::tempdir().unwrap();
485 tokio::fs::write(dir.path().join("index.js"), b"patched")
486 .await
487 .unwrap();
488
489 let result =
490 rollback_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await;
491 assert!(result.is_err());
492 assert!(result
493 .unwrap_err()
494 .to_string()
495 .contains("Hash verification failed"));
496 }
497
498 #[tokio::test]
499 async fn test_rollback_package_patch_success() {
500 let pkg_dir = tempfile::tempdir().unwrap();
501 let blobs_dir = tempfile::tempdir().unwrap();
502
503 let original = b"original content";
504 let patched = b"patched content";
505 let before_hash = compute_git_sha256_from_bytes(original);
506 let after_hash = compute_git_sha256_from_bytes(patched);
507
508 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
510 .await
511 .unwrap();
512
513 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
515 .await
516 .unwrap();
517
518 let mut files = HashMap::new();
519 files.insert(
520 "index.js".to_string(),
521 PatchFileInfo {
522 before_hash: before_hash.clone(),
523 after_hash,
524 },
525 );
526
527 let result = rollback_package_patch(
528 "pkg:npm/test@1.0.0",
529 pkg_dir.path(),
530 &files,
531 blobs_dir.path(),
532 false,
533 )
534 .await;
535
536 assert!(result.success);
537 assert_eq!(result.files_rolled_back.len(), 1);
538 assert!(result.error.is_none());
539
540 let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
542 assert_eq!(content, original);
543 }
544
545 #[tokio::test]
546 async fn test_rollback_package_patch_dry_run() {
547 let pkg_dir = tempfile::tempdir().unwrap();
548 let blobs_dir = tempfile::tempdir().unwrap();
549
550 let original = b"original content";
551 let patched = b"patched content";
552 let before_hash = compute_git_sha256_from_bytes(original);
553 let after_hash = compute_git_sha256_from_bytes(patched);
554
555 tokio::fs::write(pkg_dir.path().join("index.js"), patched)
556 .await
557 .unwrap();
558 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
559 .await
560 .unwrap();
561
562 let mut files = HashMap::new();
563 files.insert(
564 "index.js".to_string(),
565 PatchFileInfo {
566 before_hash,
567 after_hash,
568 },
569 );
570
571 let result = rollback_package_patch(
572 "pkg:npm/test@1.0.0",
573 pkg_dir.path(),
574 &files,
575 blobs_dir.path(),
576 true, )
578 .await;
579
580 assert!(result.success);
581 assert_eq!(result.files_rolled_back.len(), 0); let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
585 assert_eq!(content, patched);
586 }
587
588 #[tokio::test]
589 async fn test_rollback_package_patch_all_original() {
590 let pkg_dir = tempfile::tempdir().unwrap();
591 let blobs_dir = tempfile::tempdir().unwrap();
592
593 let original = b"original content";
594 let before_hash = compute_git_sha256_from_bytes(original);
595
596 tokio::fs::write(pkg_dir.path().join("index.js"), original)
598 .await
599 .unwrap();
600 tokio::fs::write(blobs_dir.path().join(&before_hash), original)
601 .await
602 .unwrap();
603
604 let mut files = HashMap::new();
605 files.insert(
606 "index.js".to_string(),
607 PatchFileInfo {
608 before_hash,
609 after_hash: "bbbb".to_string(),
610 },
611 );
612
613 let result = rollback_package_patch(
614 "pkg:npm/test@1.0.0",
615 pkg_dir.path(),
616 &files,
617 blobs_dir.path(),
618 false,
619 )
620 .await;
621
622 assert!(result.success);
623 assert_eq!(result.files_rolled_back.len(), 0);
624 }
625
626 #[tokio::test]
627 async fn test_rollback_package_patch_missing_blob_blocks() {
628 let pkg_dir = tempfile::tempdir().unwrap();
629 let blobs_dir = tempfile::tempdir().unwrap();
630
631 tokio::fs::write(pkg_dir.path().join("index.js"), b"patched content")
632 .await
633 .unwrap();
634
635 let mut files = HashMap::new();
636 files.insert(
637 "index.js".to_string(),
638 PatchFileInfo {
639 before_hash: "missing_hash".to_string(),
640 after_hash: "bbbb".to_string(),
641 },
642 );
643
644 let result = rollback_package_patch(
645 "pkg:npm/test@1.0.0",
646 pkg_dir.path(),
647 &files,
648 blobs_dir.path(),
649 false,
650 )
651 .await;
652
653 assert!(!result.success);
654 assert!(result.error.is_some());
655 }
656}