Skip to main content

socket_patch_core/patch/
apply.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 patch verification.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum VerifyStatus {
10    /// File is ready to be patched (current hash matches beforeHash).
11    Ready,
12    /// File is already in the patched state (current hash matches afterHash).
13    AlreadyPatched,
14    /// File hash does not match either beforeHash or afterHash.
15    HashMismatch,
16    /// File was not found on disk.
17    NotFound,
18}
19
20/// Result of verifying whether a single file can be patched.
21#[derive(Debug, Clone)]
22pub struct VerifyResult {
23    pub file: String,
24    pub status: VerifyStatus,
25    pub message: Option<String>,
26    pub current_hash: Option<String>,
27    pub expected_hash: Option<String>,
28    pub target_hash: Option<String>,
29}
30
31/// Result of applying patches to a single package.
32#[derive(Debug, Clone)]
33pub struct ApplyResult {
34    pub package_key: String,
35    pub package_path: String,
36    pub success: bool,
37    pub files_verified: Vec<VerifyResult>,
38    pub files_patched: Vec<String>,
39    pub error: Option<String>,
40}
41
42/// Normalize file path by removing the "package/" prefix if present.
43/// Patch files come from the API with paths like "package/lib/file.js"
44/// but we need relative paths like "lib/file.js" for the actual package directory.
45pub fn 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 patched.
55pub async fn verify_file_patch(
56    pkg_path: &Path,
57    file_name: &str,
58    file_info: &PatchFileInfo,
59) -> VerifyResult {
60    let normalized = normalize_file_path(file_name);
61    let filepath = pkg_path.join(normalized);
62
63    let is_new_file = file_info.before_hash.is_empty();
64
65    // Check if file exists
66    if tokio::fs::metadata(&filepath).await.is_err() {
67        // New files (empty beforeHash) are expected to not exist yet.
68        if is_new_file {
69            return VerifyResult {
70                file: file_name.to_string(),
71                status: VerifyStatus::Ready,
72                message: None,
73                current_hash: None,
74                expected_hash: None,
75                target_hash: Some(file_info.after_hash.clone()),
76            };
77        }
78        return VerifyResult {
79            file: file_name.to_string(),
80            status: VerifyStatus::NotFound,
81            message: Some("File not found".to_string()),
82            current_hash: None,
83            expected_hash: None,
84            target_hash: None,
85        };
86    }
87
88    // Compute current hash
89    let current_hash = match compute_file_git_sha256(&filepath).await {
90        Ok(h) => h,
91        Err(e) => {
92            return VerifyResult {
93                file: file_name.to_string(),
94                status: VerifyStatus::NotFound,
95                message: Some(format!("Failed to hash file: {}", e)),
96                current_hash: None,
97                expected_hash: None,
98                target_hash: None,
99            };
100        }
101    };
102
103    // Check if already patched
104    if current_hash == file_info.after_hash {
105        return VerifyResult {
106            file: file_name.to_string(),
107            status: VerifyStatus::AlreadyPatched,
108            message: None,
109            current_hash: Some(current_hash),
110            expected_hash: None,
111            target_hash: None,
112        };
113    }
114
115    // New files (empty beforeHash) with existing content that doesn't match
116    // afterHash: treat as Ready (force overwrite).
117    if is_new_file {
118        return VerifyResult {
119            file: file_name.to_string(),
120            status: VerifyStatus::Ready,
121            message: None,
122            current_hash: Some(current_hash),
123            expected_hash: None,
124            target_hash: Some(file_info.after_hash.clone()),
125        };
126    }
127
128    // Check if matches expected before hash
129    if current_hash != file_info.before_hash {
130        return VerifyResult {
131            file: file_name.to_string(),
132            status: VerifyStatus::HashMismatch,
133            message: Some("File hash does not match expected value".to_string()),
134            current_hash: Some(current_hash),
135            expected_hash: Some(file_info.before_hash.clone()),
136            target_hash: Some(file_info.after_hash.clone()),
137        };
138    }
139
140    VerifyResult {
141        file: file_name.to_string(),
142        status: VerifyStatus::Ready,
143        message: None,
144        current_hash: Some(current_hash),
145        expected_hash: None,
146        target_hash: Some(file_info.after_hash.clone()),
147    }
148}
149
150/// Apply a patch to a single file.
151/// Writes the patched content and verifies the resulting hash.
152pub async fn apply_file_patch(
153    pkg_path: &Path,
154    file_name: &str,
155    patched_content: &[u8],
156    expected_hash: &str,
157) -> Result<(), std::io::Error> {
158    let normalized = normalize_file_path(file_name);
159    let filepath = pkg_path.join(normalized);
160
161    // Create parent directories if needed (e.g., new files added by a patch)
162    if let Some(parent) = filepath.parent() {
163        tokio::fs::create_dir_all(parent).await?;
164    }
165
166    // Make file writable if it exists and is read-only (e.g. Go module cache)
167    #[cfg(unix)]
168    if let Ok(meta) = tokio::fs::metadata(&filepath).await {
169        use std::os::unix::fs::PermissionsExt;
170        let perms = meta.permissions();
171        if perms.readonly() {
172            let mode = perms.mode();
173            let mut new_perms = perms;
174            new_perms.set_mode(mode | 0o200);
175            tokio::fs::set_permissions(&filepath, new_perms).await?;
176        }
177    }
178
179    // Write the patched content
180    tokio::fs::write(&filepath, patched_content).await?;
181
182    // Verify the hash after writing
183    let verify_hash = compute_file_git_sha256(&filepath).await?;
184    if verify_hash != expected_hash {
185        return Err(std::io::Error::new(
186            std::io::ErrorKind::InvalidData,
187            format!(
188                "Hash verification failed after patch. Expected: {}, Got: {}",
189                expected_hash, verify_hash
190            ),
191        ));
192    }
193
194    Ok(())
195}
196
197/// Verify and apply patches for a single package.
198///
199/// For each file in `files`, this function:
200/// 1. Verifies the file is ready to be patched (or already patched).
201/// 2. If not dry_run, reads the blob from `blobs_path` and writes it.
202/// 3. Returns a summary of what happened.
203pub async fn apply_package_patch(
204    package_key: &str,
205    pkg_path: &Path,
206    files: &HashMap<String, PatchFileInfo>,
207    blobs_path: &Path,
208    dry_run: bool,
209    force: bool,
210) -> ApplyResult {
211    let mut result = ApplyResult {
212        package_key: package_key.to_string(),
213        package_path: pkg_path.display().to_string(),
214        success: false,
215        files_verified: Vec::new(),
216        files_patched: Vec::new(),
217        error: None,
218    };
219
220    // First, verify all files
221    for (file_name, file_info) in files {
222        let mut verify_result = verify_file_patch(pkg_path, file_name, file_info).await;
223
224        if verify_result.status != VerifyStatus::Ready
225            && verify_result.status != VerifyStatus::AlreadyPatched
226        {
227            if force {
228                match verify_result.status {
229                    VerifyStatus::HashMismatch => {
230                        // Force: treat hash mismatch as ready
231                        verify_result.status = VerifyStatus::Ready;
232                    }
233                    VerifyStatus::NotFound => {
234                        // Force: skip files that don't exist (non-new files)
235                        result.files_verified.push(verify_result);
236                        continue;
237                    }
238                    _ => {}
239                }
240            } else {
241                let msg = verify_result
242                    .message
243                    .clone()
244                    .unwrap_or_else(|| format!("{:?}", verify_result.status));
245                result.error = Some(format!(
246                    "Cannot apply patch: {} - {}",
247                    verify_result.file, msg
248                ));
249                result.files_verified.push(verify_result);
250                return result;
251            }
252        }
253
254        result.files_verified.push(verify_result);
255    }
256
257    // Check if all files are already patched
258    let all_already_patched = result
259        .files_verified
260        .iter()
261        .all(|v| v.status == VerifyStatus::AlreadyPatched);
262
263    if all_already_patched {
264        result.success = true;
265        return result;
266    }
267
268    // Check if all files are either already patched or not found (force mode skip)
269    let all_done_or_skipped = result
270        .files_verified
271        .iter()
272        .all(|v| v.status == VerifyStatus::AlreadyPatched || v.status == VerifyStatus::NotFound);
273
274    if all_done_or_skipped {
275        // Some or all files were not found but skipped via --force
276        let not_found_count = result.files_verified.iter()
277            .filter(|v| v.status == VerifyStatus::NotFound)
278            .count();
279        result.success = true;
280        result.error = Some(format!(
281            "All patch files were skipped: {} not found on disk (--force)",
282            not_found_count
283        ));
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    // Apply patches to 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 == VerifyStatus::AlreadyPatched
298                || vr.status == VerifyStatus::NotFound
299            {
300                continue;
301            }
302        }
303
304        // Read patched content from blobs
305        let blob_path = blobs_path.join(&file_info.after_hash);
306        let patched_content = match tokio::fs::read(&blob_path).await {
307            Ok(content) => content,
308            Err(e) => {
309                result.error = Some(format!(
310                    "Failed to read blob {}: {}",
311                    file_info.after_hash, e
312                ));
313                return result;
314            }
315        };
316
317        // Apply the patch
318        if let Err(e) = apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await {
319            result.error = Some(e.to_string());
320            return result;
321        }
322
323        result.files_patched.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    #[test]
336    fn test_normalize_file_path_with_prefix() {
337        assert_eq!(normalize_file_path("package/lib/server.js"), "lib/server.js");
338    }
339
340    #[test]
341    fn test_normalize_file_path_without_prefix() {
342        assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js");
343    }
344
345    #[test]
346    fn test_normalize_file_path_just_prefix() {
347        assert_eq!(normalize_file_path("package/"), "");
348    }
349
350    #[test]
351    fn test_normalize_file_path_package_not_prefix() {
352        // "package" without trailing "/" should NOT be stripped
353        assert_eq!(normalize_file_path("packagefoo/bar.js"), "packagefoo/bar.js");
354    }
355
356    #[tokio::test]
357    async fn test_verify_file_patch_not_found() {
358        let dir = tempfile::tempdir().unwrap();
359        let file_info = PatchFileInfo {
360            before_hash: "aaa".to_string(),
361            after_hash: "bbb".to_string(),
362        };
363
364        let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await;
365        assert_eq!(result.status, VerifyStatus::NotFound);
366    }
367
368    #[tokio::test]
369    async fn test_verify_file_patch_ready() {
370        let dir = tempfile::tempdir().unwrap();
371        let content = b"original content";
372        let before_hash = compute_git_sha256_from_bytes(content);
373        let after_hash = "bbbbbbbb".to_string();
374
375        tokio::fs::write(dir.path().join("index.js"), content)
376            .await
377            .unwrap();
378
379        let file_info = PatchFileInfo {
380            before_hash: before_hash.clone(),
381            after_hash,
382        };
383
384        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
385        assert_eq!(result.status, VerifyStatus::Ready);
386        assert_eq!(result.current_hash.unwrap(), before_hash);
387    }
388
389    #[tokio::test]
390    async fn test_verify_file_patch_already_patched() {
391        let dir = tempfile::tempdir().unwrap();
392        let content = b"patched content";
393        let after_hash = compute_git_sha256_from_bytes(content);
394
395        tokio::fs::write(dir.path().join("index.js"), content)
396            .await
397            .unwrap();
398
399        let file_info = PatchFileInfo {
400            before_hash: "aaaa".to_string(),
401            after_hash: after_hash.clone(),
402        };
403
404        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
405        assert_eq!(result.status, VerifyStatus::AlreadyPatched);
406    }
407
408    #[tokio::test]
409    async fn test_verify_file_patch_hash_mismatch() {
410        let dir = tempfile::tempdir().unwrap();
411        tokio::fs::write(dir.path().join("index.js"), b"something else")
412            .await
413            .unwrap();
414
415        let file_info = PatchFileInfo {
416            before_hash: "aaaa".to_string(),
417            after_hash: "bbbb".to_string(),
418        };
419
420        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
421        assert_eq!(result.status, VerifyStatus::HashMismatch);
422    }
423
424    #[tokio::test]
425    async fn test_verify_with_package_prefix() {
426        let dir = tempfile::tempdir().unwrap();
427        let content = b"original content";
428        let before_hash = compute_git_sha256_from_bytes(content);
429
430        // File is at lib/server.js but patch refers to package/lib/server.js
431        tokio::fs::create_dir_all(dir.path().join("lib")).await.unwrap();
432        tokio::fs::write(dir.path().join("lib/server.js"), content)
433            .await
434            .unwrap();
435
436        let file_info = PatchFileInfo {
437            before_hash: before_hash.clone(),
438            after_hash: "bbbb".to_string(),
439        };
440
441        let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await;
442        assert_eq!(result.status, VerifyStatus::Ready);
443    }
444
445    #[tokio::test]
446    async fn test_apply_file_patch_success() {
447        let dir = tempfile::tempdir().unwrap();
448        let original = b"original";
449        let patched = b"patched content";
450        let patched_hash = compute_git_sha256_from_bytes(patched);
451
452        tokio::fs::write(dir.path().join("index.js"), original)
453            .await
454            .unwrap();
455
456        apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
457            .await
458            .unwrap();
459
460        let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
461        assert_eq!(written, patched);
462    }
463
464    #[tokio::test]
465    async fn test_apply_file_patch_hash_mismatch() {
466        let dir = tempfile::tempdir().unwrap();
467        tokio::fs::write(dir.path().join("index.js"), b"original")
468            .await
469            .unwrap();
470
471        let result =
472            apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await;
473        assert!(result.is_err());
474        let err = result.unwrap_err();
475        assert!(err.to_string().contains("Hash verification failed"));
476    }
477
478    #[tokio::test]
479    async fn test_apply_package_patch_success() {
480        let pkg_dir = tempfile::tempdir().unwrap();
481        let blobs_dir = tempfile::tempdir().unwrap();
482
483        let original = b"original content";
484        let patched = b"patched content";
485        let before_hash = compute_git_sha256_from_bytes(original);
486        let after_hash = compute_git_sha256_from_bytes(patched);
487
488        // Write original file
489        tokio::fs::write(pkg_dir.path().join("index.js"), original)
490            .await
491            .unwrap();
492
493        // Write blob
494        tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
495            .await
496            .unwrap();
497
498        let mut files = HashMap::new();
499        files.insert(
500            "index.js".to_string(),
501            PatchFileInfo {
502                before_hash,
503                after_hash: after_hash.clone(),
504            },
505        );
506
507        let result =
508            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
509                .await;
510
511        assert!(result.success);
512        assert_eq!(result.files_patched.len(), 1);
513        assert!(result.error.is_none());
514    }
515
516    #[tokio::test]
517    async fn test_apply_package_patch_dry_run() {
518        let pkg_dir = tempfile::tempdir().unwrap();
519        let blobs_dir = tempfile::tempdir().unwrap();
520
521        let original = b"original content";
522        let before_hash = compute_git_sha256_from_bytes(original);
523
524        tokio::fs::write(pkg_dir.path().join("index.js"), original)
525            .await
526            .unwrap();
527
528        let mut files = HashMap::new();
529        files.insert(
530            "index.js".to_string(),
531            PatchFileInfo {
532                before_hash,
533                after_hash: "bbbb".to_string(),
534            },
535        );
536
537        let result =
538            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), true, false)
539                .await;
540
541        assert!(result.success);
542        assert_eq!(result.files_patched.len(), 0); // dry run: nothing actually patched
543
544        // File should still have original content
545        let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
546        assert_eq!(content, original);
547    }
548
549    #[tokio::test]
550    async fn test_apply_package_patch_all_already_patched() {
551        let pkg_dir = tempfile::tempdir().unwrap();
552        let blobs_dir = tempfile::tempdir().unwrap();
553
554        let patched = b"patched content";
555        let after_hash = compute_git_sha256_from_bytes(patched);
556
557        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
558            .await
559            .unwrap();
560
561        let mut files = HashMap::new();
562        files.insert(
563            "index.js".to_string(),
564            PatchFileInfo {
565                before_hash: "aaaa".to_string(),
566                after_hash,
567            },
568        );
569
570        let result =
571            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
572                .await;
573
574        assert!(result.success);
575        assert_eq!(result.files_patched.len(), 0);
576    }
577
578    #[tokio::test]
579    async fn test_apply_package_patch_hash_mismatch_blocks() {
580        let pkg_dir = tempfile::tempdir().unwrap();
581        let blobs_dir = tempfile::tempdir().unwrap();
582
583        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
584            .await
585            .unwrap();
586
587        let mut files = HashMap::new();
588        files.insert(
589            "index.js".to_string(),
590            PatchFileInfo {
591                before_hash: "aaaa".to_string(),
592                after_hash: "bbbb".to_string(),
593            },
594        );
595
596        let result =
597            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
598                .await;
599
600        assert!(!result.success);
601        assert!(result.error.is_some());
602    }
603
604    #[tokio::test]
605    async fn test_apply_package_patch_force_hash_mismatch() {
606        let pkg_dir = tempfile::tempdir().unwrap();
607        let blobs_dir = tempfile::tempdir().unwrap();
608
609        let patched = b"patched content";
610        let after_hash = compute_git_sha256_from_bytes(patched);
611
612        // Write a file whose hash does NOT match before_hash
613        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
614            .await
615            .unwrap();
616
617        // Write blob
618        tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
619            .await
620            .unwrap();
621
622        let mut files = HashMap::new();
623        files.insert(
624            "index.js".to_string(),
625            PatchFileInfo {
626                before_hash: "aaaa".to_string(),
627                after_hash: after_hash.clone(),
628            },
629        );
630
631        // Without force: should fail
632        let result =
633            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
634                .await;
635        assert!(!result.success);
636
637        // Reset the file
638        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
639            .await
640            .unwrap();
641
642        // With force: should succeed
643        let result =
644            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, true)
645                .await;
646        assert!(result.success);
647        assert_eq!(result.files_patched.len(), 1);
648
649        let written = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
650        assert_eq!(written, patched);
651    }
652
653    #[tokio::test]
654    async fn test_apply_package_patch_force_not_found_skips() {
655        let pkg_dir = tempfile::tempdir().unwrap();
656        let blobs_dir = tempfile::tempdir().unwrap();
657
658        let mut files = HashMap::new();
659        files.insert(
660            "missing.js".to_string(),
661            PatchFileInfo {
662                before_hash: "aaaa".to_string(),
663                after_hash: "bbbb".to_string(),
664            },
665        );
666
667        // Without force: should fail (NotFound for non-new file)
668        let result =
669            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
670                .await;
671        assert!(!result.success);
672
673        // With force: should succeed by skipping the missing file
674        let result =
675            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, true)
676                .await;
677        assert!(result.success);
678        assert_eq!(result.files_patched.len(), 0);
679    }
680}