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    // Write the original content
198    tokio::fs::write(&filepath, original_content).await?;
199
200    // Verify the hash after writing
201    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
215/// Verify and rollback patches for a single package.
216///
217/// For each file in `files`, this function:
218/// 1. Verifies the file is ready to be rolled back (or already original).
219/// 2. If not dry_run, reads the before-hash blob and writes it back.
220/// 3. Returns a summary of what happened.
221pub 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    // First, verify all files
238    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 any file has issues (not ready and not already original), we can't proceed
243        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    // Check if all files are already in original state
262    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, stop here
272    if dry_run {
273        result.success = true;
274        return result;
275    }
276
277    // Rollback files that need it
278    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        // New files (empty beforeHash): delete instead of restoring.
290        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        // Read original content from blobs
302        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        // Rollback the file
315        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        // File is in patched state
383        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
384            .await
385            .unwrap();
386
387        // Before blob exists
388        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        // File is already in original state
412        tokio::fs::write(pkg_dir.path().join("index.js"), original)
413            .await
414            .unwrap();
415
416        // Before blob exists
417        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        // File has been modified to something unexpected
440        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
441            .await
442            .unwrap();
443
444        // Before blob exists
445        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        // File currently has patched content
470        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        // File is in patched state
509        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
510            .await
511            .unwrap();
512
513        // Before blob exists
514        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        // Verify file was restored
541        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, // dry run
577        )
578        .await;
579
580        assert!(result.success);
581        assert_eq!(result.files_rolled_back.len(), 0); // dry run
582
583        // File should still be patched
584        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        // File is already original
597        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}