Skip to main content

socket_patch_core/patch/
rollback.rs

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/// Status of a file rollback verification.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum VerifyRollbackStatus {
10    /// File is ready to be rolled back (current hash matches afterHash).
11    Ready,
12    /// File is already in the original state (current hash matches beforeHash).
13    AlreadyOriginal,
14    /// File hash does not match the expected afterHash.
15    HashMismatch,
16    /// File was not found on disk.
17    NotFound,
18    /// The before-hash blob needed for rollback is missing from the blobs directory.
19    MissingBlob,
20}
21
22/// Result of verifying whether a single file can be rolled back.
23#[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/// Result of rolling back patches for a single package.
34#[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
44/// Normalize file path by removing the "package/" prefix if present.
45fn 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
54/// Verify a single file can be rolled back.
55///
56/// A file is ready for rollback if:
57/// 1. The file exists on disk.
58/// 2. The before-hash blob exists in the blobs directory.
59/// 3. Its current hash matches the afterHash (patched state).
60pub 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    // For new files (empty beforeHash), rollback means deleting the file.
72    if is_new_file {
73        if tokio::fs::metadata(&filepath).await.is_err() {
74            // File already doesn't exist — already rolled back.
75            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    // Check if file exists
108    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    // Check if before blob exists (required for rollback)
120    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    // Compute current hash
136    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    // Check if already in original state
151    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    // Check if matches expected patched hash (afterHash)
163    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
186/// Rollback a single file to its original state.
187/// Writes the original content and verifies the resulting hash.
188pub 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    // Make file writable if it is read-only (e.g. Go module cache)
198    #[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    // Write the original content
211    tokio::fs::write(&filepath, original_content).await?;
212
213    // Verify the hash after writing
214    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
228/// Verify and rollback patches for a single package.
229///
230/// For each file in `files`, this function:
231/// 1. Verifies the file is ready to be rolled back (or already original).
232/// 2. If not dry_run, reads the before-hash blob and writes it back.
233/// 3. Returns a summary of what happened.
234pub 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    // First, verify all files
251    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 any file has issues (not ready and not already original), we can't proceed
256        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    // Check if all files are already in original state
275    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, stop here
285    if dry_run {
286        result.success = true;
287        return result;
288    }
289
290    // Rollback files that need it
291    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        // New files (empty beforeHash): delete instead of restoring.
303        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        // Read original content from blobs
315        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        // Rollback the file
328        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        // File is in patched state
396        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
397            .await
398            .unwrap();
399
400        // Before blob exists
401        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        // File is already in original state
425        tokio::fs::write(pkg_dir.path().join("index.js"), original)
426            .await
427            .unwrap();
428
429        // Before blob exists
430        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        // File has been modified to something unexpected
453        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
454            .await
455            .unwrap();
456
457        // Before blob exists
458        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        // File currently has patched content
483        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        // File is in patched state
522        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
523            .await
524            .unwrap();
525
526        // Before blob exists
527        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        // Verify file was restored
554        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, // dry run
590        )
591        .await;
592
593        assert!(result.success);
594        assert_eq!(result.files_rolled_back.len(), 0); // dry run
595
596        // File should still be patched
597        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        // File is already original
610        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}