Skip to main content

socket_patch_core/patch/
apply.rs

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