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 (or skipped due to NotFound with force)
258    let all_patched = result
259        .files_verified
260        .iter()
261        .all(|v| v.status == VerifyStatus::AlreadyPatched || v.status == VerifyStatus::NotFound);
262    if all_patched {
263        result.success = true;
264        return result;
265    }
266
267    // If dry run, stop here
268    if dry_run {
269        result.success = true;
270        return result;
271    }
272
273    // Apply patches to files that need it
274    for (file_name, file_info) in files {
275        let verify_result = result.files_verified.iter().find(|v| v.file == *file_name);
276        if let Some(vr) = verify_result {
277            if vr.status == VerifyStatus::AlreadyPatched
278                || vr.status == VerifyStatus::NotFound
279            {
280                continue;
281            }
282        }
283
284        // Read patched content from blobs
285        let blob_path = blobs_path.join(&file_info.after_hash);
286        let patched_content = match tokio::fs::read(&blob_path).await {
287            Ok(content) => content,
288            Err(e) => {
289                result.error = Some(format!(
290                    "Failed to read blob {}: {}",
291                    file_info.after_hash, e
292                ));
293                return result;
294            }
295        };
296
297        // Apply the patch
298        if let Err(e) = apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await {
299            result.error = Some(e.to_string());
300            return result;
301        }
302
303        result.files_patched.push(file_name.clone());
304    }
305
306    result.success = true;
307    result
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::hash::git_sha256::compute_git_sha256_from_bytes;
314
315    #[test]
316    fn test_normalize_file_path_with_prefix() {
317        assert_eq!(normalize_file_path("package/lib/server.js"), "lib/server.js");
318    }
319
320    #[test]
321    fn test_normalize_file_path_without_prefix() {
322        assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js");
323    }
324
325    #[test]
326    fn test_normalize_file_path_just_prefix() {
327        assert_eq!(normalize_file_path("package/"), "");
328    }
329
330    #[test]
331    fn test_normalize_file_path_package_not_prefix() {
332        // "package" without trailing "/" should NOT be stripped
333        assert_eq!(normalize_file_path("packagefoo/bar.js"), "packagefoo/bar.js");
334    }
335
336    #[tokio::test]
337    async fn test_verify_file_patch_not_found() {
338        let dir = tempfile::tempdir().unwrap();
339        let file_info = PatchFileInfo {
340            before_hash: "aaa".to_string(),
341            after_hash: "bbb".to_string(),
342        };
343
344        let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await;
345        assert_eq!(result.status, VerifyStatus::NotFound);
346    }
347
348    #[tokio::test]
349    async fn test_verify_file_patch_ready() {
350        let dir = tempfile::tempdir().unwrap();
351        let content = b"original content";
352        let before_hash = compute_git_sha256_from_bytes(content);
353        let after_hash = "bbbbbbbb".to_string();
354
355        tokio::fs::write(dir.path().join("index.js"), content)
356            .await
357            .unwrap();
358
359        let file_info = PatchFileInfo {
360            before_hash: before_hash.clone(),
361            after_hash,
362        };
363
364        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
365        assert_eq!(result.status, VerifyStatus::Ready);
366        assert_eq!(result.current_hash.unwrap(), before_hash);
367    }
368
369    #[tokio::test]
370    async fn test_verify_file_patch_already_patched() {
371        let dir = tempfile::tempdir().unwrap();
372        let content = b"patched content";
373        let after_hash = compute_git_sha256_from_bytes(content);
374
375        tokio::fs::write(dir.path().join("index.js"), content)
376            .await
377            .unwrap();
378
379        let file_info = PatchFileInfo {
380            before_hash: "aaaa".to_string(),
381            after_hash: after_hash.clone(),
382        };
383
384        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
385        assert_eq!(result.status, VerifyStatus::AlreadyPatched);
386    }
387
388    #[tokio::test]
389    async fn test_verify_file_patch_hash_mismatch() {
390        let dir = tempfile::tempdir().unwrap();
391        tokio::fs::write(dir.path().join("index.js"), b"something else")
392            .await
393            .unwrap();
394
395        let file_info = PatchFileInfo {
396            before_hash: "aaaa".to_string(),
397            after_hash: "bbbb".to_string(),
398        };
399
400        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
401        assert_eq!(result.status, VerifyStatus::HashMismatch);
402    }
403
404    #[tokio::test]
405    async fn test_verify_with_package_prefix() {
406        let dir = tempfile::tempdir().unwrap();
407        let content = b"original content";
408        let before_hash = compute_git_sha256_from_bytes(content);
409
410        // File is at lib/server.js but patch refers to package/lib/server.js
411        tokio::fs::create_dir_all(dir.path().join("lib")).await.unwrap();
412        tokio::fs::write(dir.path().join("lib/server.js"), content)
413            .await
414            .unwrap();
415
416        let file_info = PatchFileInfo {
417            before_hash: before_hash.clone(),
418            after_hash: "bbbb".to_string(),
419        };
420
421        let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await;
422        assert_eq!(result.status, VerifyStatus::Ready);
423    }
424
425    #[tokio::test]
426    async fn test_apply_file_patch_success() {
427        let dir = tempfile::tempdir().unwrap();
428        let original = b"original";
429        let patched = b"patched content";
430        let patched_hash = compute_git_sha256_from_bytes(patched);
431
432        tokio::fs::write(dir.path().join("index.js"), original)
433            .await
434            .unwrap();
435
436        apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
437            .await
438            .unwrap();
439
440        let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
441        assert_eq!(written, patched);
442    }
443
444    #[tokio::test]
445    async fn test_apply_file_patch_hash_mismatch() {
446        let dir = tempfile::tempdir().unwrap();
447        tokio::fs::write(dir.path().join("index.js"), b"original")
448            .await
449            .unwrap();
450
451        let result =
452            apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await;
453        assert!(result.is_err());
454        let err = result.unwrap_err();
455        assert!(err.to_string().contains("Hash verification failed"));
456    }
457
458    #[tokio::test]
459    async fn test_apply_package_patch_success() {
460        let pkg_dir = tempfile::tempdir().unwrap();
461        let blobs_dir = tempfile::tempdir().unwrap();
462
463        let original = b"original content";
464        let patched = b"patched content";
465        let before_hash = compute_git_sha256_from_bytes(original);
466        let after_hash = compute_git_sha256_from_bytes(patched);
467
468        // Write original file
469        tokio::fs::write(pkg_dir.path().join("index.js"), original)
470            .await
471            .unwrap();
472
473        // Write blob
474        tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
475            .await
476            .unwrap();
477
478        let mut files = HashMap::new();
479        files.insert(
480            "index.js".to_string(),
481            PatchFileInfo {
482                before_hash,
483                after_hash: after_hash.clone(),
484            },
485        );
486
487        let result =
488            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
489                .await;
490
491        assert!(result.success);
492        assert_eq!(result.files_patched.len(), 1);
493        assert!(result.error.is_none());
494    }
495
496    #[tokio::test]
497    async fn test_apply_package_patch_dry_run() {
498        let pkg_dir = tempfile::tempdir().unwrap();
499        let blobs_dir = tempfile::tempdir().unwrap();
500
501        let original = b"original content";
502        let before_hash = compute_git_sha256_from_bytes(original);
503
504        tokio::fs::write(pkg_dir.path().join("index.js"), original)
505            .await
506            .unwrap();
507
508        let mut files = HashMap::new();
509        files.insert(
510            "index.js".to_string(),
511            PatchFileInfo {
512                before_hash,
513                after_hash: "bbbb".to_string(),
514            },
515        );
516
517        let result =
518            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), true, false)
519                .await;
520
521        assert!(result.success);
522        assert_eq!(result.files_patched.len(), 0); // dry run: nothing actually patched
523
524        // File should still have original content
525        let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
526        assert_eq!(content, original);
527    }
528
529    #[tokio::test]
530    async fn test_apply_package_patch_all_already_patched() {
531        let pkg_dir = tempfile::tempdir().unwrap();
532        let blobs_dir = tempfile::tempdir().unwrap();
533
534        let patched = b"patched content";
535        let after_hash = compute_git_sha256_from_bytes(patched);
536
537        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
538            .await
539            .unwrap();
540
541        let mut files = HashMap::new();
542        files.insert(
543            "index.js".to_string(),
544            PatchFileInfo {
545                before_hash: "aaaa".to_string(),
546                after_hash,
547            },
548        );
549
550        let result =
551            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
552                .await;
553
554        assert!(result.success);
555        assert_eq!(result.files_patched.len(), 0);
556    }
557
558    #[tokio::test]
559    async fn test_apply_package_patch_hash_mismatch_blocks() {
560        let pkg_dir = tempfile::tempdir().unwrap();
561        let blobs_dir = tempfile::tempdir().unwrap();
562
563        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
564            .await
565            .unwrap();
566
567        let mut files = HashMap::new();
568        files.insert(
569            "index.js".to_string(),
570            PatchFileInfo {
571                before_hash: "aaaa".to_string(),
572                after_hash: "bbbb".to_string(),
573            },
574        );
575
576        let result =
577            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
578                .await;
579
580        assert!(!result.success);
581        assert!(result.error.is_some());
582    }
583
584    #[tokio::test]
585    async fn test_apply_package_patch_force_hash_mismatch() {
586        let pkg_dir = tempfile::tempdir().unwrap();
587        let blobs_dir = tempfile::tempdir().unwrap();
588
589        let patched = b"patched content";
590        let after_hash = compute_git_sha256_from_bytes(patched);
591
592        // Write a file whose hash does NOT match before_hash
593        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
594            .await
595            .unwrap();
596
597        // Write blob
598        tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
599            .await
600            .unwrap();
601
602        let mut files = HashMap::new();
603        files.insert(
604            "index.js".to_string(),
605            PatchFileInfo {
606                before_hash: "aaaa".to_string(),
607                after_hash: after_hash.clone(),
608            },
609        );
610
611        // Without force: should fail
612        let result =
613            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
614                .await;
615        assert!(!result.success);
616
617        // Reset the file
618        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
619            .await
620            .unwrap();
621
622        // With force: should succeed
623        let result =
624            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, true)
625                .await;
626        assert!(result.success);
627        assert_eq!(result.files_patched.len(), 1);
628
629        let written = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
630        assert_eq!(written, patched);
631    }
632
633    #[tokio::test]
634    async fn test_apply_package_patch_force_not_found_skips() {
635        let pkg_dir = tempfile::tempdir().unwrap();
636        let blobs_dir = tempfile::tempdir().unwrap();
637
638        let mut files = HashMap::new();
639        files.insert(
640            "missing.js".to_string(),
641            PatchFileInfo {
642                before_hash: "aaaa".to_string(),
643                after_hash: "bbbb".to_string(),
644            },
645        );
646
647        // Without force: should fail (NotFound for non-new file)
648        let result =
649            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, false)
650                .await;
651        assert!(!result.success);
652
653        // With force: should succeed by skipping the missing file
654        let result =
655            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, blobs_dir.path(), false, true)
656                .await;
657        assert!(result.success);
658        assert_eq!(result.files_patched.len(), 0);
659    }
660}