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. Its current hash matches the afterHash (patched state).
59/// 3. The before-hash blob exists in the blobs directory.
60///
61/// A file whose current hash already matches the beforeHash is reported
62/// `AlreadyOriginal` *before* the blob is checked — a finished rollback is
63/// a no-op and must not be blocked by a missing (e.g. GC'd) blob it would
64/// never need to read.
65pub async fn verify_file_rollback(
66    pkg_path: &Path,
67    file_name: &str,
68    file_info: &PatchFileInfo,
69    blobs_path: &Path,
70) -> VerifyRollbackResult {
71    let normalized = normalize_file_path(file_name);
72    let filepath = pkg_path.join(normalized);
73
74    let is_new_file = file_info.before_hash.is_empty();
75
76    // For new files (empty beforeHash), rollback means deleting the file.
77    if is_new_file {
78        if tokio::fs::metadata(&filepath).await.is_err() {
79            // File already doesn't exist — already rolled back.
80            return VerifyRollbackResult {
81                file: file_name.to_string(),
82                status: VerifyRollbackStatus::AlreadyOriginal,
83                message: None,
84                current_hash: None,
85                expected_hash: None,
86                target_hash: None,
87            };
88        }
89        let current_hash = compute_file_git_sha256(&filepath).await.unwrap_or_default();
90        if current_hash == file_info.after_hash {
91            return VerifyRollbackResult {
92                file: file_name.to_string(),
93                status: VerifyRollbackStatus::Ready,
94                message: None,
95                current_hash: Some(current_hash),
96                expected_hash: None,
97                target_hash: None,
98            };
99        }
100        return VerifyRollbackResult {
101            file: file_name.to_string(),
102            status: VerifyRollbackStatus::HashMismatch,
103            message: Some(
104                "File has been modified after patching. Cannot safely rollback.".to_string(),
105            ),
106            current_hash: Some(current_hash),
107            expected_hash: Some(file_info.after_hash.clone()),
108            target_hash: None,
109        };
110    }
111
112    // Check if file exists
113    if tokio::fs::metadata(&filepath).await.is_err() {
114        return VerifyRollbackResult {
115            file: file_name.to_string(),
116            status: VerifyRollbackStatus::NotFound,
117            message: Some("File not found".to_string()),
118            current_hash: None,
119            expected_hash: None,
120            target_hash: None,
121        };
122    }
123
124    // Compute current hash
125    let current_hash = match compute_file_git_sha256(&filepath).await {
126        Ok(h) => h,
127        Err(e) => {
128            return VerifyRollbackResult {
129                file: file_name.to_string(),
130                status: VerifyRollbackStatus::NotFound,
131                message: Some(format!("Failed to hash file: {}", e)),
132                current_hash: None,
133                expected_hash: None,
134                target_hash: None,
135            };
136        }
137    };
138
139    // Check if already in original state. This must be tested BEFORE the
140    // before-blob existence check: a file that is already rolled back
141    // needs no blob to restore, so a garbage-collected blob must not turn
142    // a finished, no-op rollback into a spurious `MissingBlob` failure
143    // (which would otherwise block the whole package's rollback).
144    if current_hash == file_info.before_hash {
145        return VerifyRollbackResult {
146            file: file_name.to_string(),
147            status: VerifyRollbackStatus::AlreadyOriginal,
148            message: None,
149            current_hash: Some(current_hash),
150            expected_hash: None,
151            target_hash: None,
152        };
153    }
154
155    // Check if before blob exists (required to actually restore the file)
156    let before_blob_path = blobs_path.join(&file_info.before_hash);
157    if tokio::fs::metadata(&before_blob_path).await.is_err() {
158        return VerifyRollbackResult {
159            file: file_name.to_string(),
160            status: VerifyRollbackStatus::MissingBlob,
161            message: Some(format!(
162                "Before blob not found: {}. Re-download the patch to enable rollback.",
163                file_info.before_hash
164            )),
165            current_hash: Some(current_hash),
166            expected_hash: None,
167            target_hash: Some(file_info.before_hash.clone()),
168        };
169    }
170
171    // Check if matches expected patched hash (afterHash)
172    if current_hash != file_info.after_hash {
173        return VerifyRollbackResult {
174            file: file_name.to_string(),
175            status: VerifyRollbackStatus::HashMismatch,
176            message: Some(
177                "File has been modified after patching. Cannot safely rollback.".to_string(),
178            ),
179            current_hash: Some(current_hash),
180            expected_hash: Some(file_info.after_hash.clone()),
181            target_hash: Some(file_info.before_hash.clone()),
182        };
183    }
184
185    VerifyRollbackResult {
186        file: file_name.to_string(),
187        status: VerifyRollbackStatus::Ready,
188        message: None,
189        current_hash: Some(current_hash),
190        expected_hash: None,
191        target_hash: Some(file_info.before_hash.clone()),
192    }
193}
194
195/// Rollback a single file to its original state by writing
196/// `original_content` (whose Git SHA256 must equal `expected_hash`).
197///
198/// This delegates to [`apply_file_patch`](crate::patch::apply::apply_file_patch),
199/// the hardened write path shared with apply. Rolling a file back is the
200/// exact same operation as patching it forward — "safely overwrite this
201/// file with these hash-verified bytes" — so it must get the exact same
202/// guarantees:
203///
204/// * **Atomic** — the bytes are staged in the parent directory, fsync'd,
205///   and `rename(2)`d over the target. A crash or `ENOSPC` mid-write
206///   leaves either the old or the new content, never a truncated file.
207/// * **Copy-on-write safe** — a symlink/hardlink into a shared content
208///   store (pnpm, Nix, the Go module cache) is broken into a private
209///   inode first, so a rollback never bleeds into a sibling project's
210///   copy or the store entry.
211/// * **Validate-before-write** — `original_content` is hash-checked in
212///   memory *before* any disk write, so a corrupt blob is refused
213///   instead of being committed over the file and only then flagged.
214/// * **Permission-faithful** — the file's mode + uid/gid are restored
215///   afterward. Because apply preserves a file's original permissions
216///   when patching, the on-disk patched file already carries the
217///   pre-patch mode (e.g. a read-only `0o444` Go-cache source), and
218///   that exact mode is re-applied to the rolled-back inode.
219///
220/// The previous implementation used a bare in-place `tokio::fs::write`,
221/// which had none of these properties: it could corrupt a hardlinked
222/// sibling, leave a half-written file on a crash, write a bad blob over
223/// the file *before* discovering the hash mismatch, and leave a
224/// read-only file writable.
225pub async fn rollback_file_patch(
226    pkg_path: &Path,
227    file_name: &str,
228    original_content: &[u8],
229    expected_hash: &str,
230) -> Result<(), std::io::Error> {
231    crate::patch::apply::apply_file_patch(pkg_path, file_name, original_content, expected_hash)
232        .await
233}
234
235/// Verify and rollback patches for a single package.
236///
237/// For each file in `files`, this function:
238/// 1. Verifies the file is ready to be rolled back (or already original).
239/// 2. If not dry_run, reads the before-hash blob and writes it back.
240/// 3. Returns a summary of what happened.
241pub async fn rollback_package_patch(
242    package_key: &str,
243    pkg_path: &Path,
244    files: &HashMap<String, PatchFileInfo>,
245    blobs_path: &Path,
246    dry_run: bool,
247) -> RollbackResult {
248    let mut result = RollbackResult {
249        package_key: package_key.to_string(),
250        package_path: pkg_path.display().to_string(),
251        success: false,
252        files_verified: Vec::new(),
253        files_rolled_back: Vec::new(),
254        error: None,
255    };
256
257    // First, verify all files
258    for (file_name, file_info) in files {
259        let verify_result = verify_file_rollback(pkg_path, file_name, file_info, blobs_path).await;
260
261        // If any file has issues (not ready and not already original), we can't proceed
262        if verify_result.status != VerifyRollbackStatus::Ready
263            && verify_result.status != VerifyRollbackStatus::AlreadyOriginal
264        {
265            let msg = verify_result
266                .message
267                .clone()
268                .unwrap_or_else(|| format!("{:?}", verify_result.status));
269            result.error = Some(format!("Cannot rollback: {} - {}", verify_result.file, msg));
270            result.files_verified.push(verify_result);
271            return result;
272        }
273
274        result.files_verified.push(verify_result);
275    }
276
277    // Check if all files are already in original state
278    let all_original = result
279        .files_verified
280        .iter()
281        .all(|v| v.status == VerifyRollbackStatus::AlreadyOriginal);
282    if all_original {
283        result.success = true;
284        return result;
285    }
286
287    // If dry run, stop here
288    if dry_run {
289        result.success = true;
290        return result;
291    }
292
293    // Rollback files that need it
294    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 == 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            // Unlinking a directory entry requires write permission on the
307            // *parent directory*, not the file. Go's module cache marks
308            // package directories read-only (0o555), so — exactly as the
309            // apply write path does — temporarily grant owner-write on the
310            // parent and restore its exact mode afterward, whether the
311            // delete succeeds or fails.
312            let dir_guard = crate::patch::apply::DirWriteGuard::acquire(filepath.parent()).await;
313            let remove_result = tokio::fs::remove_file(&filepath).await;
314            dir_guard.restore().await;
315            if let Err(e) = remove_result {
316                result.error = Some(format!("Failed to delete {}: {}", file_name, e));
317                return result;
318            }
319            result.files_rolled_back.push(file_name.clone());
320            continue;
321        }
322
323        // Read original content from blobs
324        let blob_path = blobs_path.join(&file_info.before_hash);
325        let original_content = match tokio::fs::read(&blob_path).await {
326            Ok(content) => content,
327            Err(e) => {
328                result.error = Some(format!(
329                    "Failed to read blob {}: {}",
330                    file_info.before_hash, e
331                ));
332                return result;
333            }
334        };
335
336        // Rollback the file
337        if let Err(e) = rollback_file_patch(
338            pkg_path,
339            file_name,
340            &original_content,
341            &file_info.before_hash,
342        )
343        .await
344        {
345            result.error = Some(e.to_string());
346            return result;
347        }
348
349        result.files_rolled_back.push(file_name.clone());
350    }
351
352    result.success = true;
353    result
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::hash::git_sha256::compute_git_sha256_from_bytes;
360
361    #[tokio::test]
362    async fn test_verify_file_rollback_not_found() {
363        let pkg_dir = tempfile::tempdir().unwrap();
364        let blobs_dir = tempfile::tempdir().unwrap();
365
366        let file_info = PatchFileInfo {
367            before_hash: "aaa".to_string(),
368            after_hash: "bbb".to_string(),
369        };
370
371        let result = verify_file_rollback(
372            pkg_dir.path(),
373            "nonexistent.js",
374            &file_info,
375            blobs_dir.path(),
376        )
377        .await;
378        assert_eq!(result.status, VerifyRollbackStatus::NotFound);
379    }
380
381    #[tokio::test]
382    async fn test_verify_file_rollback_missing_blob() {
383        let pkg_dir = tempfile::tempdir().unwrap();
384        let blobs_dir = tempfile::tempdir().unwrap();
385
386        let content = b"patched content";
387        tokio::fs::write(pkg_dir.path().join("index.js"), content)
388            .await
389            .unwrap();
390
391        let file_info = PatchFileInfo {
392            before_hash: "missing_blob_hash".to_string(),
393            after_hash: compute_git_sha256_from_bytes(content),
394        };
395
396        let result =
397            verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
398        assert_eq!(result.status, VerifyRollbackStatus::MissingBlob);
399        assert!(result.message.unwrap().contains("Before blob not found"));
400    }
401
402    #[tokio::test]
403    async fn test_verify_file_rollback_ready() {
404        let pkg_dir = tempfile::tempdir().unwrap();
405        let blobs_dir = tempfile::tempdir().unwrap();
406
407        let original = b"original content";
408        let patched = b"patched content";
409        let before_hash = compute_git_sha256_from_bytes(original);
410        let after_hash = compute_git_sha256_from_bytes(patched);
411
412        // File is in patched state
413        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
414            .await
415            .unwrap();
416
417        // Before blob exists
418        tokio::fs::write(blobs_dir.path().join(&before_hash), original)
419            .await
420            .unwrap();
421
422        let file_info = PatchFileInfo {
423            before_hash: before_hash.clone(),
424            after_hash: after_hash.clone(),
425        };
426
427        let result =
428            verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
429        assert_eq!(result.status, VerifyRollbackStatus::Ready);
430        assert_eq!(result.current_hash.unwrap(), after_hash);
431    }
432
433    #[tokio::test]
434    async fn test_verify_file_rollback_already_original() {
435        let pkg_dir = tempfile::tempdir().unwrap();
436        let blobs_dir = tempfile::tempdir().unwrap();
437
438        let original = b"original content";
439        let before_hash = compute_git_sha256_from_bytes(original);
440
441        // File is already in original state
442        tokio::fs::write(pkg_dir.path().join("index.js"), original)
443            .await
444            .unwrap();
445
446        // Before blob exists
447        tokio::fs::write(blobs_dir.path().join(&before_hash), original)
448            .await
449            .unwrap();
450
451        let file_info = PatchFileInfo {
452            before_hash: before_hash.clone(),
453            after_hash: "bbbb".to_string(),
454        };
455
456        let result =
457            verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
458        assert_eq!(result.status, VerifyRollbackStatus::AlreadyOriginal);
459    }
460
461    #[tokio::test]
462    async fn test_verify_file_rollback_hash_mismatch() {
463        let pkg_dir = tempfile::tempdir().unwrap();
464        let blobs_dir = tempfile::tempdir().unwrap();
465
466        let original = b"original content";
467        let before_hash = compute_git_sha256_from_bytes(original);
468
469        // File has been modified to something unexpected
470        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
471            .await
472            .unwrap();
473
474        // Before blob exists
475        tokio::fs::write(blobs_dir.path().join(&before_hash), original)
476            .await
477            .unwrap();
478
479        let file_info = PatchFileInfo {
480            before_hash,
481            after_hash: "expected_after_hash".to_string(),
482        };
483
484        let result =
485            verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
486        assert_eq!(result.status, VerifyRollbackStatus::HashMismatch);
487        assert!(result.message.unwrap().contains("modified after patching"));
488    }
489
490    #[tokio::test]
491    async fn test_rollback_file_patch_success() {
492        let dir = tempfile::tempdir().unwrap();
493        let original = b"original content";
494        let original_hash = compute_git_sha256_from_bytes(original);
495
496        // File currently has patched content
497        tokio::fs::write(dir.path().join("index.js"), b"patched")
498            .await
499            .unwrap();
500
501        rollback_file_patch(dir.path(), "index.js", original, &original_hash)
502            .await
503            .unwrap();
504
505        let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
506        assert_eq!(written, original);
507    }
508
509    #[tokio::test]
510    async fn test_rollback_file_patch_hash_mismatch() {
511        let dir = tempfile::tempdir().unwrap();
512        tokio::fs::write(dir.path().join("index.js"), b"patched")
513            .await
514            .unwrap();
515
516        let result =
517            rollback_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await;
518        assert!(result.is_err());
519        assert!(result
520            .unwrap_err()
521            .to_string()
522            .contains("Hash verification failed"));
523    }
524
525    /// Validate-before-write: a corrupt/mismatched rollback blob must be
526    /// refused *before* any disk write, leaving the on-disk file
527    /// byte-identical to its pre-call (patched) state and dropping no
528    /// `.socket-stage-*` litter. Regression: the old in-place
529    /// `tokio::fs::write` committed the bad bytes over the file and only
530    /// then hashed, leaving the file corrupted on the error path.
531    #[tokio::test]
532    async fn test_rollback_file_patch_hash_mismatch_leaves_file_intact() {
533        let dir = tempfile::tempdir().unwrap();
534        let path = dir.path().join("index.js");
535        tokio::fs::write(&path, b"patched bytes on disk")
536            .await
537            .unwrap();
538
539        let result =
540            rollback_file_patch(dir.path(), "index.js", b"original content", "wrong_hash").await;
541        assert!(result.is_err());
542
543        // The file must NOT have been overwritten with the bad blob.
544        assert_eq!(
545            tokio::fs::read(&path).await.unwrap(),
546            b"patched bytes on disk"
547        );
548
549        // No staged temp file leaked into the directory.
550        let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap();
551        while let Some(entry) = entries.next_entry().await.unwrap() {
552            let name = entry.file_name().to_string_lossy().to_string();
553            assert!(
554                !name.starts_with(".socket-stage-") && !name.starts_with(".socket-cow-"),
555                "stage/cow litter leaked: {name}"
556            );
557        }
558    }
559
560    /// Copy-on-write safety: rolling back a file that shares an inode
561    /// with a sibling (the pnpm / Go-cache hardlink case) must only
562    /// restore *our* copy. The sibling — another project's view or the
563    /// shared store entry — must keep its bytes. Regression: the old
564    /// in-place write mutated the shared inode and corrupted the sibling.
565    #[cfg(unix)]
566    #[tokio::test]
567    async fn test_rollback_file_patch_does_not_propagate_to_hardlinked_sibling() {
568        let dir = tempfile::tempdir().unwrap();
569        let project = dir.path().join("project").join("foo.js");
570        let sibling = dir.path().join("sibling.js");
571        tokio::fs::create_dir_all(project.parent().unwrap())
572            .await
573            .unwrap();
574
575        // Both paths point at the same inode, both currently "patched".
576        tokio::fs::write(&sibling, b"patched bytes").await.unwrap();
577        tokio::fs::hard_link(&sibling, &project).await.unwrap();
578
579        let original = b"original bytes";
580        let original_hash = compute_git_sha256_from_bytes(original);
581        rollback_file_patch(
582            project.parent().unwrap(),
583            "foo.js",
584            original,
585            &original_hash,
586        )
587        .await
588        .unwrap();
589
590        // Our project view is rolled back...
591        assert_eq!(tokio::fs::read(&project).await.unwrap(), original);
592        // ...but the sibling inode is untouched.
593        assert_eq!(tokio::fs::read(&sibling).await.unwrap(), b"patched bytes");
594    }
595
596    /// Permission fidelity: rolling back a read-only file (Go module
597    /// cache marks sources `0o444`) must restore the original content
598    /// AND leave the file read-only afterward. Regression: the old code
599    /// relaxed the mode to `0o644` to write and never restored it,
600    /// silently leaving rolled-back cache files writable.
601    #[cfg(unix)]
602    #[tokio::test]
603    async fn test_rollback_file_patch_preserves_readonly_mode() {
604        use std::os::unix::fs::PermissionsExt;
605
606        let dir = tempfile::tempdir().unwrap();
607        let path = dir.path().join("index.js");
608        let original = b"original content";
609        let original_hash = compute_git_sha256_from_bytes(original);
610
611        tokio::fs::write(&path, b"patched content").await.unwrap();
612        // Read-only patched file, as apply would have left a Go-cache source.
613        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444))
614            .await
615            .unwrap();
616
617        rollback_file_patch(dir.path(), "index.js", original, &original_hash)
618            .await
619            .unwrap();
620
621        assert_eq!(tokio::fs::read(&path).await.unwrap(), original);
622        let mode = tokio::fs::metadata(&path)
623            .await
624            .unwrap()
625            .permissions()
626            .mode()
627            & 0o7777;
628        assert_eq!(
629            mode, 0o444,
630            "rollback must restore the read-only mode, not leave the file writable"
631        );
632    }
633
634    /// End-to-end rollback against a fully read-only package directory
635    /// (Go cache: `0o444` files inside a `0o555` directory). The atomic
636    /// stage+rename path must temporarily grant directory write, restore
637    /// content, and put the directory mode back. Regression: the old
638    /// in-place write could not stage inside a read-only directory.
639    #[cfg(unix)]
640    #[tokio::test]
641    async fn test_rollback_package_patch_in_readonly_dir() {
642        use std::os::unix::fs::PermissionsExt;
643
644        let pkg_dir = tempfile::tempdir().unwrap();
645        let blobs_dir = tempfile::tempdir().unwrap();
646
647        let original = b"original content";
648        let patched = b"patched content";
649        let before_hash = compute_git_sha256_from_bytes(original);
650        let after_hash = compute_git_sha256_from_bytes(patched);
651
652        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
653            .await
654            .unwrap();
655        tokio::fs::write(blobs_dir.path().join(&before_hash), original)
656            .await
657            .unwrap();
658        // Lock the file and directory down, Go-cache style.
659        tokio::fs::set_permissions(
660            pkg_dir.path().join("index.js"),
661            std::fs::Permissions::from_mode(0o444),
662        )
663        .await
664        .unwrap();
665        tokio::fs::set_permissions(pkg_dir.path(), std::fs::Permissions::from_mode(0o555))
666            .await
667            .unwrap();
668
669        let mut files = HashMap::new();
670        files.insert(
671            "index.js".to_string(),
672            PatchFileInfo {
673                before_hash,
674                after_hash,
675            },
676        );
677
678        let result = rollback_package_patch(
679            "pkg:golang/example.com/x@1.0.0",
680            pkg_dir.path(),
681            &files,
682            blobs_dir.path(),
683            false,
684        )
685        .await;
686
687        assert!(result.success, "expected success: {:?}", result.error);
688        assert_eq!(result.files_rolled_back.len(), 1);
689        assert_eq!(
690            tokio::fs::read(pkg_dir.path().join("index.js"))
691                .await
692                .unwrap(),
693            original
694        );
695        // Directory mode restored to exactly 0o555.
696        assert_eq!(
697            tokio::fs::metadata(pkg_dir.path())
698                .await
699                .unwrap()
700                .permissions()
701                .mode()
702                & 0o7777,
703            0o555,
704        );
705
706        // Re-grant write so the TempDir can clean itself up.
707        tokio::fs::set_permissions(pkg_dir.path(), std::fs::Permissions::from_mode(0o755))
708            .await
709            .unwrap();
710    }
711
712    #[tokio::test]
713    async fn test_rollback_package_patch_success() {
714        let pkg_dir = tempfile::tempdir().unwrap();
715        let blobs_dir = tempfile::tempdir().unwrap();
716
717        let original = b"original content";
718        let patched = b"patched content";
719        let before_hash = compute_git_sha256_from_bytes(original);
720        let after_hash = compute_git_sha256_from_bytes(patched);
721
722        // File is in patched state
723        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
724            .await
725            .unwrap();
726
727        // Before blob exists
728        tokio::fs::write(blobs_dir.path().join(&before_hash), original)
729            .await
730            .unwrap();
731
732        let mut files = HashMap::new();
733        files.insert(
734            "index.js".to_string(),
735            PatchFileInfo {
736                before_hash: before_hash.clone(),
737                after_hash,
738            },
739        );
740
741        let result = rollback_package_patch(
742            "pkg:npm/test@1.0.0",
743            pkg_dir.path(),
744            &files,
745            blobs_dir.path(),
746            false,
747        )
748        .await;
749
750        assert!(result.success);
751        assert_eq!(result.files_rolled_back.len(), 1);
752        assert!(result.error.is_none());
753
754        // Verify file was restored
755        let content = tokio::fs::read(pkg_dir.path().join("index.js"))
756            .await
757            .unwrap();
758        assert_eq!(content, original);
759    }
760
761    #[tokio::test]
762    async fn test_rollback_package_patch_dry_run() {
763        let pkg_dir = tempfile::tempdir().unwrap();
764        let blobs_dir = tempfile::tempdir().unwrap();
765
766        let original = b"original content";
767        let patched = b"patched content";
768        let before_hash = compute_git_sha256_from_bytes(original);
769        let after_hash = compute_git_sha256_from_bytes(patched);
770
771        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
772            .await
773            .unwrap();
774        tokio::fs::write(blobs_dir.path().join(&before_hash), original)
775            .await
776            .unwrap();
777
778        let mut files = HashMap::new();
779        files.insert(
780            "index.js".to_string(),
781            PatchFileInfo {
782                before_hash,
783                after_hash,
784            },
785        );
786
787        let result = rollback_package_patch(
788            "pkg:npm/test@1.0.0",
789            pkg_dir.path(),
790            &files,
791            blobs_dir.path(),
792            true, // dry run
793        )
794        .await;
795
796        assert!(result.success);
797        assert_eq!(result.files_rolled_back.len(), 0); // dry run
798
799        // File should still be patched
800        let content = tokio::fs::read(pkg_dir.path().join("index.js"))
801            .await
802            .unwrap();
803        assert_eq!(content, patched);
804    }
805
806    #[tokio::test]
807    async fn test_rollback_package_patch_all_original() {
808        let pkg_dir = tempfile::tempdir().unwrap();
809        let blobs_dir = tempfile::tempdir().unwrap();
810
811        let original = b"original content";
812        let before_hash = compute_git_sha256_from_bytes(original);
813
814        // File is already original
815        tokio::fs::write(pkg_dir.path().join("index.js"), original)
816            .await
817            .unwrap();
818        tokio::fs::write(blobs_dir.path().join(&before_hash), original)
819            .await
820            .unwrap();
821
822        let mut files = HashMap::new();
823        files.insert(
824            "index.js".to_string(),
825            PatchFileInfo {
826                before_hash,
827                after_hash: "bbbb".to_string(),
828            },
829        );
830
831        let result = rollback_package_patch(
832            "pkg:npm/test@1.0.0",
833            pkg_dir.path(),
834            &files,
835            blobs_dir.path(),
836            false,
837        )
838        .await;
839
840        assert!(result.success);
841        assert_eq!(result.files_rolled_back.len(), 0);
842    }
843
844    #[tokio::test]
845    async fn test_rollback_package_patch_missing_blob_blocks() {
846        let pkg_dir = tempfile::tempdir().unwrap();
847        let blobs_dir = tempfile::tempdir().unwrap();
848
849        tokio::fs::write(pkg_dir.path().join("index.js"), b"patched content")
850            .await
851            .unwrap();
852
853        let mut files = HashMap::new();
854        files.insert(
855            "index.js".to_string(),
856            PatchFileInfo {
857                before_hash: "missing_hash".to_string(),
858                after_hash: "bbbb".to_string(),
859            },
860        );
861
862        let result = rollback_package_patch(
863            "pkg:npm/test@1.0.0",
864            pkg_dir.path(),
865            &files,
866            blobs_dir.path(),
867            false,
868        )
869        .await;
870
871        assert!(!result.success);
872        assert!(result.error.is_some());
873    }
874
875    /// Regression (blob-vs-already-original ordering): a file already at
876    /// its original (`beforeHash`) state must verify as `AlreadyOriginal`
877    /// even when the before-blob is gone. A finished rollback needs no
878    /// blob to restore, so a GC'd blob must NOT downgrade it to
879    /// `MissingBlob`. Before the fix the blob check ran first and a
880    /// re-run rollback (or one after blob cleanup) reported a spurious
881    /// missing-blob failure.
882    #[tokio::test]
883    async fn test_verify_file_rollback_already_original_without_blob() {
884        let pkg_dir = tempfile::tempdir().unwrap();
885        let blobs_dir = tempfile::tempdir().unwrap();
886
887        let original = b"original content";
888        let before_hash = compute_git_sha256_from_bytes(original);
889
890        // File is already at its original state, but NO before-blob exists.
891        tokio::fs::write(pkg_dir.path().join("index.js"), original)
892            .await
893            .unwrap();
894
895        let file_info = PatchFileInfo {
896            before_hash,
897            after_hash: "some_after_hash".to_string(),
898        };
899
900        let result =
901            verify_file_rollback(pkg_dir.path(), "index.js", &file_info, blobs_dir.path()).await;
902        assert_eq!(result.status, VerifyRollbackStatus::AlreadyOriginal);
903    }
904
905    /// Package-level consequence of the ordering fix: an already-original
906    /// file whose blob was GC'd must not block its sibling's real
907    /// rollback. The whole package should succeed and the ready file
908    /// should be restored. Before the fix the missing blob on the
909    /// no-op file aborted the entire package rollback.
910    #[tokio::test]
911    async fn test_rollback_package_patch_already_original_missing_blob_does_not_block() {
912        let pkg_dir = tempfile::tempdir().unwrap();
913        let blobs_dir = tempfile::tempdir().unwrap();
914
915        // File A: already at original state; its before-blob is absent.
916        let a_original = b"a original";
917        let a_before = compute_git_sha256_from_bytes(a_original);
918        tokio::fs::write(pkg_dir.path().join("a.js"), a_original)
919            .await
920            .unwrap();
921
922        // File B: still patched; before-blob present, ready to roll back.
923        let b_original = b"b original";
924        let b_patched = b"b patched";
925        let b_before = compute_git_sha256_from_bytes(b_original);
926        let b_after = compute_git_sha256_from_bytes(b_patched);
927        tokio::fs::write(pkg_dir.path().join("b.js"), b_patched)
928            .await
929            .unwrap();
930        tokio::fs::write(blobs_dir.path().join(&b_before), b_original)
931            .await
932            .unwrap();
933
934        let mut files = HashMap::new();
935        files.insert(
936            "a.js".to_string(),
937            PatchFileInfo {
938                before_hash: a_before,
939                after_hash: "a_after".to_string(),
940            },
941        );
942        files.insert(
943            "b.js".to_string(),
944            PatchFileInfo {
945                before_hash: b_before,
946                after_hash: b_after,
947            },
948        );
949
950        let result = rollback_package_patch(
951            "pkg:npm/test@1.0.0",
952            pkg_dir.path(),
953            &files,
954            blobs_dir.path(),
955            false,
956        )
957        .await;
958
959        assert!(result.success, "expected success: {:?}", result.error);
960        assert_eq!(result.files_rolled_back, vec!["b.js".to_string()]);
961        assert_eq!(
962            tokio::fs::read(pkg_dir.path().join("b.js")).await.unwrap(),
963            b_original
964        );
965        // A was already original and untouched.
966        assert_eq!(
967            tokio::fs::read(pkg_dir.path().join("a.js")).await.unwrap(),
968            a_original
969        );
970    }
971
972    /// New-file rollback (empty `beforeHash`): the file the patch added
973    /// is deleted when its content still matches `afterHash`.
974    #[tokio::test]
975    async fn test_rollback_package_patch_new_file_deleted() {
976        let pkg_dir = tempfile::tempdir().unwrap();
977        let blobs_dir = tempfile::tempdir().unwrap();
978
979        let added = b"file added by the patch\n";
980        let after_hash = compute_git_sha256_from_bytes(added);
981        let path = pkg_dir.path().join("added.js");
982        tokio::fs::write(&path, added).await.unwrap();
983
984        let mut files = HashMap::new();
985        files.insert(
986            "added.js".to_string(),
987            PatchFileInfo {
988                before_hash: String::new(),
989                after_hash,
990            },
991        );
992
993        let result = rollback_package_patch(
994            "pkg:npm/test@1.0.0",
995            pkg_dir.path(),
996            &files,
997            blobs_dir.path(),
998            false,
999        )
1000        .await;
1001
1002        assert!(result.success, "expected success: {:?}", result.error);
1003        assert_eq!(result.files_rolled_back, vec!["added.js".to_string()]);
1004        assert!(
1005            tokio::fs::metadata(&path).await.is_err(),
1006            "the patch-added file must be deleted on rollback"
1007        );
1008    }
1009
1010    /// New-file rollback is a no-op (success, nothing deleted) when the
1011    /// added file is already gone — e.g. the operator removed it by hand.
1012    #[tokio::test]
1013    async fn test_rollback_package_patch_new_file_already_gone() {
1014        let pkg_dir = tempfile::tempdir().unwrap();
1015        let blobs_dir = tempfile::tempdir().unwrap();
1016
1017        let mut files = HashMap::new();
1018        files.insert(
1019            "added.js".to_string(),
1020            PatchFileInfo {
1021                before_hash: String::new(),
1022                after_hash: compute_git_sha256_from_bytes(b"whatever"),
1023            },
1024        );
1025
1026        let result = rollback_package_patch(
1027            "pkg:npm/test@1.0.0",
1028            pkg_dir.path(),
1029            &files,
1030            blobs_dir.path(),
1031            false,
1032        )
1033        .await;
1034
1035        assert!(result.success, "expected success: {:?}", result.error);
1036        assert_eq!(result.files_rolled_back.len(), 0);
1037    }
1038
1039    /// Regression (read-only-dir delete): deleting a patch-added file
1040    /// requires write permission on the *parent directory*. A Go-cache
1041    /// style read-only directory (0o555) must be temporarily relaxed for
1042    /// the unlink and restored to its exact prior mode afterward. Before
1043    /// the fix the bare `remove_file` failed with EACCES.
1044    #[cfg(unix)]
1045    #[tokio::test]
1046    async fn test_rollback_package_patch_new_file_delete_in_readonly_dir() {
1047        use std::os::unix::fs::PermissionsExt;
1048
1049        let pkg_dir = tempfile::tempdir().unwrap();
1050        let blobs_dir = tempfile::tempdir().unwrap();
1051
1052        let added = b"added by patch\n";
1053        let after_hash = compute_git_sha256_from_bytes(added);
1054        let path = pkg_dir.path().join("added.js");
1055        tokio::fs::write(&path, added).await.unwrap();
1056        // Read-only file inside a read-only directory (Go cache layout).
1057        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444))
1058            .await
1059            .unwrap();
1060        tokio::fs::set_permissions(pkg_dir.path(), std::fs::Permissions::from_mode(0o555))
1061            .await
1062            .unwrap();
1063
1064        let mut files = HashMap::new();
1065        files.insert(
1066            "added.js".to_string(),
1067            PatchFileInfo {
1068                before_hash: String::new(),
1069                after_hash,
1070            },
1071        );
1072
1073        let result = rollback_package_patch(
1074            "pkg:golang/example.com/x@1.0.0",
1075            pkg_dir.path(),
1076            &files,
1077            blobs_dir.path(),
1078            false,
1079        )
1080        .await;
1081
1082        assert!(result.success, "expected success: {:?}", result.error);
1083        assert_eq!(result.files_rolled_back, vec!["added.js".to_string()]);
1084        assert!(tokio::fs::metadata(&path).await.is_err());
1085        // Directory mode restored to exactly 0o555.
1086        assert_eq!(
1087            tokio::fs::metadata(pkg_dir.path())
1088                .await
1089                .unwrap()
1090                .permissions()
1091                .mode()
1092                & 0o7777,
1093            0o555,
1094        );
1095
1096        // Re-grant write so the TempDir can clean itself up.
1097        tokio::fs::set_permissions(pkg_dir.path(), std::fs::Permissions::from_mode(0o755))
1098            .await
1099            .unwrap();
1100    }
1101}