Skip to main content

socket_patch_core/patch/
apply.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::hash::git_sha256::compute_git_sha256_from_bytes;
5use crate::manifest::schema::PatchFileInfo;
6use crate::patch::cow::break_hardlink_if_needed;
7use crate::patch::diff::apply_diff;
8use crate::patch::file_hash::compute_file_git_sha256;
9use crate::patch::package::read_archive_filtered;
10
11/// Status of a file patch verification.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum VerifyStatus {
14    /// File is ready to be patched (current hash matches beforeHash).
15    Ready,
16    /// File is already in the patched state (current hash matches afterHash).
17    AlreadyPatched,
18    /// File hash does not match either beforeHash or afterHash.
19    HashMismatch,
20    /// File was not found on disk.
21    NotFound,
22}
23
24/// Result of verifying whether a single file can be patched.
25#[derive(Debug, Clone)]
26pub struct VerifyResult {
27    pub file: String,
28    pub status: VerifyStatus,
29    pub message: Option<String>,
30    pub current_hash: Option<String>,
31    pub expected_hash: Option<String>,
32    pub target_hash: Option<String>,
33}
34
35/// Which patch source actually wrote the patched bytes for a file.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum AppliedVia {
38    /// Bytes came from a per-package archive in `.socket/packages/`.
39    Package,
40    /// Bytes were produced by applying a bsdiff delta from
41    /// `.socket/diffs/<uuid>.tar.gz`.
42    Diff,
43    /// Bytes came from a per-file blob in `.socket/blobs/`.
44    Blob,
45}
46
47impl AppliedVia {
48    /// Short lowercase tag, suitable for JSON and human output.
49    pub fn as_tag(&self) -> &'static str {
50        match self {
51            AppliedVia::Package => "package",
52            AppliedVia::Diff => "diff",
53            AppliedVia::Blob => "blob",
54        }
55    }
56}
57
58/// Patch sources the apply pipeline may use to obtain patched bytes.
59///
60/// `blobs_path` is always required and serves as the universal fallback.
61/// `packages_path` and `diffs_path` are optional opt-ins to the new
62/// pathways introduced in socket-patch 2.2.
63#[derive(Debug, Clone, Copy)]
64pub struct PatchSources<'a> {
65    pub blobs_path: &'a Path,
66    pub packages_path: Option<&'a Path>,
67    pub diffs_path: Option<&'a Path>,
68}
69
70impl<'a> PatchSources<'a> {
71    /// Construct a `PatchSources` that only knows about the legacy
72    /// per-file blob directory. Convenient for tests and existing call
73    /// sites that have not been upgraded.
74    pub fn blobs_only(blobs_path: &'a Path) -> Self {
75        Self {
76            blobs_path,
77            packages_path: None,
78            diffs_path: None,
79        }
80    }
81}
82
83/// Result of applying patches to a single package.
84#[derive(Debug, Clone)]
85pub struct ApplyResult {
86    pub package_key: String,
87    pub package_path: String,
88    pub success: bool,
89    pub files_verified: Vec<VerifyResult>,
90    pub files_patched: Vec<String>,
91    /// Per-file record of which source produced the patched bytes. Only
92    /// populated for files in `files_patched`.
93    pub applied_via: HashMap<String, AppliedVia>,
94    pub error: Option<String>,
95    /// Ecosystem sidecar fixup outcome — a typed
96    /// [`SidecarRecord`](crate::patch::sidecars::SidecarRecord) carrying
97    /// per-file actions (rewritten / deleted / created) and an
98    /// optional structured advisory. `None` when no sidecar
99    /// applied (e.g. npm) or when no files were patched.
100    ///
101    /// Surfaced in the CLI JSON envelope under
102    /// `Envelope.sidecars[]` (top-level, not per-event).
103    pub sidecar: Option<crate::patch::sidecars::SidecarRecord>,
104}
105
106/// Normalize file path by removing the "package/" prefix if present.
107/// Patch files come from the API with paths like "package/lib/file.js"
108/// but we need relative paths like "lib/file.js" for the actual package directory.
109pub fn normalize_file_path(file_name: &str) -> &str {
110    const PACKAGE_PREFIX: &str = "package/";
111    if let Some(stripped) = file_name.strip_prefix(PACKAGE_PREFIX) {
112        stripped
113    } else {
114        file_name
115    }
116}
117
118/// Verify a single file can be patched.
119pub async fn verify_file_patch(
120    pkg_path: &Path,
121    file_name: &str,
122    file_info: &PatchFileInfo,
123) -> VerifyResult {
124    let normalized = normalize_file_path(file_name);
125    let filepath = pkg_path.join(normalized);
126
127    let is_new_file = file_info.before_hash.is_empty();
128
129    // Check if file exists
130    if tokio::fs::metadata(&filepath).await.is_err() {
131        // New files (empty beforeHash) are expected to not exist yet.
132        if is_new_file {
133            return VerifyResult {
134                file: file_name.to_string(),
135                status: VerifyStatus::Ready,
136                message: None,
137                current_hash: None,
138                expected_hash: None,
139                target_hash: Some(file_info.after_hash.clone()),
140            };
141        }
142        return VerifyResult {
143            file: file_name.to_string(),
144            status: VerifyStatus::NotFound,
145            message: Some("File not found".to_string()),
146            current_hash: None,
147            expected_hash: None,
148            target_hash: None,
149        };
150    }
151
152    // Compute current hash
153    let current_hash = match compute_file_git_sha256(&filepath).await {
154        Ok(h) => h,
155        Err(e) => {
156            return VerifyResult {
157                file: file_name.to_string(),
158                status: VerifyStatus::NotFound,
159                message: Some(format!("Failed to hash file: {}", e)),
160                current_hash: None,
161                expected_hash: None,
162                target_hash: None,
163            };
164        }
165    };
166
167    // Check if already patched
168    if current_hash == file_info.after_hash {
169        return VerifyResult {
170            file: file_name.to_string(),
171            status: VerifyStatus::AlreadyPatched,
172            message: None,
173            current_hash: Some(current_hash),
174            expected_hash: None,
175            target_hash: None,
176        };
177    }
178
179    // New files (empty beforeHash) with existing content that doesn't match
180    // afterHash: treat as Ready (force overwrite).
181    if is_new_file {
182        return VerifyResult {
183            file: file_name.to_string(),
184            status: VerifyStatus::Ready,
185            message: None,
186            current_hash: Some(current_hash),
187            expected_hash: None,
188            target_hash: Some(file_info.after_hash.clone()),
189        };
190    }
191
192    // Check if matches expected before hash
193    if current_hash != file_info.before_hash {
194        return VerifyResult {
195            file: file_name.to_string(),
196            status: VerifyStatus::HashMismatch,
197            message: Some("File hash does not match expected value".to_string()),
198            current_hash: Some(current_hash),
199            expected_hash: Some(file_info.before_hash.clone()),
200            target_hash: Some(file_info.after_hash.clone()),
201        };
202    }
203
204    VerifyResult {
205        file: file_name.to_string(),
206        status: VerifyStatus::Ready,
207        message: None,
208        current_hash: Some(current_hash),
209        expected_hash: None,
210        target_hash: Some(file_info.after_hash.clone()),
211    }
212}
213
214/// Apply a patch to a single file.
215///
216/// **Permission policy** (per the user-visible contract — patched
217/// files must look identical to pre-patch perms-wise):
218///
219/// 1. **Existing file**. Snapshot mode + owner + group before writing.
220///    If the file is read-only, temporarily grant owner-write so the
221///    overwrite succeeds (e.g. Go's module cache marks sources read-only).
222///    After the write, restore the **exact** original mode and chown
223///    back to the pre-patch uid/gid. Owners stay put even when
224///    `tokio::fs::write` truncates and rewrites.
225///
226/// 2. **New file** (created by the patch). Inherit owner + group from
227///    the parent directory and force mode `0o444` (read-only for all).
228///    Mirrors how an unpacked tarball treats new package files —
229///    consumers expect package sources to be read-only by default.
230///
231/// On Windows there is no `uid`/`gid`, so the owner/group step is a
232/// no-op; the read-only attribute is preserved on existing files and
233/// set on new files to honor the read-only-by-default policy.
234///
235/// Writes the patched content and verifies the resulting hash.
236pub async fn apply_file_patch(
237    pkg_path: &Path,
238    file_name: &str,
239    patched_content: &[u8],
240    expected_hash: &str,
241) -> Result<(), std::io::Error> {
242    let normalized = normalize_file_path(file_name);
243    let filepath = pkg_path.join(normalized);
244
245    // Hash-check the in-memory content BEFORE touching disk. Removes
246    // the prior "wrote bytes, then post-write verify failed, can't
247    // restore" failure mode — if the upstream blob is corrupt we
248    // error out before any disk write.
249    let content_hash = compute_git_sha256_from_bytes(patched_content);
250    if content_hash != expected_hash {
251        return Err(std::io::Error::new(
252            std::io::ErrorKind::InvalidData,
253            format!(
254                "Hash verification failed before patch. Expected: {}, Got: {}",
255                expected_hash, content_hash
256            ),
257        ));
258    }
259
260    // Snapshot pre-patch metadata so `restore_file_permissions` can
261    // re-apply the original mode + uid/gid to the post-rename inode.
262    // `None` means the file is being created by this patch — the
263    // new-file branch of restore_file_permissions inherits from the
264    // parent dir.
265    let existing_meta = tokio::fs::metadata(&filepath).await.ok();
266
267    // Create parent directories if needed (e.g., new files added by a patch).
268    if let Some(parent) = filepath.parent() {
269        tokio::fs::create_dir_all(parent).await?;
270    }
271
272    // Copy-on-write defense against pnpm / bazel / nix shared inodes.
273    // If `filepath` is a symlink into a content store, or a hardlink
274    // shared with other projects, give this project a private inode
275    // before we mutate. No-op on regular private files (single
276    // syscall). See `patch::cow`.
277    break_hardlink_if_needed(&filepath).await?;
278
279    // Atomic write: stage in the parent directory, fsync, rename onto
280    // the target. POSIX `rename(2)` is atomic — observers see either
281    // the old bytes or the new bytes, never a truncated half-write.
282    //
283    // The stage file is created with the user's umask defaults
284    // (typically 0o644) — that's how we sidestep the "existing file
285    // is 0o444" problem the old in-place write had: we rename a fresh
286    // user-writable inode over the target instead of trying to open
287    // a read-only file for write. `restore_file_permissions` then
288    // re-applies the pre-patch mode + uid/gid to the new inode.
289    write_atomic(&filepath, patched_content).await?;
290
291    // Restore (or set) the final permissions on the post-rename inode.
292    // On Unix this includes chown back to the pre-patch uid/gid (or
293    // to the parent dir's uid/gid for new files); on Windows we only
294    // manage the readonly attribute.
295    restore_file_permissions(&filepath, existing_meta.as_ref()).await?;
296
297    Ok(())
298}
299
300/// Write `content` to `target` atomically via stage + rename.
301///
302/// Two-phase commit:
303///   1. Create `<parent>/.socket-stage-<filename>-<uuid>` (leading dot
304///      so editor globs ignore it; uuid suffix so concurrent callers
305///      never collide — defense in depth on top of the apply lock).
306///   2. `write_all` the content, then `sync_all()` so the bytes are
307///      durably on disk before the rename.
308///   3. `rename(stage, target)` — atomic on POSIX, best-effort on
309///      Windows. On failure unlink the stage so we don't leave a
310///      dotfile behind in the package directory.
311async fn write_atomic(target: &Path, content: &[u8]) -> std::io::Result<()> {
312    let parent = target.parent().unwrap_or_else(|| Path::new("."));
313    let stem = target
314        .file_name()
315        .map(|n| n.to_string_lossy().into_owned())
316        .unwrap_or_else(|| "anon".to_string());
317    let stage = parent.join(format!(
318        ".socket-stage-{}-{}",
319        stem,
320        uuid::Uuid::new_v4()
321    ));
322
323    let mut file = tokio::fs::OpenOptions::new()
324        .write(true)
325        .create_new(true)
326        .open(&stage)
327        .await?;
328
329    use tokio::io::AsyncWriteExt;
330    if let Err(e) = file.write_all(content).await {
331        let _ = tokio::fs::remove_file(&stage).await;
332        return Err(e);
333    }
334    if let Err(e) = file.sync_all().await {
335        let _ = tokio::fs::remove_file(&stage).await;
336        return Err(e);
337    }
338    drop(file);
339
340    if let Err(e) = tokio::fs::rename(&stage, target).await {
341        let _ = tokio::fs::remove_file(&stage).await;
342        return Err(e);
343    }
344    Ok(())
345}
346
347/// Restore the post-write permission state on `filepath`.
348///
349/// * `pre_patch` = `Some(meta)` → the file existed before the patch;
350///   restore its exact mode + uid/gid.
351/// * `pre_patch` = `None` → the file is new; inherit owner/group from
352///   the parent dir and set mode `0o444`.
353///
354/// Split out of `apply_file_patch` to keep that function readable and
355/// to make the platform branching unit-testable.
356async fn restore_file_permissions(
357    filepath: &Path,
358    pre_patch: Option<&std::fs::Metadata>,
359) -> Result<(), std::io::Error> {
360    #[cfg(unix)]
361    {
362        use std::os::unix::fs::{MetadataExt, PermissionsExt};
363
364        match pre_patch {
365            Some(meta) => {
366                // Existing file: re-apply the original mode + ownership.
367                let restored = std::fs::Permissions::from_mode(meta.mode());
368                tokio::fs::set_permissions(filepath, restored).await?;
369                let uid = meta.uid();
370                let gid = meta.gid();
371                chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid)).await?;
372            }
373            None => {
374                // New file. Inherit owner/group from the parent dir.
375                if let Some(parent) = filepath.parent() {
376                    if let Ok(parent_meta) = tokio::fs::metadata(parent).await {
377                        let uid = parent_meta.uid();
378                        let gid = parent_meta.gid();
379                        chown_blocking(filepath.to_path_buf(), Some(uid), Some(gid))
380                            .await?;
381                    }
382                }
383                // Default new-file mode: read-only for all.
384                let readonly = std::fs::Permissions::from_mode(0o444);
385                tokio::fs::set_permissions(filepath, readonly).await?;
386            }
387        }
388    }
389
390    #[cfg(windows)]
391    {
392        match pre_patch {
393            Some(meta) => {
394                // Re-apply the pre-patch readonly state; tokio::fs::write
395                // does not preserve it across the truncate+rewrite.
396                let perms = meta.permissions();
397                tokio::fs::set_permissions(filepath, perms).await?;
398            }
399            None => {
400                // New file: read-only by default.
401                if let Ok(meta) = tokio::fs::metadata(filepath).await {
402                    let mut perms = meta.permissions();
403                    perms.set_readonly(true);
404                    tokio::fs::set_permissions(filepath, perms).await?;
405                }
406            }
407        }
408    }
409
410    let _ = filepath;
411    let _ = pre_patch;
412    Ok(())
413}
414
415/// Synchronous `chown` wrapped to run on the blocking pool so we don't
416/// stall the async runtime. `std::os::unix::fs::chown` is a thin
417/// syscall wrapper — fast in the no-op case (uid/gid already match)
418/// but still nominally blocking.
419#[cfg(unix)]
420async fn chown_blocking(
421    path: std::path::PathBuf,
422    uid: Option<u32>,
423    gid: Option<u32>,
424) -> Result<(), std::io::Error> {
425    tokio::task::spawn_blocking(move || std::os::unix::fs::chown(&path, uid, gid))
426        .await
427        .map_err(|e| std::io::Error::other(e.to_string()))?
428}
429
430/// Verify and apply patches for a single package.
431///
432/// For each file in `files`, this function:
433/// 1. Verifies the file is ready to be patched (or already patched).
434/// 2. If not dry_run, tries patch sources in order: package archive → diff
435///    archive → per-file blob. Each strategy is opt-in via `sources`.
436/// 3. Returns a summary of what happened.
437///
438/// `uuid` is the patch UUID. Pass `Some` to enable package- and
439/// diff-archive lookup (the corresponding `sources.packages_path` /
440/// `sources.diffs_path` must also be set). Pass `None` to restrict the
441/// pipeline to per-file blobs only — equivalent to pre-2.2 behavior.
442pub async fn apply_package_patch(
443    package_key: &str,
444    pkg_path: &Path,
445    files: &HashMap<String, PatchFileInfo>,
446    sources: &PatchSources<'_>,
447    uuid: Option<&str>,
448    dry_run: bool,
449    force: bool,
450) -> ApplyResult {
451    let mut result = ApplyResult {
452        package_key: package_key.to_string(),
453        package_path: pkg_path.display().to_string(),
454        success: false,
455        files_verified: Vec::new(),
456        files_patched: Vec::new(),
457        applied_via: HashMap::new(),
458        error: None,
459        sidecar: None,
460    };
461
462    // First, verify all files
463    for (file_name, file_info) in files {
464        let mut verify_result = verify_file_patch(pkg_path, file_name, file_info).await;
465
466        if verify_result.status != VerifyStatus::Ready
467            && verify_result.status != VerifyStatus::AlreadyPatched
468        {
469            if force {
470                match verify_result.status {
471                    VerifyStatus::HashMismatch => {
472                        // Force: treat hash mismatch as ready
473                        verify_result.status = VerifyStatus::Ready;
474                    }
475                    VerifyStatus::NotFound => {
476                        // Force: skip files that don't exist (non-new files)
477                        result.files_verified.push(verify_result);
478                        continue;
479                    }
480                    _ => {}
481                }
482            } else {
483                let msg = verify_result
484                    .message
485                    .clone()
486                    .unwrap_or_else(|| format!("{:?}", verify_result.status));
487                result.error = Some(format!(
488                    "Cannot apply patch: {} - {}",
489                    verify_result.file, msg
490                ));
491                result.files_verified.push(verify_result);
492                return result;
493            }
494        }
495
496        result.files_verified.push(verify_result);
497    }
498
499    // Check if all files are already patched
500    let all_already_patched = result
501        .files_verified
502        .iter()
503        .all(|v| v.status == VerifyStatus::AlreadyPatched);
504
505    if all_already_patched {
506        result.success = true;
507        return result;
508    }
509
510    // Check if all files are either already patched or not found (force mode skip)
511    let all_done_or_skipped = result
512        .files_verified
513        .iter()
514        .all(|v| v.status == VerifyStatus::AlreadyPatched || v.status == VerifyStatus::NotFound);
515
516    if all_done_or_skipped {
517        // Some or all files were not found but skipped via --force
518        let not_found_count = result.files_verified.iter()
519            .filter(|v| v.status == VerifyStatus::NotFound)
520            .count();
521        result.success = true;
522        result.error = Some(format!(
523            "All patch files were skipped: {} not found on disk (--force)",
524            not_found_count
525        ));
526        return result;
527    }
528
529    // If dry run, stop here
530    if dry_run {
531        result.success = true;
532        return result;
533    }
534
535    // Eagerly load the package and diff archives (if any) into memory so
536    // we don't reparse the tar.gz once per file. Both are small archives.
537    let package_entries = match (uuid, sources.packages_path) {
538        (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await,
539        _ => None,
540    };
541    let diff_entries = match (uuid, sources.diffs_path) {
542        (Some(uuid), Some(dir)) => load_archive_if_present(dir, uuid, files).await,
543        _ => None,
544    };
545
546    // Apply patches to files that need it. For each file, try package
547    // archive first, then diff, then blob.
548    for (file_name, file_info) in files {
549        let verify_result = result.files_verified.iter().find(|v| v.file == *file_name);
550        if let Some(vr) = verify_result {
551            if vr.status == VerifyStatus::AlreadyPatched
552                || vr.status == VerifyStatus::NotFound
553            {
554                continue;
555            }
556        }
557
558        let normalized = normalize_file_path(file_name).to_string();
559
560        // ── Strategy 1: package archive ──────────────────────────────
561        if try_apply_from_archive(
562            package_entries.as_ref(),
563            &normalized,
564            pkg_path,
565            file_name,
566            file_info,
567        )
568        .await
569        {
570            result.files_patched.push(file_name.clone());
571            result
572                .applied_via
573                .insert(file_name.clone(), AppliedVia::Package);
574            continue;
575        }
576
577        // ── Strategy 2: per-file diff ────────────────────────────────
578        // Diffs only apply cleanly when the on-disk content actually
579        // hashes to `before_hash` — otherwise the bsdiff output won't
580        // match `after_hash`. We pass the pre-apply current_hash
581        // captured by `verify_file_patch` so `try_apply_from_diff` can
582        // skip the wasted decompress+apply work when --force is
583        // overriding a hash mismatch (force flips status to Ready but
584        // the underlying hash is still wrong).
585        let current_hash_for_diff = verify_result.and_then(|v| v.current_hash.as_deref());
586        if try_apply_from_diff(
587            diff_entries.as_ref(),
588            &normalized,
589            pkg_path,
590            file_name,
591            file_info,
592            current_hash_for_diff,
593        )
594        .await
595        {
596            result.files_patched.push(file_name.clone());
597            result
598                .applied_via
599                .insert(file_name.clone(), AppliedVia::Diff);
600            continue;
601        }
602
603        // ── Strategy 3: per-file blob (legacy fallback) ──────────────
604        let blob_path = sources.blobs_path.join(&file_info.after_hash);
605        let patched_content = match tokio::fs::read(&blob_path).await {
606            Ok(content) => content,
607            Err(e) => {
608                result.error = Some(format!(
609                    "Failed to read blob {}: {}",
610                    file_info.after_hash, e
611                ));
612                return result;
613            }
614        };
615
616        if let Err(e) =
617            apply_file_patch(pkg_path, file_name, &patched_content, &file_info.after_hash).await
618        {
619            result.error = Some(e.to_string());
620            return result;
621        }
622
623        result.files_patched.push(file_name.clone());
624        result
625            .applied_via
626            .insert(file_name.clone(), AppliedVia::Blob);
627    }
628
629    // Ecosystem sidecar fixup. Best-effort: a failing sidecar does
630    // NOT undo the patch (the bytes were committed atomically via
631    // stage+rename; nothing to roll back). The error path is
632    // converted at this boundary into a `SidecarRecord` carrying
633    // `SidecarAdvisoryCode::SidecarFixupFailed` so downstream
634    // consumers see a uniform shape regardless of whether the
635    // fixup succeeded, was advisory-only, or raised an error.
636    if !result.files_patched.is_empty() {
637        use crate::patch::sidecars::{
638            dispatch_fixup, SidecarAdvisory, SidecarAdvisoryCode, SidecarRecord, SidecarSeverity,
639        };
640        match dispatch_fixup(package_key, pkg_path, &result.files_patched, files).await {
641            Ok(Some(record)) => result.sidecar = Some(record),
642            Ok(None) => {}
643            Err(e) => {
644                let ecosystem = crate::crawlers::Ecosystem::from_purl(package_key)
645                    .map(|eco| eco.cli_name().to_string())
646                    .unwrap_or_else(|| "unknown".to_string());
647                result.sidecar = Some(SidecarRecord {
648                    purl: package_key.to_string(),
649                    ecosystem,
650                    files: Vec::new(),
651                    advisory: Some(SidecarAdvisory {
652                        code: SidecarAdvisoryCode::SidecarFixupFailed,
653                        severity: SidecarSeverity::Error,
654                        message: format!("sidecar fixup failed (patch still applied): {}", e),
655                    }),
656                });
657            }
658        }
659    }
660
661    result.success = true;
662    result
663}
664
665/// Try to write the patched bytes from `package_entries[normalized_path]`
666/// to disk, verifying the post-write hash. Returns `true` on success.
667async fn try_apply_from_archive(
668    package_entries: Option<&HashMap<String, Vec<u8>>>,
669    normalized_path: &str,
670    pkg_path: &Path,
671    file_name: &str,
672    file_info: &PatchFileInfo,
673) -> bool {
674    let entries = match package_entries {
675        Some(e) => e,
676        None => return false,
677    };
678    let bytes = match entries.get(normalized_path) {
679        Some(b) => b,
680        None => return false,
681    };
682    if compute_git_sha256_from_bytes(bytes) != file_info.after_hash {
683        return false;
684    }
685    apply_file_patch(pkg_path, file_name, bytes, &file_info.after_hash)
686        .await
687        .is_ok()
688}
689
690/// Try to apply the bsdiff delta from `diff_entries[normalized_path]` to
691/// the on-disk file at `pkg_path/normalized_path`. Bails out (returning
692/// `false`) for any of:
693///   * no diff entry,
694///   * `current_hash` is missing or doesn't match `file_info.before_hash`
695///     (this is the strong gate — even `--force` promoting a
696///     HashMismatch to Ready will still bail here, because the on-disk
697///     hash captured by `verify_file_patch` was the real, mismatched
698///     value),
699///   * `file_info.before_hash` is empty (new files),
700///   * read/diff/verify/write failure.
701async fn try_apply_from_diff(
702    diff_entries: Option<&HashMap<String, Vec<u8>>>,
703    normalized_path: &str,
704    pkg_path: &Path,
705    file_name: &str,
706    file_info: &PatchFileInfo,
707    current_hash: Option<&str>,
708) -> bool {
709    let entries = match diff_entries {
710        Some(e) => e,
711        None => return false,
712    };
713    let delta = match entries.get(normalized_path) {
714        Some(d) => d,
715        None => return false,
716    };
717    if file_info.before_hash.is_empty() {
718        // New files have no before content to diff against.
719        return false;
720    }
721    // Strong invariant: only run the diff when on-disk bytes hash to
722    // exactly the `before_hash` the delta was authored against. This
723    // closes the force-mode loophole — `--force` flips VerifyStatus to
724    // Ready, but `current_hash` retains the original on-disk hash, so
725    // the comparison below still rejects.
726    match current_hash {
727        Some(h) if h == file_info.before_hash => {}
728        _ => return false,
729    }
730
731    let on_disk_path = pkg_path.join(normalized_path);
732    let before_bytes = match tokio::fs::read(&on_disk_path).await {
733        Ok(b) => b,
734        Err(_) => return false,
735    };
736    let patched = match apply_diff(&before_bytes, delta) {
737        Ok(p) => p,
738        Err(_) => return false,
739    };
740    if compute_git_sha256_from_bytes(&patched) != file_info.after_hash {
741        return false;
742    }
743    apply_file_patch(pkg_path, file_name, &patched, &file_info.after_hash)
744        .await
745        .is_ok()
746}
747
748/// Open `<dir>/<uuid>.tar.gz` (if it exists) and return its entries
749/// filtered to the patched files in `files`. Errors and missing files
750/// both yield `None` so the caller silently falls through to the next
751/// strategy.
752async fn load_archive_if_present(
753    dir: &Path,
754    uuid: &str,
755    files: &HashMap<String, PatchFileInfo>,
756) -> Option<HashMap<String, Vec<u8>>> {
757    let archive_path = dir.join(format!("{uuid}.tar.gz"));
758    if tokio::fs::metadata(&archive_path).await.is_err() {
759        return None;
760    }
761    // `read_archive_filtered` is synchronous (tar + flate2 are sync). Run
762    // it on the blocking pool so we don't stall the executor for large
763    // archives.
764    let archive_path_owned = archive_path.clone();
765    let files_owned = files.clone();
766    tokio::task::spawn_blocking(move || read_archive_filtered(&archive_path_owned, &files_owned))
767        .await
768        .ok()
769        .and_then(|r| r.ok())
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use crate::hash::git_sha256::compute_git_sha256_from_bytes;
776
777    #[test]
778    fn test_normalize_file_path_with_prefix() {
779        assert_eq!(normalize_file_path("package/lib/server.js"), "lib/server.js");
780    }
781
782    #[test]
783    fn test_normalize_file_path_without_prefix() {
784        assert_eq!(normalize_file_path("lib/server.js"), "lib/server.js");
785    }
786
787    #[test]
788    fn test_normalize_file_path_just_prefix() {
789        assert_eq!(normalize_file_path("package/"), "");
790    }
791
792    #[test]
793    fn test_normalize_file_path_package_not_prefix() {
794        // "package" without trailing "/" should NOT be stripped
795        assert_eq!(normalize_file_path("packagefoo/bar.js"), "packagefoo/bar.js");
796    }
797
798    #[tokio::test]
799    async fn test_verify_file_patch_not_found() {
800        let dir = tempfile::tempdir().unwrap();
801        let file_info = PatchFileInfo {
802            before_hash: "aaa".to_string(),
803            after_hash: "bbb".to_string(),
804        };
805
806        let result = verify_file_patch(dir.path(), "nonexistent.js", &file_info).await;
807        assert_eq!(result.status, VerifyStatus::NotFound);
808    }
809
810    #[tokio::test]
811    async fn test_verify_file_patch_ready() {
812        let dir = tempfile::tempdir().unwrap();
813        let content = b"original content";
814        let before_hash = compute_git_sha256_from_bytes(content);
815        let after_hash = "bbbbbbbb".to_string();
816
817        tokio::fs::write(dir.path().join("index.js"), content)
818            .await
819            .unwrap();
820
821        let file_info = PatchFileInfo {
822            before_hash: before_hash.clone(),
823            after_hash,
824        };
825
826        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
827        assert_eq!(result.status, VerifyStatus::Ready);
828        assert_eq!(result.current_hash.unwrap(), before_hash);
829    }
830
831    #[tokio::test]
832    async fn test_verify_file_patch_already_patched() {
833        let dir = tempfile::tempdir().unwrap();
834        let content = b"patched content";
835        let after_hash = compute_git_sha256_from_bytes(content);
836
837        tokio::fs::write(dir.path().join("index.js"), content)
838            .await
839            .unwrap();
840
841        let file_info = PatchFileInfo {
842            before_hash: "aaaa".to_string(),
843            after_hash: after_hash.clone(),
844        };
845
846        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
847        assert_eq!(result.status, VerifyStatus::AlreadyPatched);
848    }
849
850    #[tokio::test]
851    async fn test_verify_file_patch_hash_mismatch() {
852        let dir = tempfile::tempdir().unwrap();
853        tokio::fs::write(dir.path().join("index.js"), b"something else")
854            .await
855            .unwrap();
856
857        let file_info = PatchFileInfo {
858            before_hash: "aaaa".to_string(),
859            after_hash: "bbbb".to_string(),
860        };
861
862        let result = verify_file_patch(dir.path(), "index.js", &file_info).await;
863        assert_eq!(result.status, VerifyStatus::HashMismatch);
864    }
865
866    #[tokio::test]
867    async fn test_verify_with_package_prefix() {
868        let dir = tempfile::tempdir().unwrap();
869        let content = b"original content";
870        let before_hash = compute_git_sha256_from_bytes(content);
871
872        // File is at lib/server.js but patch refers to package/lib/server.js
873        tokio::fs::create_dir_all(dir.path().join("lib")).await.unwrap();
874        tokio::fs::write(dir.path().join("lib/server.js"), content)
875            .await
876            .unwrap();
877
878        let file_info = PatchFileInfo {
879            before_hash: before_hash.clone(),
880            after_hash: "bbbb".to_string(),
881        };
882
883        let result = verify_file_patch(dir.path(), "package/lib/server.js", &file_info).await;
884        assert_eq!(result.status, VerifyStatus::Ready);
885    }
886
887    #[tokio::test]
888    async fn test_apply_file_patch_success() {
889        let dir = tempfile::tempdir().unwrap();
890        let original = b"original";
891        let patched = b"patched content";
892        let patched_hash = compute_git_sha256_from_bytes(patched);
893
894        tokio::fs::write(dir.path().join("index.js"), original)
895            .await
896            .unwrap();
897
898        apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
899            .await
900            .unwrap();
901
902        let written = tokio::fs::read(dir.path().join("index.js")).await.unwrap();
903        assert_eq!(written, patched);
904    }
905
906    #[tokio::test]
907    async fn test_apply_file_patch_hash_mismatch() {
908        let dir = tempfile::tempdir().unwrap();
909        tokio::fs::write(dir.path().join("index.js"), b"original")
910            .await
911            .unwrap();
912
913        let result =
914            apply_file_patch(dir.path(), "index.js", b"patched content", "wrong_hash").await;
915        assert!(result.is_err());
916        let err = result.unwrap_err();
917        assert!(err.to_string().contains("Hash verification failed"));
918    }
919
920    /// Atomic-write contract: if the apply errors mid-flight (here:
921    /// in-memory hash mismatch, which fires BEFORE any disk write),
922    /// the target file is byte-identical to its pre-call state AND
923    /// no `.socket-stage-*` file is left in the parent directory.
924    #[tokio::test]
925    async fn test_apply_file_patch_hash_mismatch_leaves_original_intact() {
926        let dir = tempfile::tempdir().unwrap();
927        let path = dir.path().join("index.js");
928        tokio::fs::write(&path, b"original").await.unwrap();
929
930        let result = apply_file_patch(dir.path(), "index.js", b"patched", "deadbeef").await;
931        assert!(result.is_err());
932
933        // Original content untouched.
934        assert_eq!(tokio::fs::read(&path).await.unwrap(), b"original");
935
936        // No stage litter (stage files are named `.socket-stage-*`).
937        let mut entries = tokio::fs::read_dir(dir.path()).await.unwrap();
938        while let Some(entry) = entries.next_entry().await.unwrap() {
939            let name = entry.file_name().to_string_lossy().to_string();
940            assert!(
941                !name.starts_with(".socket-stage-"),
942                "stage file leaked into parent dir: {name}"
943            );
944        }
945    }
946
947    /// Apply against a hardlink (the pnpm content-store case) must
948    /// only mutate this project's view. The sibling link — which
949    /// represents another project's `node_modules/<pkg>` or the
950    /// global store entry — must keep the original bytes.
951    #[cfg(unix)]
952    #[tokio::test]
953    async fn test_apply_file_patch_does_not_propagate_to_hardlinked_sibling() {
954        let dir = tempfile::tempdir().unwrap();
955        let project = dir.path().join("project-b").join("foo.js");
956        let store = dir.path().join("store-a.js");
957        tokio::fs::create_dir_all(project.parent().unwrap())
958            .await
959            .unwrap();
960
961        // Pre-existing store entry; both project and store point at
962        // the same inode (this is what pnpm produces with
963        // `package-import-method=hardlink`).
964        tokio::fs::write(&store, b"original").await.unwrap();
965        tokio::fs::hard_link(&store, &project).await.unwrap();
966
967        let patched = b"patched";
968        let patched_hash = compute_git_sha256_from_bytes(patched);
969        apply_file_patch(project.parent().unwrap(), "foo.js", patched, &patched_hash)
970            .await
971            .unwrap();
972
973        // Project sees the patched bytes.
974        assert_eq!(tokio::fs::read(&project).await.unwrap(), b"patched");
975        // Store entry is untouched — the headline pnpm invariant.
976        assert_eq!(tokio::fs::read(&store).await.unwrap(), b"original");
977    }
978
979    /// Existing read-only file: temporarily made writable for the
980    /// overwrite, restored to read-only afterward, content updated.
981    /// Mirrors the Go module cache scenario.
982    #[cfg(unix)]
983    #[tokio::test]
984    async fn test_apply_file_patch_preserves_readonly_mode() {
985        use std::os::unix::fs::PermissionsExt;
986
987        let dir = tempfile::tempdir().unwrap();
988        let path = dir.path().join("index.js");
989        let original = b"original";
990        let patched = b"patched content";
991        let patched_hash = compute_git_sha256_from_bytes(patched);
992
993        tokio::fs::write(&path, original).await.unwrap();
994        // 0o444 = r--r--r--. Owner has no write bit.
995        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444))
996            .await
997            .unwrap();
998
999        apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
1000            .await
1001            .unwrap();
1002
1003        // Content updated.
1004        let written = tokio::fs::read(&path).await.unwrap();
1005        assert_eq!(written, patched);
1006        // Mode preserved bit-for-bit.
1007        let mode_after = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
1008            & 0o7777;
1009        assert_eq!(
1010            mode_after, 0o444,
1011            "mode must be restored to the pre-patch value after the write"
1012        );
1013    }
1014
1015    /// Non-default mode (e.g. 0o755 for an executable script) survives
1016    /// the patch round-trip unchanged.
1017    #[cfg(unix)]
1018    #[tokio::test]
1019    async fn test_apply_file_patch_preserves_executable_mode() {
1020        use std::os::unix::fs::PermissionsExt;
1021
1022        let dir = tempfile::tempdir().unwrap();
1023        let path = dir.path().join("bin.sh");
1024        let original = b"#!/bin/sh\necho old\n";
1025        let patched = b"#!/bin/sh\necho new\n";
1026        let patched_hash = compute_git_sha256_from_bytes(patched);
1027
1028        tokio::fs::write(&path, original).await.unwrap();
1029        tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
1030            .await
1031            .unwrap();
1032
1033        apply_file_patch(dir.path(), "bin.sh", patched, &patched_hash)
1034            .await
1035            .unwrap();
1036
1037        let mode_after = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
1038            & 0o7777;
1039        assert_eq!(mode_after, 0o755);
1040    }
1041
1042    /// New file created by the patch: default mode is read-only (0o444)
1043    /// and the parent directory's uid/gid get inherited (the uid/gid
1044    /// check is a smoke test — running as a regular user the new file
1045    /// would already inherit the user's uid, but the test still locks
1046    /// in that the new file's uid matches the parent's, which is what
1047    /// the chown call enforces).
1048    #[cfg(unix)]
1049    #[tokio::test]
1050    async fn test_apply_file_patch_new_file_is_readonly_and_inherits_dir_owner() {
1051        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1052
1053        let dir = tempfile::tempdir().unwrap();
1054        let nested = "new-dir/new.js";
1055        let patched = b"brand new file content\n";
1056        let patched_hash = compute_git_sha256_from_bytes(patched);
1057
1058        // File does not yet exist — this is the new-file path.
1059        apply_file_patch(dir.path(), nested, patched, &patched_hash)
1060            .await
1061            .unwrap();
1062
1063        let path = dir.path().join(nested);
1064        // Default new-file mode is 0o444.
1065        let mode = tokio::fs::metadata(&path).await.unwrap().permissions().mode()
1066            & 0o7777;
1067        assert_eq!(mode, 0o444, "new files default to read-only");
1068
1069        // uid/gid inherited from the parent directory.
1070        let parent_meta = tokio::fs::metadata(path.parent().unwrap()).await.unwrap();
1071        let file_meta = tokio::fs::metadata(&path).await.unwrap();
1072        assert_eq!(file_meta.uid(), parent_meta.uid());
1073        assert_eq!(file_meta.gid(), parent_meta.gid());
1074    }
1075
1076    /// Existing patched file's uid/gid survive the round-trip. We can
1077    /// only verify "uid stays the same" without root, but that's
1078    /// enough to catch a regression that accidentally clobbered ownership.
1079    #[cfg(unix)]
1080    #[tokio::test]
1081    async fn test_apply_file_patch_preserves_uid_gid() {
1082        use std::os::unix::fs::MetadataExt;
1083
1084        let dir = tempfile::tempdir().unwrap();
1085        let path = dir.path().join("index.js");
1086        let original = b"orig";
1087        let patched = b"new";
1088        let patched_hash = compute_git_sha256_from_bytes(patched);
1089
1090        tokio::fs::write(&path, original).await.unwrap();
1091        let pre = tokio::fs::metadata(&path).await.unwrap();
1092
1093        apply_file_patch(dir.path(), "index.js", patched, &patched_hash)
1094            .await
1095            .unwrap();
1096
1097        let post = tokio::fs::metadata(&path).await.unwrap();
1098        assert_eq!(pre.uid(), post.uid());
1099        assert_eq!(pre.gid(), post.gid());
1100    }
1101
1102    #[tokio::test]
1103    async fn test_apply_package_patch_success() {
1104        let pkg_dir = tempfile::tempdir().unwrap();
1105        let blobs_dir = tempfile::tempdir().unwrap();
1106
1107        let original = b"original content";
1108        let patched = b"patched content";
1109        let before_hash = compute_git_sha256_from_bytes(original);
1110        let after_hash = compute_git_sha256_from_bytes(patched);
1111
1112        // Write original file
1113        tokio::fs::write(pkg_dir.path().join("index.js"), original)
1114            .await
1115            .unwrap();
1116
1117        // Write blob
1118        tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
1119            .await
1120            .unwrap();
1121
1122        let mut files = HashMap::new();
1123        files.insert(
1124            "index.js".to_string(),
1125            PatchFileInfo {
1126                before_hash,
1127                after_hash: after_hash.clone(),
1128            },
1129        );
1130
1131        let result =
1132            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1133                .await;
1134
1135        assert!(result.success);
1136        assert_eq!(result.files_patched.len(), 1);
1137        assert!(result.error.is_none());
1138    }
1139
1140    #[tokio::test]
1141    async fn test_apply_package_patch_dry_run() {
1142        let pkg_dir = tempfile::tempdir().unwrap();
1143        let blobs_dir = tempfile::tempdir().unwrap();
1144
1145        let original = b"original content";
1146        let before_hash = compute_git_sha256_from_bytes(original);
1147
1148        tokio::fs::write(pkg_dir.path().join("index.js"), original)
1149            .await
1150            .unwrap();
1151
1152        let mut files = HashMap::new();
1153        files.insert(
1154            "index.js".to_string(),
1155            PatchFileInfo {
1156                before_hash,
1157                after_hash: "bbbb".to_string(),
1158            },
1159        );
1160
1161        let result =
1162            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, true, false)
1163                .await;
1164
1165        assert!(result.success);
1166        assert_eq!(result.files_patched.len(), 0); // dry run: nothing actually patched
1167
1168        // File should still have original content
1169        let content = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
1170        assert_eq!(content, original);
1171    }
1172
1173    #[tokio::test]
1174    async fn test_apply_package_patch_all_already_patched() {
1175        let pkg_dir = tempfile::tempdir().unwrap();
1176        let blobs_dir = tempfile::tempdir().unwrap();
1177
1178        let patched = b"patched content";
1179        let after_hash = compute_git_sha256_from_bytes(patched);
1180
1181        tokio::fs::write(pkg_dir.path().join("index.js"), patched)
1182            .await
1183            .unwrap();
1184
1185        let mut files = HashMap::new();
1186        files.insert(
1187            "index.js".to_string(),
1188            PatchFileInfo {
1189                before_hash: "aaaa".to_string(),
1190                after_hash,
1191            },
1192        );
1193
1194        let result =
1195            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1196                .await;
1197
1198        assert!(result.success);
1199        assert_eq!(result.files_patched.len(), 0);
1200    }
1201
1202    #[tokio::test]
1203    async fn test_apply_package_patch_hash_mismatch_blocks() {
1204        let pkg_dir = tempfile::tempdir().unwrap();
1205        let blobs_dir = tempfile::tempdir().unwrap();
1206
1207        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1208            .await
1209            .unwrap();
1210
1211        let mut files = HashMap::new();
1212        files.insert(
1213            "index.js".to_string(),
1214            PatchFileInfo {
1215                before_hash: "aaaa".to_string(),
1216                after_hash: "bbbb".to_string(),
1217            },
1218        );
1219
1220        let result =
1221            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1222                .await;
1223
1224        assert!(!result.success);
1225        assert!(result.error.is_some());
1226    }
1227
1228    #[tokio::test]
1229    async fn test_apply_package_patch_force_hash_mismatch() {
1230        let pkg_dir = tempfile::tempdir().unwrap();
1231        let blobs_dir = tempfile::tempdir().unwrap();
1232
1233        let patched = b"patched content";
1234        let after_hash = compute_git_sha256_from_bytes(patched);
1235
1236        // Write a file whose hash does NOT match before_hash
1237        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1238            .await
1239            .unwrap();
1240
1241        // Write blob
1242        tokio::fs::write(blobs_dir.path().join(&after_hash), patched)
1243            .await
1244            .unwrap();
1245
1246        let mut files = HashMap::new();
1247        files.insert(
1248            "index.js".to_string(),
1249            PatchFileInfo {
1250                before_hash: "aaaa".to_string(),
1251                after_hash: after_hash.clone(),
1252            },
1253        );
1254
1255        // Without force: should fail
1256        let result =
1257            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1258                .await;
1259        assert!(!result.success);
1260
1261        // Reset the file
1262        tokio::fs::write(pkg_dir.path().join("index.js"), b"something unexpected")
1263            .await
1264            .unwrap();
1265
1266        // With force: should succeed
1267        let result =
1268            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, true)
1269                .await;
1270        assert!(result.success);
1271        assert_eq!(result.files_patched.len(), 1);
1272
1273        let written = tokio::fs::read(pkg_dir.path().join("index.js")).await.unwrap();
1274        assert_eq!(written, patched);
1275    }
1276
1277    #[tokio::test]
1278    async fn test_apply_package_patch_force_not_found_skips() {
1279        let pkg_dir = tempfile::tempdir().unwrap();
1280        let blobs_dir = tempfile::tempdir().unwrap();
1281
1282        let mut files = HashMap::new();
1283        files.insert(
1284            "missing.js".to_string(),
1285            PatchFileInfo {
1286                before_hash: "aaaa".to_string(),
1287                after_hash: "bbbb".to_string(),
1288            },
1289        );
1290
1291        // Without force: should fail (NotFound for non-new file)
1292        let result =
1293            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, false)
1294                .await;
1295        assert!(!result.success);
1296
1297        // With force: should succeed by skipping the missing file
1298        let result =
1299            apply_package_patch("pkg:npm/test@1.0.0", pkg_dir.path(), &files, &PatchSources::blobs_only(blobs_dir.path()), None, false, true)
1300                .await;
1301        assert!(result.success);
1302        assert_eq!(result.files_patched.len(), 0);
1303    }
1304
1305    // ── Fallback-chain tests ─────────────────────────────────────────
1306    //
1307    // Tests below exercise the new strategies introduced in 2.2:
1308    // package archive (.socket/packages/<uuid>.tar.gz) and per-file diff
1309    // archive (.socket/diffs/<uuid>.tar.gz), plus the priority order
1310    // package → diff → blob.
1311
1312    use flate2::write::GzEncoder;
1313    use flate2::Compression as GzCompression;
1314    use qbsdiff::Bsdiff;
1315
1316    const TEST_UUID: &str = "11111111-1111-4111-8111-111111111111";
1317
1318    /// Write a tar.gz archive at `<dir>/<uuid>.tar.gz` containing the
1319    /// given (entry name → bytes) pairs.
1320    fn write_uuid_archive(dir: &Path, uuid: &str, entries: &[(&str, &[u8])]) {
1321        let archive_path = dir.join(format!("{uuid}.tar.gz"));
1322        let file = std::fs::File::create(&archive_path).unwrap();
1323        let gz = GzEncoder::new(file, GzCompression::default());
1324        let mut builder = tar::Builder::new(gz);
1325        for (name, data) in entries {
1326            let mut header = tar::Header::new_gnu();
1327            header.set_size(data.len() as u64);
1328            header.set_mode(0o644);
1329            header.set_cksum();
1330            builder.append_data(&mut header, name, *data).unwrap();
1331        }
1332        builder.into_inner().unwrap().finish().unwrap();
1333    }
1334
1335    fn make_delta(before: &[u8], after: &[u8]) -> Vec<u8> {
1336        let mut delta = Vec::new();
1337        Bsdiff::new(before, after)
1338            .compare(std::io::Cursor::new(&mut delta))
1339            .unwrap();
1340        delta
1341    }
1342
1343    /// Returns a fully-populated three-source fixture: original file on
1344    /// disk, all of (package, diff, blob) available with valid patched
1345    /// content. Caller can then delete sources to test fallback.
1346    async fn make_fixture() -> (
1347        tempfile::TempDir, // root holding pkg/, blobs/, packages/, diffs/
1348        std::path::PathBuf, // pkg dir
1349        std::path::PathBuf, // blobs dir
1350        std::path::PathBuf, // packages dir
1351        std::path::PathBuf, // diffs dir
1352        HashMap<String, PatchFileInfo>,
1353        Vec<u8>, // original bytes
1354        Vec<u8>, // patched bytes
1355    ) {
1356        let root = tempfile::tempdir().unwrap();
1357        let pkg_dir = root.path().join("pkg");
1358        let blobs_dir = root.path().join("blobs");
1359        let packages_dir = root.path().join("packages");
1360        let diffs_dir = root.path().join("diffs");
1361        tokio::fs::create_dir_all(&pkg_dir).await.unwrap();
1362        tokio::fs::create_dir_all(&blobs_dir).await.unwrap();
1363        tokio::fs::create_dir_all(&packages_dir).await.unwrap();
1364        tokio::fs::create_dir_all(&diffs_dir).await.unwrap();
1365
1366        let original: Vec<u8> = b"the original content of the file".to_vec();
1367        let patched: Vec<u8> = b"the PATCHED content of the file!".to_vec();
1368        let before_hash = compute_git_sha256_from_bytes(&original);
1369        let after_hash = compute_git_sha256_from_bytes(&patched);
1370
1371        // On-disk file at pkg/index.js
1372        tokio::fs::write(pkg_dir.join("index.js"), &original)
1373            .await
1374            .unwrap();
1375
1376        // Per-file blob at blobs/<after_hash>
1377        tokio::fs::write(blobs_dir.join(&after_hash), &patched)
1378            .await
1379            .unwrap();
1380
1381        // Package archive containing the patched bytes
1382        write_uuid_archive(&packages_dir, TEST_UUID, &[("index.js", &patched)]);
1383
1384        // Diff archive containing bsdiff(original -> patched)
1385        let delta = make_delta(&original, &patched);
1386        write_uuid_archive(&diffs_dir, TEST_UUID, &[("index.js", &delta)]);
1387
1388        let mut files = HashMap::new();
1389        files.insert(
1390            "index.js".to_string(),
1391            PatchFileInfo {
1392                before_hash,
1393                after_hash,
1394            },
1395        );
1396
1397        (root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, patched)
1398    }
1399
1400    #[tokio::test]
1401    async fn test_apply_via_package_when_archive_present() {
1402        let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1403            make_fixture().await;
1404
1405        let sources = PatchSources {
1406            blobs_path: &blobs_dir,
1407            packages_path: Some(&packages_dir),
1408            diffs_path: Some(&diffs_dir),
1409        };
1410        let result = apply_package_patch(
1411            "pkg:npm/x@1.0.0",
1412            &pkg_dir,
1413            &files,
1414            &sources,
1415            Some(TEST_UUID),
1416            false,
1417            false,
1418        )
1419        .await;
1420
1421        assert!(result.success, "expected success: {:?}", result.error);
1422        assert_eq!(result.files_patched, vec!["index.js".to_string()]);
1423        assert_eq!(
1424            result.applied_via.get("index.js"),
1425            Some(&AppliedVia::Package)
1426        );
1427        let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1428        assert_eq!(written, patched);
1429    }
1430
1431    #[tokio::test]
1432    async fn test_apply_falls_back_to_diff_when_no_package() {
1433        let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1434            make_fixture().await;
1435        // Delete the package archive.
1436        tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1437            .await
1438            .unwrap();
1439
1440        let sources = PatchSources {
1441            blobs_path: &blobs_dir,
1442            packages_path: Some(&packages_dir),
1443            diffs_path: Some(&diffs_dir),
1444        };
1445        let result = apply_package_patch(
1446            "pkg:npm/x@1.0.0",
1447            &pkg_dir,
1448            &files,
1449            &sources,
1450            Some(TEST_UUID),
1451            false,
1452            false,
1453        )
1454        .await;
1455
1456        assert!(result.success, "expected success: {:?}", result.error);
1457        assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff));
1458        let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1459        assert_eq!(written, patched);
1460    }
1461
1462    #[tokio::test]
1463    async fn test_apply_falls_back_to_blob_when_no_archives() {
1464        let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1465            make_fixture().await;
1466        // Delete both archives.
1467        tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1468            .await
1469            .unwrap();
1470        tokio::fs::remove_file(diffs_dir.join(format!("{TEST_UUID}.tar.gz")))
1471            .await
1472            .unwrap();
1473
1474        let sources = PatchSources {
1475            blobs_path: &blobs_dir,
1476            packages_path: Some(&packages_dir),
1477            diffs_path: Some(&diffs_dir),
1478        };
1479        let result = apply_package_patch(
1480            "pkg:npm/x@1.0.0",
1481            &pkg_dir,
1482            &files,
1483            &sources,
1484            Some(TEST_UUID),
1485            false,
1486            false,
1487        )
1488        .await;
1489
1490        assert!(result.success);
1491        assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
1492        let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1493        assert_eq!(written, patched);
1494    }
1495
1496    #[tokio::test]
1497    async fn test_apply_uuid_none_disables_alt_sources() {
1498        // Even if archives exist, passing `uuid = None` must restrict the
1499        // pipeline to the blob path — preserving pre-2.2 behavior.
1500        let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, _patched) =
1501            make_fixture().await;
1502
1503        let sources = PatchSources {
1504            blobs_path: &blobs_dir,
1505            packages_path: Some(&packages_dir),
1506            diffs_path: Some(&diffs_dir),
1507        };
1508        let result = apply_package_patch(
1509            "pkg:npm/x@1.0.0",
1510            &pkg_dir,
1511            &files,
1512            &sources,
1513            None,
1514            false,
1515            false,
1516        )
1517        .await;
1518
1519        assert!(result.success);
1520        assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
1521    }
1522
1523    #[tokio::test]
1524    async fn test_apply_via_diff_falls_through_when_before_hash_mismatch() {
1525        // Corrupt the on-disk file so its hash no longer matches
1526        // before_hash. Diff strategy must NOT run (its output would never
1527        // match after_hash), so we fall through to the blob.
1528        let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1529            make_fixture().await;
1530        tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1531            .await
1532            .unwrap();
1533        // Overwrite on-disk content with garbage; use --force so verify
1534        // promotes the HashMismatch to Ready and the pipeline still tries
1535        // to apply.
1536        tokio::fs::write(pkg_dir.join("index.js"), b"garbage")
1537            .await
1538            .unwrap();
1539
1540        let sources = PatchSources {
1541            blobs_path: &blobs_dir,
1542            packages_path: Some(&packages_dir),
1543            diffs_path: Some(&diffs_dir),
1544        };
1545        let result = apply_package_patch(
1546            "pkg:npm/x@1.0.0",
1547            &pkg_dir,
1548            &files,
1549            &sources,
1550            Some(TEST_UUID),
1551            false,
1552            true, // --force
1553        )
1554        .await;
1555
1556        assert!(result.success);
1557        // Diff would produce wrong output → strategy skipped → blob writes.
1558        assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Blob));
1559        let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1560        assert_eq!(written, patched);
1561    }
1562
1563    #[tokio::test]
1564    async fn test_apply_via_package_skips_when_hash_mismatches() {
1565        // Package archive contains the WRONG bytes (would not hash to
1566        // after_hash). The package strategy must refuse the entry and
1567        // fall back to diff or blob.
1568        let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, _orig, patched) =
1569            make_fixture().await;
1570        // Replace the package archive with one whose entry is corrupt.
1571        tokio::fs::remove_file(packages_dir.join(format!("{TEST_UUID}.tar.gz")))
1572            .await
1573            .unwrap();
1574        write_uuid_archive(
1575            &packages_dir,
1576            TEST_UUID,
1577            &[("index.js", b"corrupt package payload")],
1578        );
1579
1580        let sources = PatchSources {
1581            blobs_path: &blobs_dir,
1582            packages_path: Some(&packages_dir),
1583            diffs_path: Some(&diffs_dir),
1584        };
1585        let result = apply_package_patch(
1586            "pkg:npm/x@1.0.0",
1587            &pkg_dir,
1588            &files,
1589            &sources,
1590            Some(TEST_UUID),
1591            false,
1592            false,
1593        )
1594        .await;
1595
1596        assert!(result.success);
1597        // Package refused → diff succeeded next.
1598        assert_eq!(result.applied_via.get("index.js"), Some(&AppliedVia::Diff));
1599        let written = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1600        assert_eq!(written, patched);
1601    }
1602
1603    #[tokio::test]
1604    async fn test_apply_dry_run_does_not_touch_alternative_sources() {
1605        // Even with package/diff archives present, dry-run must not modify
1606        // files on disk.
1607        let (_root, pkg_dir, blobs_dir, packages_dir, diffs_dir, files, original, _patched) =
1608            make_fixture().await;
1609
1610        let sources = PatchSources {
1611            blobs_path: &blobs_dir,
1612            packages_path: Some(&packages_dir),
1613            diffs_path: Some(&diffs_dir),
1614        };
1615        let result = apply_package_patch(
1616            "pkg:npm/x@1.0.0",
1617            &pkg_dir,
1618            &files,
1619            &sources,
1620            Some(TEST_UUID),
1621            true, // dry-run
1622            false,
1623        )
1624        .await;
1625
1626        assert!(result.success);
1627        assert!(result.files_patched.is_empty());
1628        let on_disk = tokio::fs::read(pkg_dir.join("index.js")).await.unwrap();
1629        assert_eq!(on_disk, original);
1630    }
1631
1632    #[test]
1633    fn test_applied_via_as_tag() {
1634        assert_eq!(AppliedVia::Package.as_tag(), "package");
1635        assert_eq!(AppliedVia::Diff.as_tag(), "diff");
1636        assert_eq!(AppliedVia::Blob.as_tag(), "blob");
1637    }
1638
1639    #[test]
1640    fn test_patch_sources_blobs_only_disables_other_strategies() {
1641        let dir = tempfile::tempdir().unwrap();
1642        let sources = PatchSources::blobs_only(dir.path());
1643        assert!(sources.packages_path.is_none());
1644        assert!(sources.diffs_path.is_none());
1645    }
1646}