Skip to main content

smolvm_pack/
extract.rs

1//! Asset extraction for packed binaries.
2//!
3//! Provides shared extraction logic used by both the main `smolvm` binary
4//! (sidecar mode via `runpack`) and the standalone stub executable.
5
6use crate::format::{PackFooter, SIDECAR_EXTENSION};
7use std::fs::{self, File};
8use std::io::{Read, Seek, SeekFrom, Write};
9use std::path::{Path, PathBuf};
10
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13#[cfg(unix)]
14use std::os::unix::io::AsRawFd;
15
16/// Mark an open file as sparse (Windows/NTFS) so a later `set_len` to a large
17/// size — or writing chunks at high offsets after such a `set_len` — doesn't
18/// allocate every intermediate block, ballooning a sparse disk image (overlay,
19/// storage) to its full logical size on disk. Unix filesystems are sparse by
20/// default and never call this.
21///
22/// `smolvm-pack` cannot depend on the main crate, so this mirrors the FSCTL
23/// approach in the main crate's `src/disk_utils.rs::mark_file_sparse`.
24#[cfg(windows)]
25pub(crate) fn mark_file_sparse(file: &fs::File) -> std::io::Result<()> {
26    use std::os::windows::io::AsRawHandle;
27    use windows_sys::Win32::System::IO::DeviceIoControl;
28    // FSCTL_SET_SPARSE control code (winioctl.h).
29    const FSCTL_SET_SPARSE: u32 = 0x000900C4;
30    let mut returned: u32 = 0;
31    // SAFETY: `file` is a valid open handle; FSCTL_SET_SPARSE uses no in/out buffers.
32    let ok = unsafe {
33        DeviceIoControl(
34            file.as_raw_handle(),
35            FSCTL_SET_SPARSE,
36            std::ptr::null(),
37            0,
38            std::ptr::null_mut(),
39            0,
40            &mut returned,
41            std::ptr::null_mut(),
42        )
43    };
44    if ok == 0 {
45        Err(std::io::Error::last_os_error())
46    } else {
47        Ok(())
48    }
49}
50
51/// Acquire a blocking, exclusive advisory lock on an open lock file.
52///
53/// Unix uses `flock(LOCK_EX)`; Windows uses `LockFileEx(LOCKFILE_EXCLUSIVE_LOCK)`
54/// on the file handle. Without the Windows path, concurrent first-run
55/// extractions of the same checksum race. The lock is released when the OS
56/// closes the handle (i.e. when the `File` is dropped).
57#[cfg(unix)]
58fn lock_file_exclusive(lock_file: &fs::File) -> std::io::Result<()> {
59    let ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) };
60    if ret != 0 {
61        return Err(std::io::Error::last_os_error());
62    }
63    Ok(())
64}
65
66#[cfg(windows)]
67fn lock_file_exclusive(lock_file: &fs::File) -> std::io::Result<()> {
68    use std::os::windows::io::AsRawHandle;
69    use windows_sys::Win32::Storage::FileSystem::{LockFileEx, LOCKFILE_EXCLUSIVE_LOCK};
70    use windows_sys::Win32::System::IO::OVERLAPPED;
71
72    let handle = lock_file.as_raw_handle() as windows_sys::Win32::Foundation::HANDLE;
73    let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
74    // Lock the whole (empty) file: offset 0, maximum byte range.
75    let ret = unsafe {
76        LockFileEx(
77            handle,
78            LOCKFILE_EXCLUSIVE_LOCK,
79            0,
80            u32::MAX,
81            u32::MAX,
82            &mut overlapped,
83        )
84    };
85    if ret == 0 {
86        return Err(std::io::Error::last_os_error());
87    }
88    Ok(())
89}
90
91/// Set a Unix file mode on `path`, ignoring errors. No-op on non-Unix targets
92/// (Windows has no POSIX mode bits).
93#[inline]
94fn set_mode(path: &Path, mode: u32) {
95    #[cfg(unix)]
96    {
97        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode));
98    }
99    #[cfg(not(unix))]
100    {
101        let _ = (path, mode);
102    }
103}
104
105/// Files larger than this threshold are extracted with a sparse write
106/// (ftruncate skeleton + pwrite only non-zero 64 KiB chunks) rather than a
107/// dense sequential write.  Chosen to match typical overlay disk sizes while
108/// staying well above any regular asset file.
109const SPARSE_WRITE_THRESHOLD: u64 = 256 * 1024 * 1024; // 256 MiB
110
111/// Extract a single tar entry as a sparse file.
112///
113/// Creates the destination with `ftruncate(entry_size)` so the OS allocates
114/// no disk blocks for the zero regions, then streams `entry` in 64 KiB
115/// chunks and `pwrite`s only the non-zero ones at their correct offsets.
116///
117/// This keeps a 10 GiB overlay disk (with ~50 MB of real data) from
118/// materialising as a dense file during sidecar extraction.
119fn unpack_sparse<R: Read>(
120    entry: &mut tar::Entry<R>,
121    path: &Path,
122    entry_size: u64,
123    mode: u32,
124    real_dest: &Path,
125) -> std::io::Result<()> {
126    // Before creating any directory or opening the file, verify that the real
127    // (symlink-resolved) parent of `path` stays within `real_dest`. A prior
128    // tar entry may have placed a symlink parent component (e.g. `foo -> /`)
129    // that `create_dir_all`/`open` would traverse OUT of the extraction root,
130    // writing an attacker-controlled host file as root. `O_NOFOLLOW` below only
131    // guards the FINAL path component, not an escaping parent — this closes
132    // that gap. (The dense write path gets the same guarantee from the tar
133    // crate's `validate_inside_dst`, which the sparse path bypasses.)
134    verify_parent_within_dest(path, real_dest)?;
135
136    // Ensure the parent directory exists (mirrors what entry.unpack_in does).
137    if let Some(parent) = path.parent() {
138        fs::create_dir_all(parent)?;
139    }
140
141    // Reject symlinks and unexpected directories at the destination.
142    // A prior tar entry may have placed an intra-dest relative symlink at this
143    // path; File::create would follow it, redirecting writes to the symlink
144    // target instead of the intended path.
145    match path.symlink_metadata() {
146        Ok(meta) if meta.file_type().is_symlink() => {
147            return Err(std::io::Error::new(
148                std::io::ErrorKind::InvalidData,
149                format!("unpack_sparse: symlink at destination: {}", path.display()),
150            ));
151        }
152        Ok(meta) if meta.file_type().is_dir() => {
153            return Err(std::io::Error::new(
154                std::io::ErrorKind::InvalidData,
155                format!(
156                    "unpack_sparse: directory at destination: {}",
157                    path.display()
158                ),
159            ));
160        }
161        Ok(_) => {
162            // Regular file: remove it so create_new (O_CREAT|O_EXCL) succeeds.
163            // This handles idempotent re-extraction without silently overwriting.
164            fs::remove_file(path)?;
165        }
166        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
167        Err(e) => return Err(e),
168    }
169
170    // Open with O_CREAT|O_EXCL|O_NOFOLLOW: rejects any symlink placed in the
171    // TOCTOU window between the check above and the open (defense in depth).
172    #[cfg(unix)]
173    let mut file = {
174        use std::os::unix::fs::OpenOptionsExt;
175        fs::OpenOptions::new()
176            .write(true)
177            .create_new(true)
178            .custom_flags(libc::O_NOFOLLOW)
179            .open(path)?
180    };
181    #[cfg(not(unix))]
182    let mut file = fs::OpenOptions::new()
183        .write(true)
184        .create_new(true)
185        .open(path)?;
186
187    // On Windows/NTFS the file is dense by default: set_len to the (large)
188    // logical size and pwriting non-zero chunks at high offsets would allocate
189    // every hole, materialising a 10 GiB overlay even though only ~50 MB is
190    // real data. Mark it sparse first.
191    #[cfg(windows)]
192    mark_file_sparse(&file)?;
193
194    // ftruncate: on APFS and ext4 this allocates zero disk blocks for the
195    // hole regions — only written bytes consume real space.
196    file.set_len(entry_size)?;
197
198    let mut offset: u64 = 0;
199    let mut buf = vec![0u8; 64 * 1024];
200
201    loop {
202        let n = entry.read(&mut buf)?;
203        if n == 0 {
204            break;
205        }
206        let chunk = &buf[..n];
207        if chunk.iter().any(|&b| b != 0) {
208            file.seek(SeekFrom::Start(offset))?;
209            file.write_all(chunk)?;
210        }
211        offset += n as u64;
212    }
213
214    // Only file mode is restored, not timestamps, uid/gid, or xattrs.
215    // unpack_sparse applies to large cache assets (overlay disks, storage
216    // images) extracted to a host-local cache directory; the extra metadata
217    // does not affect functionality for those assets.
218    //
219    // Mask to the low permission bits (`& 0o777`) so a hostile header can't
220    // preserve setuid/setgid/sticky bits on a host-extracted file — mirroring
221    // the dense path, which never carries those bits through.
222    #[cfg(unix)]
223    fs::set_permissions(path, fs::Permissions::from_mode(mode & 0o777))?;
224    #[cfg(not(unix))]
225    let _ = mode;
226
227    Ok(())
228}
229
230/// Verify that the real (symlink-resolved) parent directory of `path` stays
231/// within `real_dest`.
232///
233/// `canonicalize` resolves every symlink component, so if a prior tar entry
234/// planted a symlink parent (e.g. `foo -> /`) that would let a later write
235/// escape the extraction root, the deepest existing ancestor resolves to a
236/// path outside `real_dest` and we reject before any directory is created or
237/// file opened. `real_dest` must itself be a canonicalized path (so the
238/// `starts_with` comparison is apples-to-apples, e.g. macOS `/tmp` →
239/// `/private/tmp`).
240fn verify_parent_within_dest(path: &Path, real_dest: &Path) -> std::io::Result<()> {
241    let Some(parent) = path.parent() else {
242        return Ok(());
243    };
244    // Walk up until we hit a component that already exists on disk; canonicalize
245    // it (following all symlinks) and require it to be inside real_dest.
246    for ancestor in parent.ancestors() {
247        match ancestor.canonicalize() {
248            Ok(real) => {
249                if real.starts_with(real_dest) {
250                    return Ok(());
251                }
252                return Err(std::io::Error::new(
253                    std::io::ErrorKind::InvalidData,
254                    format!(
255                        "resolved parent '{}' of '{}' escapes destination '{}'",
256                        real.display(),
257                        path.display(),
258                        real_dest.display()
259                    ),
260                ));
261            }
262            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
263            Err(e) => return Err(e),
264        }
265    }
266    Ok(())
267}
268
269/// Safely unpack a tar archive: symlinks and hardlinks are allowed only when
270/// their targets stay within `dest`, and any entry that resolves outside `dest`
271/// is rejected.
272///
273/// The standard `tar::Archive::unpack()` strips `..` components but does
274/// **not** validate symlink targets. A crafted archive could create
275/// `lib/libkrun.dylib → /tmp/evil.so`, and subsequent `dlopen()` would
276/// load the attacker's library. This function validates every symlink and
277/// hardlink target against `dest` (rejecting escaping links and absolute
278/// links that alias the destination root) and opens regular files with
279/// `O_NOFOLLOW` plus a canonicalized-parent check, so a write can never
280/// follow a planted symlink out of `dest`.
281fn safe_unpack<R: Read>(archive: &mut tar::Archive<R>, dest: &Path) -> std::io::Result<()> {
282    safe_unpack_with_limits(archive, dest, &SafeUnpackLimits::from_env())
283}
284
285/// Tunable resource ceilings and sparse-write threshold for [`safe_unpack`].
286/// Extracted into a struct so tests can inject small values (exercising the
287/// sparse-write path and the cap-rejection paths) without mutating process-wide
288/// env vars, which would race with other concurrently-running tests.
289#[derive(Clone, Copy)]
290struct SafeUnpackLimits {
291    /// Max number of tar entries before erroring (inode-flood guard).
292    max_entries: u64,
293    /// Max total apparent (header-declared) bytes before erroring
294    /// (disk-exhaustion / decompression-bomb guard).
295    max_total_bytes: u64,
296    /// Regular files with a header size >= this use the sparse-write path.
297    sparse_threshold: u64,
298}
299
300impl SafeUnpackLimits {
301    fn from_env() -> Self {
302        Self {
303            max_entries: max_extract_entries(),
304            max_total_bytes: max_extract_total_bytes(),
305            sparse_threshold: SPARSE_WRITE_THRESHOLD,
306        }
307    }
308}
309
310fn safe_unpack_with_limits<R: Read>(
311    archive: &mut tar::Archive<R>,
312    dest: &Path,
313    limits: &SafeUnpackLimits,
314) -> std::io::Result<()> {
315    // Use `normalize_path` (not `canonicalize`) for the containment base so it
316    // matches the per-entry `normalized` paths, which are built from this same
317    // plain `dest`. On Windows `canonicalize` returns a `\\?\`-verbatim path
318    // while the entry paths stay plain, so `starts_with` would reject every
319    // entry; `normalize_path` (which still resolves `..`, preserving the
320    // traversal defense) keeps both sides in the same form on all platforms.
321    let canonical_dest = normalize_path(dest);
322
323    // Real (symlink-resolved) destination, used only for the sparse-write
324    // parent-escape check. `dest` exists by the time we get here (callers
325    // `create_dir_all` it first), so canonicalize succeeds; fall back to the
326    // normalized form if not. Kept separate from `canonical_dest` because the
327    // per-entry containment check compares against plain-joined paths.
328    let real_dest = dest
329        .canonicalize()
330        .unwrap_or_else(|_| canonical_dest.clone());
331
332    // Bound total work so a hostile layer can't exhaust host disk/inodes across
333    // co-tenants (decompression bomb / entry flood). Counts apparent (header)
334    // sizes and entries; both have generous finite ceilings, overridable via
335    // env for constrained hosts / tests.
336    let max_entries = limits.max_entries;
337    let max_total_bytes = limits.max_total_bytes;
338    let mut entry_count: u64 = 0;
339    let mut total_bytes: u64 = 0;
340
341    // Track directories with restrictive permissions. We extract all entries
342    // with directories temporarily set to 0o755, then apply final permissions
343    // after all children are written. This matches GNU tar / bsdtar behavior
344    // and prevents extraction failures when a read-only directory appears
345    // before its children in the tar stream (e.g., Fedora's mode-555
346    // /usr/lib64/pm-utils/*.d directories).
347    let mut deferred_dir_modes: Vec<(PathBuf, u32)> = Vec::new();
348
349    for entry_result in archive.entries()? {
350        let mut entry = entry_result?;
351        let entry_type = entry.header().entry_type();
352        let entry_path = entry.path()?.to_path_buf();
353
354        // Enforce the entry-count and total-bytes ceilings (Fix 3): reject an
355        // archive that would flood inodes or exhaust disk before we write it.
356        entry_count += 1;
357        if entry_count > max_entries {
358            return Err(std::io::Error::new(
359                std::io::ErrorKind::InvalidData,
360                format!("tar archive exceeds max entry count ({max_entries})"),
361            ));
362        }
363        total_bytes = total_bytes.saturating_add(entry.header().size().unwrap_or(0));
364        if total_bytes > max_total_bytes {
365            return Err(std::io::Error::new(
366                std::io::ErrorKind::InvalidData,
367                format!("tar archive exceeds max total size ({max_total_bytes} bytes)"),
368            ));
369        }
370
371        match entry_type {
372            tar::EntryType::Regular
373            | tar::EntryType::GNUSparse
374            | tar::EntryType::Directory
375            | tar::EntryType::Continuous => {}
376            // GNU/PAX extension headers are metadata for the next entry.
377            // The tar crate normally consumes them internally, but some
378            // archives surface them as explicit entries. Skip them.
379            tar::EntryType::GNULongName
380            | tar::EntryType::GNULongLink
381            | tar::EntryType::XGlobalHeader
382            | tar::EntryType::XHeader => {
383                continue;
384            }
385            tar::EntryType::Symlink => {
386                // Allow symlinks but validate the target stays within dest.
387                if let Some(link_target) = entry.link_name()? {
388                    let link_target = link_target.to_path_buf();
389                    // tar targets use Unix semantics: an absolute target starts
390                    // with '/'. `Path::is_absolute` is false for those on Windows
391                    // (no drive), and `Path::join` would then treat the leading
392                    // slash as a root that wipes `dest`, so detect it by string.
393                    let target_str = link_target.to_string_lossy();
394                    // Resolve relative symlinks against the entry's parent dir
395                    let resolved = if target_str.starts_with('/') {
396                        // Absolute symlinks: jail to dest (e.g., /lib/foo → dest/lib/foo)
397                        dest.join(target_str.trim_start_matches('/'))
398                    } else {
399                        let parent = entry_path.parent().unwrap_or(Path::new(""));
400                        dest.join(parent).join(&link_target)
401                    };
402                    // Normalize the path by resolving .. components
403                    let normalized = normalize_path(&resolved);
404                    if !normalized.starts_with(&canonical_dest) {
405                        return Err(std::io::Error::new(
406                            std::io::ErrorKind::InvalidData,
407                            format!(
408                                "tar symlink '{}' -> '{}' escapes destination directory",
409                                entry_path.display(),
410                                link_target.display()
411                            ),
412                        ));
413                    }
414                    // Root-cause guard (Fix 1b): reject an ABSOLUTE symlink that
415                    // resolves to the destination ROOT itself (e.g. `foo -> /`,
416                    // `x -> /..`). The jailed check above passes these (they
417                    // "stay within dest" only because the jail rewrite collapses
418                    // them onto dest), but on disk the symlink is created with
419                    // its LITERAL target, turning `foo` into an alias for the
420                    // real filesystem root — the exact primitive a later
421                    // `foo/etc/...` entry uses to escape. A legitimate absolute
422                    // symlink points to a sub-path (`/usr/lib/y`), never at the
423                    // root, so this preserves in-image absolute links.
424                    if target_str.starts_with('/') && normalized == canonical_dest {
425                        return Err(std::io::Error::new(
426                            std::io::ErrorKind::InvalidData,
427                            format!(
428                                "tar symlink '{}' -> '{}' aliases the destination root",
429                                entry_path.display(),
430                                link_target.display()
431                            ),
432                        ));
433                    }
434                }
435            }
436            tar::EntryType::Link => {
437                // Allow hardlinks but validate the target stays within dest.
438                if let Some(link_target) = entry.link_name()? {
439                    // Same Unix-absolute handling as symlinks above.
440                    let target_str = link_target.to_string_lossy();
441                    let full_target = if target_str.starts_with('/') {
442                        dest.join(target_str.trim_start_matches('/'))
443                    } else {
444                        dest.join(link_target.as_ref())
445                    };
446                    let normalized = normalize_path(&full_target);
447                    if !normalized.starts_with(&canonical_dest) {
448                        return Err(std::io::Error::new(
449                            std::io::ErrorKind::InvalidData,
450                            format!(
451                                "tar hardlink '{}' escapes destination directory",
452                                entry_path.display()
453                            ),
454                        ));
455                    }
456                    // Skip hardlinks whose target was skipped (e.g., overlayfs
457                    // whiteout char devices). The target doesn't exist on disk
458                    // so creating the hardlink would fail.
459                    if !normalized.exists() {
460                        continue;
461                    }
462                }
463            }
464            tar::EntryType::Char | tar::EntryType::Block | tar::EntryType::Fifo => {
465                // Device nodes and FIFOs appear in overlayfs upper-layer
466                // exports (e.g., whiteout char devices from package upgrades,
467                // named pipes from certain RPM scriptlets). These cannot be
468                // created without root on macOS and aren't needed on the
469                // host — skip them.
470                continue;
471            }
472            _other => {
473                // Unknown or unsupported entry types (sockets, vendor
474                // extensions, future tar formats). Skip rather than fail —
475                // the packed image runs inside a Linux VM where the agent
476                // rootfs provides these files; missing non-regular entries
477                // on the host extraction side don't affect functionality.
478                continue;
479            }
480        }
481
482        // Validate that the unpacked path stays within dest.
483        let full_path = dest.join(&entry_path);
484        let normalized = normalize_path(&full_path);
485        if !normalized.starts_with(&canonical_dest) {
486            return Err(std::io::Error::new(
487                std::io::ErrorKind::InvalidData,
488                format!(
489                    "tar entry '{}' escapes destination directory",
490                    entry_path.display()
491                ),
492            ));
493        }
494
495        // Ensure parent directories are writable before extracting any entry.
496        // OCI layer tars may set restrictive directory modes (e.g., dr-xr-xr-x)
497        // before child entries, which prevents creating files or subdirectories.
498        if let Some(parent) = full_path.parent() {
499            if parent.is_dir() {
500                set_mode(parent, 0o755);
501            }
502        }
503
504        // Save the tar's intended directory mode for deferred application.
505        if entry_type == tar::EntryType::Directory {
506            let mode = entry.header().mode().unwrap_or(0o755);
507            if mode & 0o200 == 0 {
508                deferred_dir_modes.push((full_path.clone(), mode));
509            }
510        }
511
512        let is_regular =
513            entry_type == tar::EntryType::Regular || entry_type == tar::EntryType::GNUSparse;
514
515        // For large regular files use a sparse write: ftruncate creates the
516        // hole skeleton, then we only pwrite non-zero 64 KiB chunks.  This
517        // prevents 10 GiB overlay disks from materialising as dense files on
518        // disk and causing ENOSPC or slow extraction.
519        if is_regular && entry.header().size().unwrap_or(0) >= limits.sparse_threshold {
520            let entry_size = entry.header().size()?;
521            let mode = entry.header().mode().unwrap_or(0o644);
522            if let Err(e) = unpack_sparse(&mut entry, &full_path, entry_size, mode, &real_dest) {
523                return Err(std::io::Error::new(
524                    e.kind(),
525                    format!("failed to unpack '{}': {}", entry_path.display(), e),
526                ));
527            }
528        } else {
529            if let Err(e) = entry.unpack_in(dest) {
530                // On macOS, certain entries fail to unpack due to platform
531                // limitations (xattr encoding, uid/gid mapping, resource forks).
532                // For non-Regular entries (symlinks, hardlinks, dirs), skip and
533                // continue rather than aborting the entire extraction.
534                if !is_regular {
535                    continue;
536                }
537                return Err(std::io::Error::new(
538                    e.kind(),
539                    format!("failed to unpack '{}': {}", entry_path.display(), e),
540                ));
541            }
542
543            // After extracting a directory, force it writable so subsequent
544            // entries (children) can be created inside it. Final permissions
545            // are applied after the loop.
546            if entry_type == tar::EntryType::Directory && full_path.is_dir() {
547                set_mode(&full_path, 0o755);
548            }
549        }
550    }
551
552    // Apply deferred directory permissions now that all children are written.
553    for (path, mode) in deferred_dir_modes {
554        if path.is_dir() {
555            set_mode(&path, mode);
556        }
557    }
558
559    Ok(())
560}
561
562/// Normalize a path by resolving `.` and `..` components without requiring
563/// the path to exist on disk (unlike `canonicalize()`).
564fn normalize_path(path: &Path) -> PathBuf {
565    let mut components = Vec::new();
566    for component in path.components() {
567        match component {
568            std::path::Component::ParentDir => {
569                components.pop();
570            }
571            std::path::Component::CurDir => {}
572            c => components.push(c),
573        }
574    }
575    components.iter().collect()
576}
577
578/// Resolve a manifest asset path against an extraction cache without allowing
579/// absolute paths, parent components, or symlink traversal outside the cache.
580pub fn resolve_cache_asset_path(
581    cache_dir: &Path,
582    asset_rel_path: &str,
583    context: &str,
584) -> std::io::Result<PathBuf> {
585    if asset_rel_path.is_empty() {
586        return Err(std::io::Error::new(
587            std::io::ErrorKind::InvalidInput,
588            format!("{} path is empty", context),
589        ));
590    }
591
592    let rel = Path::new(asset_rel_path);
593    if rel.is_absolute() {
594        return Err(std::io::Error::new(
595            std::io::ErrorKind::InvalidInput,
596            format!("{} path must be relative", context),
597        ));
598    }
599
600    for component in rel.components() {
601        match component {
602            std::path::Component::Normal(_) => {}
603            std::path::Component::ParentDir
604            | std::path::Component::CurDir
605            | std::path::Component::RootDir
606            | std::path::Component::Prefix(_) => {
607                return Err(std::io::Error::new(
608                    std::io::ErrorKind::InvalidInput,
609                    format!("{} path contains disallowed components", context),
610                ));
611            }
612        }
613    }
614
615    let cache_root = cache_dir
616        .canonicalize()
617        .unwrap_or_else(|_| normalize_path(cache_dir));
618    let candidate = cache_dir.join(rel);
619
620    let resolved = if candidate.exists() {
621        candidate.canonicalize()?
622    } else {
623        // Candidate doesn't exist yet. Canonicalize its parent (which must
624        // exist — it's the cache dir) and join the filename. This avoids
625        // the macOS /tmp → /private/tmp symlink mismatch that would cause
626        // the starts_with check below to fail when cache_root is canonical
627        // but normalize_path is not.
628        let parent = candidate.parent().unwrap_or(&candidate);
629        let canonical_parent = parent
630            .canonicalize()
631            .unwrap_or_else(|_| normalize_path(parent));
632        canonical_parent.join(candidate.file_name().unwrap_or_default())
633    };
634
635    if !resolved.starts_with(&cache_root) {
636        return Err(std::io::Error::new(
637            std::io::ErrorKind::InvalidInput,
638            format!("{} path escapes cache directory", context),
639        ));
640    }
641
642    Ok(resolved)
643}
644
645/// Marker file indicating extraction is complete.
646const EXTRACTION_MARKER: &str = ".smolvm-extracted";
647
648/// Get the cache directory for a given checksum.
649///
650/// Returns `~/.cache/smolvm-pack/<checksum>/` (hex-formatted).
651///
652/// FOLLOW-UP (Finding D): `checksum` is a 32-bit CRC content fingerprint, which
653/// is not collision-resistant — two distinct packs can share a cache dir. This
654/// is a content-addressing weakness (not a write-escape) and warrants migrating
655/// the cache key to a SHA-256 digest; tracked separately as it changes the
656/// on-disk cache layout and footer format.
657pub fn get_cache_dir(checksum: u32) -> std::io::Result<PathBuf> {
658    let base = dirs::cache_dir()
659        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no cache directory"))?;
660
661    Ok(base.join("smolvm-pack").join(format!("{:08x}", checksum)))
662}
663
664/// Check if assets have already been extracted.
665pub fn is_extracted(cache_dir: &Path) -> bool {
666    cache_dir.join(EXTRACTION_MARKER).exists()
667}
668
669/// Maximum total size of the pack extraction cache before LRU eviction kicks in.
670/// Override with `SMOLVM_PACK_CACHE_MAX_BYTES` (in bytes); default 5 GiB.
671pub fn pack_cache_max_bytes() -> u64 {
672    const DEFAULT: u64 = 5 * 1024 * 1024 * 1024;
673    std::env::var("SMOLVM_PACK_CACHE_MAX_BYTES")
674        .ok()
675        .and_then(|s| s.trim().parse::<u64>().ok())
676        .filter(|&n| n > 0)
677        .unwrap_or(DEFAULT)
678}
679
680/// Maximum number of entries `safe_unpack` will extract from a single archive
681/// before erroring (inode-flood / entry-bomb guard). Override with
682/// `SMOLVM_PACK_MAX_ENTRIES`; default 2,000,000 — far above any real image tar.
683fn max_extract_entries() -> u64 {
684    const DEFAULT: u64 = 2_000_000;
685    std::env::var("SMOLVM_PACK_MAX_ENTRIES")
686        .ok()
687        .and_then(|s| s.trim().parse::<u64>().ok())
688        .filter(|&n| n > 0)
689        .unwrap_or(DEFAULT)
690}
691
692/// Maximum total apparent (header-declared) size `safe_unpack` will extract from
693/// a single archive before erroring (disk-exhaustion / decompression-bomb
694/// guard). Override with `SMOLVM_PACK_MAX_EXTRACT_BYTES`; default 128 GiB —
695/// generous enough for several large (sparse) overlay disks yet finite.
696fn max_extract_total_bytes() -> u64 {
697    const DEFAULT: u64 = 128 * 1024 * 1024 * 1024;
698    std::env::var("SMOLVM_PACK_MAX_EXTRACT_BYTES")
699        .ok()
700        .and_then(|s| s.trim().parse::<u64>().ok())
701        .filter(|&n| n > 0)
702        .unwrap_or(DEFAULT)
703}
704
705/// Real (sparse-aware) disk usage of a single file. Extraction dirs contain
706/// large *sparse* overlay disks (e.g. a 10 GiB disk holding ~50 MB), so we must
707/// count allocated blocks, not the apparent length — otherwise the cap would
708/// over-count by orders of magnitude and evict far too aggressively.
709#[cfg(unix)]
710fn file_disk_usage(meta: &fs::Metadata) -> u64 {
711    use std::os::unix::fs::MetadataExt;
712    meta.blocks().saturating_mul(512)
713}
714#[cfg(not(unix))]
715fn file_disk_usage(meta: &fs::Metadata) -> u64 {
716    meta.len()
717}
718
719/// Recursive real disk usage of a directory tree (best-effort; unreadable
720/// entries count as zero). Does not follow symlinks.
721fn dir_disk_usage(path: &Path) -> u64 {
722    let mut total = 0u64;
723    let entries = match fs::read_dir(path) {
724        Ok(e) => e,
725        Err(_) => return 0,
726    };
727    for entry in entries.flatten() {
728        let meta = match entry.metadata() {
729            Ok(m) => m,
730            Err(_) => continue,
731        };
732        if meta.is_dir() {
733            total = total.saturating_add(dir_disk_usage(&entry.path()));
734        } else if meta.is_file() {
735            total = total.saturating_add(file_disk_usage(&meta));
736        }
737    }
738    total
739}
740
741/// Evict least-recently-modified extraction directories under `cache_root` until
742/// the cache's total real disk usage is at or below `max_bytes`. Skips
743/// directories with active leases (a running pack/VM) — they are never evicted,
744/// even if that leaves the cache over the cap. Best-effort: per-entry errors are
745/// skipped. Returns the number of bytes freed.
746///
747/// This is what bounds the otherwise-unbounded extraction cache; it runs
748/// automatically after a new (cache-miss) extraction, and keeps the newest
749/// entries (including the one just written) by evicting oldest-first.
750pub fn evict_cache_to_size(cache_root: &Path, max_bytes: u64) -> u64 {
751    evict_cache_to_size_protecting(cache_root, max_bytes, None)
752}
753
754/// Like [`evict_cache_to_size`], but never evicts `protect` (canonicalized),
755/// even if that leaves the cache over the cap.
756///
757/// The eviction after a fresh extraction runs *before* the caller acquires a
758/// layers lease, so the just-written directory has no lease yet. When a single
759/// extraction is larger than the cap (a torch/CUDA pack is ~13 GiB vs the 5 GiB
760/// default), oldest-first eviction would otherwise delete the very directory
761/// about to be booted — the VM then mounts nonexistent virtio-fs shares and the
762/// vcpu panics with `BadActivate`. Passing the current `cache_dir` as `protect`
763/// prevents that; the cache is simply allowed over-cap until the run releases it
764/// (same policy already applied to leased dirs).
765pub fn evict_cache_to_size_protecting(
766    cache_root: &Path,
767    max_bytes: u64,
768    protect: Option<&Path>,
769) -> u64 {
770    let protect_canon = protect.and_then(|p| fs::canonicalize(p).ok());
771    let mut entries: Vec<(PathBuf, std::time::SystemTime, u64)> = Vec::new();
772    let read_dir = match fs::read_dir(cache_root) {
773        Ok(rd) => rd,
774        Err(_) => return 0,
775    };
776    for entry in read_dir.flatten() {
777        let path = entry.path();
778        let meta = match fs::metadata(&path) {
779            Ok(m) => m,
780            Err(_) => continue,
781        };
782        if !meta.is_dir() {
783            continue; // skip *.lock files and other non-extraction entries
784        }
785        let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
786        entries.push((path, modified, dir_disk_usage(&entry.path())));
787    }
788
789    let total: u64 = entries.iter().map(|(_, _, s)| *s).sum();
790    if total <= max_bytes {
791        return 0;
792    }
793
794    // Oldest first — evict least-recently-used.
795    entries.sort_by_key(|(_, modified, _)| *modified);
796
797    let mut over = total - max_bytes;
798    let mut freed = 0u64;
799    for (path, _, size) in entries {
800        if over == 0 {
801            break;
802        }
803        if has_active_leases(&path) {
804            continue; // never evict a running pack/VM
805        }
806        if protect_canon
807            .as_deref()
808            .is_some_and(|pc| fs::canonicalize(&path).ok().as_deref() == Some(pc))
809        {
810            continue; // never evict the extraction we just wrote / are about to boot
811        }
812        force_detach_layers_volume(&path);
813        if fs::remove_dir_all(&path).is_ok() {
814            // Also drop the adjacent <checksum>.lock file, if any.
815            let _ = fs::remove_file(path.with_extension("lock"));
816            freed = freed.saturating_add(size);
817            over = over.saturating_sub(size);
818        }
819    }
820    freed
821}
822
823/// Check if footer indicates sidecar mode.
824fn is_sidecar_mode(footer: &PackFooter) -> bool {
825    footer.assets_offset == 0
826}
827
828/// Get sidecar file path for the given executable.
829pub fn sidecar_path_for(exe_path: &Path) -> PathBuf {
830    let filename = exe_path
831        .file_name()
832        .map(|s| s.to_string_lossy().to_string())
833        .unwrap_or_default();
834    exe_path.with_file_name(format!("{}{}", filename, SIDECAR_EXTENSION))
835}
836
837/// Extract assets from a sidecar `.smolmachine` file to the cache directory.
838///
839/// This is the primary extraction function for `smolvm pack run`.
840/// The sidecar file format is: compressed_assets + manifest + footer.
841///
842/// Uses file-based locking (`flock`) to prevent races when multiple processes
843/// attempt first-run extraction of the same sidecar concurrently. If `force`
844/// is false and extraction has already completed (marker file present), this
845/// is a no-op (after acquiring the lock to ensure visibility of a concurrent
846/// extraction that just finished).
847pub fn extract_sidecar(
848    sidecar_path: &Path,
849    cache_dir: &Path,
850    footer: &PackFooter,
851    force: bool,
852    debug: bool,
853) -> std::io::Result<()> {
854    // Private per-machine caches are LRU-capped after extraction; the shared
855    // store opts out (see `extract_sidecar_capped`).
856    extract_sidecar_capped(sidecar_path, cache_dir, footer, force, debug, true)
857}
858
859/// Core of [`extract_sidecar`]. `cap_cache` runs the LRU size-cap on
860/// `cache_dir.parent()` after a successful extraction.
861///
862/// It MUST be `false` for the node-shared store (`_shared`): those entries are
863/// reference-shared across many VMs via `.pack-shared` pointers and hold NO
864/// per-VM lease, so capping the shared root would LRU-evict a pack still mounted
865/// by live pool VMs — their `/packed_layers` then reads empty and the guest
866/// fails with "no layer directories found in /packed_layers" (exit 255 on
867/// connect/exec). Only the private per-machine cache (`smolvm-pack/<checksum>`),
868/// whose running entries DO hold leases, is safe to cap. See
869/// `extract_sidecar_shared`, which passes `false`.
870fn extract_sidecar_capped(
871    sidecar_path: &Path,
872    cache_dir: &Path,
873    footer: &PackFooter,
874    force: bool,
875    debug: bool,
876    cap_cache: bool,
877) -> std::io::Result<()> {
878    if !sidecar_path.exists() {
879        return Err(std::io::Error::new(
880            std::io::ErrorKind::NotFound,
881            format!("sidecar file not found: {}", sidecar_path.display()),
882        ));
883    }
884
885    // Ensure parent directory exists for the lockfile
886    if let Some(parent) = cache_dir.parent() {
887        fs::create_dir_all(parent)?;
888    }
889
890    // Acquire an exclusive lock adjacent to the cache directory.
891    // This serializes concurrent first-run extractions of the same checksum.
892    let lock_path = cache_dir.with_extension("lock");
893    // Held open for the function's duration: backs the advisory lock below
894    // (flock on Unix, LockFileEx on Windows) and releases on drop.
895    let lock_file = fs::OpenOptions::new()
896        .create(true)
897        .write(true)
898        .truncate(false)
899        .open(&lock_path)?;
900
901    lock_file_exclusive(&lock_file)?;
902
903    // Double-check inside the lock: another process may have completed
904    // extraction while we were waiting for the lock.
905    if !force && is_extracted(cache_dir) {
906        if debug {
907            eprintln!("debug: assets already extracted (possibly by another process)");
908        }
909        // Lock released on drop of lock_file
910        return Ok(());
911    }
912
913    // If force-extracting over an existing cache, detach any mounted
914    // case-sensitive volume first, then remove for a clean slate.
915    if force && cache_dir.exists() {
916        force_detach_layers_volume(cache_dir);
917        let _ = fs::remove_dir_all(cache_dir);
918    }
919
920    let result = extract_sidecar_inner(sidecar_path, cache_dir, footer, debug);
921
922    // If extraction failed mid-stream, partially extracted files remain on
923    // disk without a completion marker. Subsequent retries hit the same
924    // error at the same tar entry, never completing. Clean up the partial
925    // directory so the next attempt starts fresh.
926    if result.is_err() && cache_dir.exists() && !is_extracted(cache_dir) {
927        let _ = fs::remove_dir_all(cache_dir);
928    }
929
930    // After a successful new extraction (cache miss — the early-return above
931    // handles cache hits), cap the cache so old, unused extractions don't grow
932    // without bound. LRU + lease-aware: keeps the newest (incl. what we just
933    // wrote) and never evicts a running pack.
934    if result.is_ok() && cap_cache {
935        if let Some(root) = cache_dir.parent() {
936            // Protect the directory we just extracted: the caller has not yet
937            // acquired its layers lease, so without this the eviction can delete
938            // the assets this very run is about to boot (→ virtio-fs ENOENT →
939            // vcpu BadActivate). A torch pack (~13 GiB) alone exceeds the 5 GiB
940            // default cap, which is exactly when oldest-first eviction reaches it.
941            let freed =
942                evict_cache_to_size_protecting(root, pack_cache_max_bytes(), Some(cache_dir));
943            if freed > 0 && debug {
944                eprintln!("debug: pack cache evicted {freed} bytes to stay under cap");
945            }
946        }
947    }
948
949    result
950    // Lock released on drop of lock_file
951}
952
953/// Whether the shared content-addressed pack store is usable on this host.
954///
955/// The shared store extracts each build-constant pack exactly once per node into
956/// `_shared/<checksum>` (root-owned, read-only) and presents it to each VM via a
957/// per-VM idmapped bind mount, instead of re-extracting + re-chowning a private
958/// copy per machine. That mechanism is Linux-only (idmapped mounts, kernel ≥5.12)
959/// and the per-VM uid isolation it preserves only exists on the Linux fleet.
960/// `SMOLVM_DISABLE_SHARED_EXTRACT` is a kill-switch to fall back to the per-machine
961/// path without a redeploy.
962pub fn shared_extract_enabled() -> bool {
963    cfg!(target_os = "linux") && std::env::var_os("SMOLVM_DISABLE_SHARED_EXTRACT").is_none()
964}
965
966/// Directory holding the shared copy for one pack checksum, under `shared_root`.
967pub fn shared_pack_dir(shared_root: &Path, checksum: u32) -> PathBuf {
968    shared_root.join(format!("{:08x}", checksum))
969}
970
971/// Extract a sidecar pack ONCE into the shared content-addressed store and return
972/// the path to the shared copy (`shared_root/<checksum>`).
973///
974/// Unlike [`extract_sidecar`] (which writes a private per-machine copy), this is
975/// keyed purely by `footer.checksum` (a CRC32 content fingerprint), so every
976/// machine on a node whose pack hashes identically reuses the same extracted tree.
977/// The build-constant agent-rootfs (~28.6 MB / 362 files) therefore decodes once
978/// per node instead of once per machine — the cold-start tax this removes.
979///
980/// The shared copy is left **root-owned** (the extractor runs as root and the tar
981/// crate does not preserve ownership by default, so all files land `root:root`)
982/// and the store directories are locked to `0700 root`. No other uid can read the
983/// copy directly; a VM reaches it only through its own idmapped bind mount, which
984/// re-presents on-disk uid 0 as that VM's uid — preserving the per-VM isolation
985/// (#456) without a per-machine chown.
986///
987/// Idempotent + concurrency-safe: delegates to [`extract_sidecar`], whose flock +
988/// `.smolvm-extracted` marker serialize concurrent first extractions of the same
989/// checksum and make a warm hit a no-op.
990pub fn extract_sidecar_shared(
991    sidecar_path: &Path,
992    shared_root: &Path,
993    footer: &PackFooter,
994    debug: bool,
995) -> std::io::Result<PathBuf> {
996    let shared_dir = shared_pack_dir(shared_root, footer.checksum);
997    // cap_cache=false: NEVER LRU-evict the shared store. Its entries are
998    // reference-shared across every VM via `.pack-shared` pointers and take no
999    // per-VM lease, so an oldest-first size-cap here would delete a pack still
1000    // mounted by live pool VMs. See `extract_sidecar_capped`.
1001    extract_sidecar_capped(sidecar_path, &shared_dir, footer, false, debug, false)?;
1002    // Lock down the store so a dropped per-VM uid can't read the shared copy
1003    // directly (it must go through its idmapped mount). Best-effort: traversal
1004    // by root (the VMM before it drops privileges) is unaffected by 0700.
1005    restrict_to_owner(shared_root);
1006    restrict_to_owner(&shared_dir);
1007    Ok(shared_dir)
1008}
1009
1010/// Set a directory to `0700` (owner-only) if possible. Best-effort; errors are
1011/// swallowed because the store is already root-owned and root traversal ignores
1012/// the mode — this only hardens against a *dropped* sibling uid reading the copy.
1013fn restrict_to_owner(dir: &Path) {
1014    #[cfg(unix)]
1015    {
1016        use std::os::unix::fs::PermissionsExt;
1017        if let Ok(meta) = fs::metadata(dir) {
1018            let mut perms = meta.permissions();
1019            perms.set_mode(0o700);
1020            let _ = fs::set_permissions(dir, perms);
1021        }
1022    }
1023    #[cfg(not(unix))]
1024    let _ = dir;
1025}
1026
1027/// Inner extraction logic (called under the lock).
1028fn extract_sidecar_inner(
1029    sidecar_path: &Path,
1030    cache_dir: &Path,
1031    footer: &PackFooter,
1032    debug: bool,
1033) -> std::io::Result<()> {
1034    fs::create_dir_all(cache_dir)?;
1035
1036    if debug {
1037        eprintln!(
1038            "debug: reading {} bytes of compressed assets from sidecar {}",
1039            footer.assets_size,
1040            sidecar_path.display()
1041        );
1042    }
1043
1044    let sidecar_file = File::open(sidecar_path)?;
1045    let limited_reader = sidecar_file.take(footer.assets_size);
1046
1047    let decoder = zstd::stream::Decoder::new(limited_reader)
1048        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1049
1050    let mut archive = tar::Archive::new(decoder);
1051    safe_unpack(&mut archive, cache_dir)?;
1052
1053    if debug {
1054        eprintln!("debug: extracted assets to {}", cache_dir.display());
1055    }
1056
1057    // Layer order from the sidecar manifest (bottom→top). Best-effort: if the
1058    // manifest can't be read the agent falls back to a name sort.
1059    let layer_order = crate::packer::read_manifest_from_sidecar(sidecar_path)
1060        .ok()
1061        .map(|m| {
1062            m.assets
1063                .layers
1064                .iter()
1065                .filter_map(|l| layer_id_from_asset_path(&l.path))
1066                .collect::<Vec<_>>()
1067        })
1068        .unwrap_or_default();
1069
1070    post_process_extraction(cache_dir, &layer_order, debug)?;
1071    Ok(())
1072}
1073
1074/// Extract assets from a packed binary to the cache directory.
1075///
1076/// Supports both sidecar mode (assets_offset == 0) and embedded mode.
1077/// This is used by the stub executable.
1078pub fn extract_from_binary(
1079    exe_path: &Path,
1080    cache_dir: &Path,
1081    footer: &PackFooter,
1082    debug: bool,
1083) -> std::io::Result<()> {
1084    fs::create_dir_all(cache_dir)?;
1085
1086    if is_sidecar_mode(footer) {
1087        let sidecar = sidecar_path_for(exe_path);
1088        extract_sidecar(&sidecar, cache_dir, footer, false, debug)
1089    } else {
1090        // Embedded mode: read compressed assets from the executable
1091        let mut exe_file = File::open(exe_path)?;
1092        exe_file.seek(SeekFrom::Start(footer.assets_offset))?;
1093
1094        if debug {
1095            eprintln!(
1096                "debug: reading {} bytes of compressed assets from offset {}",
1097                footer.assets_size, footer.assets_offset
1098            );
1099        }
1100
1101        let limited_reader = (&mut exe_file).take(footer.assets_size);
1102
1103        let decoder = zstd::stream::Decoder::new(limited_reader)
1104            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1105
1106        let mut archive = tar::Archive::new(decoder);
1107        safe_unpack(&mut archive, cache_dir)?;
1108
1109        if debug {
1110            eprintln!("debug: extracted assets to {}", cache_dir.display());
1111        }
1112
1113        // Embedded self-exec stub: no separate sidecar manifest to source layer
1114        // order from here, so let the agent fall back to a name sort.
1115        post_process_extraction(cache_dir, &[], debug)?;
1116        Ok(())
1117    }
1118}
1119
1120/// Extract assets from a memory pointer (for Mach-O section mode on macOS).
1121///
1122/// # Safety
1123///
1124/// `assets_ptr` must point to a valid, readable memory region of at least
1125/// `assets_size` bytes that remains valid for the duration of the call.
1126#[cfg(target_os = "macos")]
1127pub unsafe fn extract_from_section(
1128    cache_dir: &Path,
1129    assets_ptr: *const u8,
1130    assets_size: usize,
1131    debug: bool,
1132) -> std::io::Result<()> {
1133    fs::create_dir_all(cache_dir)?;
1134
1135    if debug {
1136        eprintln!(
1137            "debug: extracting {} bytes of compressed assets from section",
1138            assets_size
1139        );
1140    }
1141
1142    let assets_slice = unsafe { std::slice::from_raw_parts(assets_ptr, assets_size) };
1143    let cursor = std::io::Cursor::new(assets_slice);
1144
1145    let decoder = zstd::stream::Decoder::new(cursor)
1146        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1147
1148    let mut archive = tar::Archive::new(decoder);
1149    safe_unpack(&mut archive, cache_dir)?;
1150
1151    if debug {
1152        eprintln!("debug: extracted assets to {}", cache_dir.display());
1153    }
1154
1155    // Mach-O section self-exec stub: same as embedded mode — name-sort fallback.
1156    post_process_extraction(cache_dir, &[], debug)?;
1157    Ok(())
1158}
1159
1160/// Name of the index file written into the extracted-layers dir recording the
1161/// layers in OCI order (bottom-most first), one short layer id per line. The
1162/// guest agent honors it when stacking overlayfs lowerdirs; without it, layers
1163/// (which are named by content digest) sort arbitrarily and a multi-layer pack
1164/// can be mis-stacked. Must match the agent's `LAYER_ORDER_FILE`.
1165const LAYER_ORDER_FILE: &str = "layer-order";
1166
1167/// Layer id used as the on-disk layer dir name, derived from the manifest asset
1168/// path stem so old short paths and new full-digest paths both preserve order.
1169fn layer_id_from_asset_path(path: &str) -> Option<String> {
1170    let rel = Path::new(path);
1171    (!rel.is_absolute())
1172        .then_some(rel)?
1173        .file_stem()
1174        .and_then(|s| s.to_str())
1175        .filter(|s| !s.is_empty())
1176        .map(ToOwned::to_owned)
1177}
1178
1179/// Post-process extracted assets: unpack agent rootfs, OCI layers, fix
1180/// permissions, and (when `layer_order` is non-empty) write the layer-order
1181/// index so the guest stacks the layers in true OCI order rather than by their
1182/// content-addressed names. `layer_order` is the short layer ids bottom→top.
1183fn post_process_extraction(
1184    cache_dir: &Path,
1185    layer_order: &[String],
1186    debug: bool,
1187) -> std::io::Result<()> {
1188    // Extract agent-rootfs.tar to agent-rootfs directory
1189    let rootfs_tar = cache_dir.join("agent-rootfs.tar");
1190    let rootfs_dir = cache_dir.join("agent-rootfs");
1191    if rootfs_tar.exists() && !rootfs_dir.exists() {
1192        if debug {
1193            eprintln!("debug: extracting agent-rootfs.tar...");
1194        }
1195        fs::create_dir_all(&rootfs_dir)?;
1196        let tar_file = File::open(&rootfs_tar)?;
1197        let mut archive = tar::Archive::new(tar_file);
1198        safe_unpack(&mut archive, &rootfs_dir)?;
1199    }
1200
1201    // Extract OCI layer tars to layers/{digest}/ directories.
1202    //
1203    // On macOS, the default APFS filesystem is case-insensitive. Linux OCI
1204    // layers may contain paths that differ only in case (e.g., "gdebi" script
1205    // and "GDebi/" directory). Extracting these onto case-insensitive APFS
1206    // would silently lose files. Since the extracted directories are mounted
1207    // into the guest via virtiofs as overlayfs lowerdirs, any missing files
1208    // would corrupt the packed image.
1209    //
1210    // To preserve all paths faithfully, we extract layers into a case-sensitive
1211    // APFS sparse disk image on macOS. The image is persisted in the cache and
1212    // re-mounted on subsequent runs.
1213    let layers_dir = cache_dir.join("layers");
1214    if layers_dir.exists() {
1215        if debug {
1216            eprintln!("debug: extracting OCI layers...");
1217        }
1218        // On macOS, extract into a case-sensitive volume to preserve Linux
1219        // paths that differ only in case. On Linux (ext4/xfs), the layers
1220        // dir is already case-sensitive. If the volume can't be created on
1221        // macOS, fail rather than silently corrupting case-colliding paths.
1222        let extract_dir = extraction_layers_dir(cache_dir, debug)?;
1223
1224        for entry in fs::read_dir(&layers_dir)? {
1225            let entry = entry?;
1226            let path = entry.path();
1227            if path.extension().is_some_and(|ext| ext == "tar") {
1228                let stem = path.file_stem().unwrap_or_default().to_string_lossy();
1229                let layer_dir = extract_dir.join(&*stem);
1230                if !layer_dir.exists() {
1231                    if debug {
1232                        eprintln!("debug: extracting layer {}...", stem);
1233                    }
1234                    fs::create_dir_all(&layer_dir)?;
1235                    let tar_file = File::open(&path)?;
1236                    let mut archive = tar::Archive::new(tar_file);
1237                    safe_unpack(&mut archive, &layer_dir)?;
1238                }
1239            }
1240        }
1241
1242        // Record the manifest's layer order so the guest stacks overlayfs
1243        // lowerdirs correctly (layer dirs are named by digest and don't sort
1244        // into stack order). Only ids backed by an extracted dir are written.
1245        if !layer_order.is_empty() {
1246            let lines: Vec<&str> = layer_order
1247                .iter()
1248                .filter(|id| extract_dir.join(id).is_dir())
1249                .map(String::as_str)
1250                .collect();
1251            if !lines.is_empty() {
1252                fs::write(extract_dir.join(LAYER_ORDER_FILE), lines.join("\n"))?;
1253            }
1254        }
1255    }
1256
1257    // Write marker file
1258    fs::write(cache_dir.join(EXTRACTION_MARKER), "")?;
1259
1260    // Make libraries executable (they need to be loadable).
1261    let lib_dir = cache_dir.join("lib");
1262    if lib_dir.exists() {
1263        #[cfg(unix)]
1264        {
1265            use std::os::unix::fs::PermissionsExt;
1266            for entry in fs::read_dir(&lib_dir)? {
1267                let entry = entry?;
1268                let path = entry.path();
1269                if path.is_file() {
1270                    let mut perms = fs::metadata(&path)?.permissions();
1271                    perms.set_mode(0o755);
1272                    fs::set_permissions(&path, perms)?;
1273                }
1274            }
1275        }
1276    }
1277
1278    Ok(())
1279}
1280
1281// =============================================================================
1282// Case-sensitive layer extraction (macOS)
1283//
1284// On macOS, default APFS is case-insensitive. Linux OCI layers may contain
1285// paths that differ only in case (e.g., `gdebi` vs `GDebi/`). Extracting
1286// onto case-insensitive APFS silently drops one variant, corrupting the
1287// packed image.
1288//
1289// We extract layers into a case-sensitive APFS sparse disk image. The image
1290// lives in the cache directory and is mounted on demand. Because the cache
1291// is shared across concurrent runs of the same packed artifact, we use a
1292// lease-file protocol to coordinate mount/unmount:
1293//
1294//   cache_dir/layers-cs.sparseimage   — persisted sparse image
1295//   cache_dir/layers-cs/              — mount point
1296//   cache_dir/leases/<pid>            — one file per active user
1297//   cache_dir/leases.lock             — flock for lease operations
1298//
1299// Acquire: lock → gc stale leases → ensure mounted → write lease → unlock
1300// Release: lock → remove lease → if no leases remain, detach → unlock
1301// =============================================================================
1302
1303/// Name of the sparse disk image used for case-sensitive layer extraction.
1304#[cfg(target_os = "macos")]
1305const CS_IMAGE_NAME: &str = "layers-cs.sparseimage";
1306
1307/// Subdirectory name for the case-sensitive mount point.
1308#[cfg(target_os = "macos")]
1309const CS_MOUNT_DIR: &str = "layers-cs";
1310
1311/// Subdirectory for lease files.
1312#[cfg(target_os = "macos")]
1313const LEASES_DIR: &str = "leases";
1314
1315/// Lock file for lease coordination.
1316#[cfg(target_os = "macos")]
1317const LEASES_LOCK: &str = "leases.lock";
1318
1319/// A lease on the case-sensitive layers volume. On macOS, this ensures the
1320/// APFS sparse image is mounted while any lease exists, and detaches it
1321/// when the last lease is released. On Linux, this is a no-op wrapper.
1322///
1323/// Implements `Drop` so all `?` error paths release the lease automatically.
1324pub struct LayersVolumeLease {
1325    /// Path to the layers directory (case-sensitive mount on macOS, or
1326    /// `cache_dir/layers` on Linux).
1327    pub path: PathBuf,
1328    /// Cache directory this lease belongs to (needed for cleanup on drop).
1329    #[cfg(target_os = "macos")]
1330    cache_dir: PathBuf,
1331}
1332
1333impl Drop for LayersVolumeLease {
1334    fn drop(&mut self) {
1335        #[cfg(target_os = "macos")]
1336        {
1337            release_lease(&self.cache_dir);
1338        }
1339    }
1340}
1341
1342/// Acquire a lease on the case-sensitive layers volume.
1343///
1344/// On macOS: creates the sparse image if needed, mounts it, writes a
1345/// per-PID lease file. The volume stays mounted until the last lease is
1346/// released. Returns a `LayersVolumeLease` whose `Drop` releases the lease.
1347///
1348/// On Linux: returns the `cache_dir/layers` path directly (no-op).
1349///
1350/// Called by `post_process_extraction` during first-time extraction and by
1351/// `pack_run` before launching the VM.
1352pub fn acquire_layers_lease(cache_dir: &Path, debug: bool) -> std::io::Result<LayersVolumeLease> {
1353    #[cfg(target_os = "macos")]
1354    {
1355        let image_path = cache_dir.join(CS_IMAGE_NAME);
1356        if image_path.exists() || has_layer_tars(cache_dir) {
1357            // Case-sensitive volume is required on macOS to preserve Linux
1358            // paths faithfully. Fail if it can't be acquired rather than
1359            // silently falling back to case-insensitive extraction.
1360            let path = acquire_lease(cache_dir, debug)?;
1361            return Ok(LayersVolumeLease {
1362                path,
1363                cache_dir: cache_dir.to_path_buf(),
1364            });
1365        }
1366    }
1367
1368    let _ = debug;
1369    Ok(LayersVolumeLease {
1370        path: cache_dir.join("layers"),
1371        #[cfg(target_os = "macos")]
1372        cache_dir: cache_dir.to_path_buf(),
1373    })
1374}
1375
1376/// Acquire a persistent daemon lease that survives process exit.
1377///
1378/// Unlike `acquire_layers_lease` (RAII, released on Drop), this creates a
1379/// lease file named `daemon` that persists until explicitly released by
1380/// `release_daemon_lease`. The daemon child PID is recorded in the file
1381/// so stale daemon leases can be garbage-collected.
1382///
1383/// On Linux this is a no-op that returns the layers path.
1384pub fn acquire_daemon_lease(
1385    cache_dir: &Path,
1386    daemon_pid: i32,
1387    debug: bool,
1388) -> std::io::Result<PathBuf> {
1389    #[cfg(target_os = "macos")]
1390    {
1391        let image_path = cache_dir.join(CS_IMAGE_NAME);
1392        if image_path.exists() || has_layer_tars(cache_dir) {
1393            let leases_dir = cache_dir.join(LEASES_DIR);
1394            fs::create_dir_all(&leases_dir)?;
1395            let lock = lock_leases(cache_dir)?;
1396            gc_stale_leases(&leases_dir);
1397            ensure_cs_volume_mounted(cache_dir, debug)?;
1398            fs::write(leases_dir.join("daemon"), format!("{}", daemon_pid))?;
1399            drop(lock);
1400            return Ok(cache_dir.join(CS_MOUNT_DIR));
1401        }
1402    }
1403
1404    let _ = (daemon_pid, debug);
1405    Ok(cache_dir.join("layers"))
1406}
1407
1408/// Release the persistent daemon lease and detach if no leases remain.
1409///
1410/// Called from `daemon_stop()` after the VM process has been terminated.
1411pub fn release_daemon_lease(cache_dir: &Path) {
1412    #[cfg(target_os = "macos")]
1413    {
1414        let leases_dir = cache_dir.join(LEASES_DIR);
1415        let daemon_lease = leases_dir.join("daemon");
1416        if !daemon_lease.exists() {
1417            return;
1418        }
1419
1420        let Ok(lock) = lock_leases(cache_dir) else {
1421            let _ = fs::remove_file(&daemon_lease);
1422            return;
1423        };
1424
1425        let _ = fs::remove_file(&daemon_lease);
1426        gc_stale_leases(&leases_dir);
1427        detach_if_unused(cache_dir);
1428        drop(lock);
1429    }
1430
1431    #[cfg(not(target_os = "macos"))]
1432    {
1433        let _ = cache_dir;
1434    }
1435}
1436
1437/// Check whether any active leases exist for this cache directory.
1438///
1439/// Used by `pack prune` to skip in-use caches. Garbage-collects stale
1440/// leases first (dead PIDs, dead daemon processes).
1441pub fn has_active_leases(cache_dir: &Path) -> bool {
1442    #[cfg(target_os = "macos")]
1443    {
1444        let leases_dir = cache_dir.join(LEASES_DIR);
1445        if !leases_dir.exists() {
1446            return false;
1447        }
1448
1449        let Ok(lock) = lock_leases(cache_dir) else {
1450            return false;
1451        };
1452        gc_stale_leases(&leases_dir);
1453        let active = count_leases(&leases_dir);
1454        drop(lock);
1455        active > 0
1456    }
1457
1458    #[cfg(not(target_os = "macos"))]
1459    {
1460        let _ = cache_dir;
1461        false
1462    }
1463}
1464
1465/// Force-detach and clean up all leases for a cache directory.
1466///
1467/// Used by `--force-extract` before clearing the cache. NOT used by normal
1468/// `pack prune` — prune should check `has_active_leases` first and skip
1469/// active caches.
1470pub fn force_detach_layers_volume(cache_dir: &Path) {
1471    // A fork clone's cache dir is a symlink to its golden's — the clone doesn't
1472    // own the volume or the leases behind it, so detaching through the link
1473    // would rip the layers out from under the frozen golden and its siblings.
1474    if cache_dir
1475        .symlink_metadata()
1476        .map(|m| m.file_type().is_symlink())
1477        .unwrap_or(false)
1478    {
1479        return;
1480    }
1481    #[cfg(target_os = "macos")]
1482    {
1483        let mount_point = cache_dir.join(CS_MOUNT_DIR);
1484        if mount_point.exists() && is_mount_point(&mount_point) {
1485            let _ = std::process::Command::new("hdiutil")
1486                .args(["detach", "-quiet", "-force"])
1487                .arg(&mount_point)
1488                .output();
1489        }
1490        // Remove all lease files.
1491        let _ = fs::remove_dir_all(cache_dir.join(LEASES_DIR));
1492    }
1493
1494    #[cfg(not(target_os = "macos"))]
1495    {
1496        let _ = cache_dir;
1497    }
1498}
1499
1500/// Mount the case-sensitive volume (if needed) and return the extraction
1501/// directory. Called during initial extraction (already under flock — no
1502/// lease needed). For runtime use, call `acquire_layers_lease()` instead.
1503fn extraction_layers_dir(cache_dir: &Path, debug: bool) -> std::io::Result<PathBuf> {
1504    #[cfg(target_os = "macos")]
1505    {
1506        ensure_cs_volume_mounted(cache_dir, debug)?;
1507        Ok(cache_dir.join(CS_MOUNT_DIR))
1508    }
1509
1510    #[cfg(not(target_os = "macos"))]
1511    {
1512        let _ = debug;
1513        Ok(cache_dir.join("layers"))
1514    }
1515}
1516
1517// --- macOS-only implementation details ---
1518
1519#[cfg(target_os = "macos")]
1520fn has_layer_tars(cache_dir: &Path) -> bool {
1521    let layers_dir = cache_dir.join("layers");
1522    layers_dir.exists()
1523        && fs::read_dir(&layers_dir)
1524            .ok()
1525            .map(|rd| {
1526                rd.filter_map(|e| e.ok())
1527                    .any(|e| e.path().extension().is_some_and(|ext| ext == "tar"))
1528            })
1529            .unwrap_or(false)
1530}
1531
1532/// Sum the sizes of all `.tar` files in a directory.
1533#[cfg(target_os = "macos")]
1534fn sum_tar_sizes(dir: &Path) -> u64 {
1535    let Ok(entries) = fs::read_dir(dir) else {
1536        return 0;
1537    };
1538    entries
1539        .filter_map(|e| e.ok())
1540        .filter(|e| e.path().extension().is_some_and(|ext| ext == "tar"))
1541        .filter_map(|e| e.metadata().ok())
1542        .map(|m| m.len())
1543        .sum()
1544}
1545
1546/// Check whether `path` is a mount point by comparing device IDs with parent.
1547#[cfg(target_os = "macos")]
1548fn is_mount_point(path: &Path) -> bool {
1549    use std::os::unix::fs::MetadataExt;
1550    let Ok(meta) = fs::metadata(path) else {
1551        return false;
1552    };
1553    let Ok(parent_meta) = fs::metadata(path.parent().unwrap_or(Path::new("/"))) else {
1554        return false;
1555    };
1556    meta.dev() != parent_meta.dev()
1557}
1558
1559/// Acquire a lease: lock → gc stale leases → ensure mounted → write lease.
1560#[cfg(target_os = "macos")]
1561fn acquire_lease(cache_dir: &Path, debug: bool) -> std::io::Result<PathBuf> {
1562    let mount_point = cache_dir.join(CS_MOUNT_DIR);
1563    let leases_dir = cache_dir.join(LEASES_DIR);
1564    fs::create_dir_all(&leases_dir)?;
1565
1566    let lock = lock_leases(cache_dir)?;
1567
1568    // Garbage-collect leases from dead processes.
1569    gc_stale_leases(&leases_dir);
1570
1571    // Reclaim volumes stranded by runs that were killed before releasing.
1572    reap_orphan_volumes(cache_dir);
1573
1574    // Ensure the sparse image exists and is mounted.
1575    ensure_cs_volume_mounted(cache_dir, debug)?;
1576
1577    // Write a lease file for this process.
1578    let lease_path = leases_dir.join(format!("{}", std::process::id()));
1579    fs::write(&lease_path, "")?;
1580
1581    drop(lock);
1582    Ok(mount_point)
1583}
1584
1585/// Release a lease: lock → remove lease → if no leases remain, detach.
1586#[cfg(target_os = "macos")]
1587fn release_lease(cache_dir: &Path) {
1588    let leases_dir = cache_dir.join(LEASES_DIR);
1589    let lease_path = leases_dir.join(format!("{}", std::process::id()));
1590
1591    let Ok(lock) = lock_leases(cache_dir) else {
1592        let _ = fs::remove_file(&lease_path);
1593        return;
1594    };
1595
1596    let _ = fs::remove_file(&lease_path);
1597    gc_stale_leases(&leases_dir);
1598    detach_if_unused(cache_dir);
1599    drop(lock);
1600}
1601
1602/// Remove lease files whose PID is no longer alive.
1603///
1604/// Handles both per-PID leases (named by PID number) and daemon leases
1605/// (named "daemon", containing the daemon PID as text content).
1606#[cfg(target_os = "macos")]
1607fn gc_stale_leases(leases_dir: &Path) {
1608    let Ok(entries) = fs::read_dir(leases_dir) else {
1609        return;
1610    };
1611    for entry in entries.filter_map(|e| e.ok()) {
1612        let name = entry.file_name();
1613        let name_str = name.to_string_lossy();
1614
1615        if name_str == "daemon" {
1616            // Daemon lease: PID is stored as file content.
1617            if let Ok(content) = fs::read_to_string(entry.path()) {
1618                if let Ok(pid) = content.trim().parse::<i32>() {
1619                    if unsafe { libc::kill(pid, 0) } != 0 {
1620                        let _ = fs::remove_file(entry.path());
1621                    }
1622                }
1623            }
1624        } else if let Ok(pid) = name_str.parse::<i32>() {
1625            // Per-PID lease: file name is the PID.
1626            if unsafe { libc::kill(pid, 0) } != 0 {
1627                let _ = fs::remove_file(entry.path());
1628            }
1629        }
1630    }
1631}
1632
1633/// Count active lease files in the leases directory.
1634#[cfg(target_os = "macos")]
1635fn count_leases(leases_dir: &Path) -> usize {
1636    fs::read_dir(leases_dir)
1637        .ok()
1638        .map(|rd| rd.filter_map(|e| e.ok()).count())
1639        .unwrap_or(0)
1640}
1641
1642/// Detach the case-sensitive volume if no leases remain.
1643#[cfg(target_os = "macos")]
1644fn detach_if_unused(cache_dir: &Path) {
1645    let leases_dir = cache_dir.join(LEASES_DIR);
1646    if count_leases(&leases_dir) == 0 {
1647        let mount_point = cache_dir.join(CS_MOUNT_DIR);
1648        if mount_point.exists() && is_mount_point(&mount_point) {
1649            let _ = std::process::Command::new("hdiutil")
1650                .args(["detach", "-quiet"])
1651                .arg(&mount_point)
1652                .output();
1653        }
1654    }
1655}
1656
1657/// The sibling cache directories that may hold a stranded volume.
1658///
1659/// Two layouts use this protocol: `<root>/<hash>` (standalone packs) and
1660/// `<root>/<hash>/pack` (VM-backed packs). Both are handled by walking up to the
1661/// directory that *contains the hashes* and re-applying our own suffix, so we
1662/// only ever look at peers of the same shape.
1663#[cfg(target_os = "macos")]
1664fn sibling_cache_dirs(cache_dir: &Path) -> Vec<PathBuf> {
1665    let nested = cache_dir.file_name().is_some_and(|n| n == "pack");
1666    let Some(root) = (if nested {
1667        cache_dir.parent().and_then(Path::parent)
1668    } else {
1669        cache_dir.parent()
1670    }) else {
1671        return Vec::new();
1672    };
1673    let Ok(entries) = fs::read_dir(root) else {
1674        return Vec::new();
1675    };
1676    entries
1677        .filter_map(|e| e.ok())
1678        .map(|e| {
1679            if nested {
1680                e.path().join("pack")
1681            } else {
1682                e.path()
1683            }
1684        })
1685        .filter(|p| p != cache_dir && p.is_dir())
1686        .collect()
1687}
1688
1689/// Detach case-sensitive volumes stranded by runs that never released.
1690///
1691/// A run that is killed (or crashes) skips [`release_lease`], leaving its volume
1692/// mounted with a lease file whose PID is dead. Its *own* directory self-heals —
1693/// the next run of that same artifact acquires, GCs the dead lease, and detaches
1694/// on release. What never heals is an artifact that is not run again: its volume
1695/// holds a `/dev/diskN` for the life of the login session, and enough of them
1696/// exhaust the device table. So the sweep is over *siblings*, not self.
1697///
1698/// Each peer is locked non-blocking: a directory whose lock is held has a live
1699/// process inside acquire/release, which by definition is not stranded, and
1700/// blocking on it would let an unrelated artifact stall this run.
1701#[cfg(target_os = "macos")]
1702fn reap_orphan_volumes(cache_dir: &Path) {
1703    for dir in sibling_cache_dirs(cache_dir) {
1704        let mount_point = dir.join(CS_MOUNT_DIR);
1705        if !mount_point.exists() || !is_mount_point(&mount_point) {
1706            continue;
1707        }
1708        let Ok(lock) = try_lock_leases(&dir) else {
1709            continue;
1710        };
1711        gc_stale_leases(&dir.join(LEASES_DIR));
1712        detach_if_unused(&dir);
1713        drop(lock);
1714    }
1715}
1716
1717/// Acquire the leases lock, or fail immediately if another process holds it.
1718#[cfg(target_os = "macos")]
1719fn try_lock_leases(cache_dir: &Path) -> std::io::Result<File> {
1720    let lock_path = cache_dir.join(LEASES_LOCK);
1721    let lock_file = fs::OpenOptions::new()
1722        .create(true)
1723        .write(true)
1724        .truncate(false)
1725        .open(&lock_path)?;
1726    let ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
1727    if ret != 0 {
1728        return Err(std::io::Error::last_os_error());
1729    }
1730    Ok(lock_file)
1731}
1732
1733/// Acquire the leases lock (flock-based, like extract_sidecar).
1734#[cfg(target_os = "macos")]
1735fn lock_leases(cache_dir: &Path) -> std::io::Result<File> {
1736    let lock_path = cache_dir.join(LEASES_LOCK);
1737    let lock_file = fs::OpenOptions::new()
1738        .create(true)
1739        .write(true)
1740        .truncate(false)
1741        .open(&lock_path)?;
1742    let ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) };
1743    if ret != 0 {
1744        return Err(std::io::Error::last_os_error());
1745    }
1746    Ok(lock_file)
1747}
1748
1749/// Create the sparse image if needed and mount it.
1750#[cfg(target_os = "macos")]
1751fn ensure_cs_volume_mounted(cache_dir: &Path, debug: bool) -> std::io::Result<()> {
1752    let image_path = cache_dir.join(CS_IMAGE_NAME);
1753    let mount_point = cache_dir.join(CS_MOUNT_DIR);
1754
1755    // Already mounted — nothing to do.
1756    if mount_point.exists() && is_mount_point(&mount_point) {
1757        return Ok(());
1758    }
1759
1760    // Create the sparse image if it doesn't exist.
1761    if !image_path.exists() {
1762        let layers_dir = cache_dir.join("layers");
1763        let total_tar_bytes = sum_tar_sizes(&layers_dir);
1764        // 2.5x headroom + 512 MiB for fs metadata, minimum 1 GiB.
1765        // Sparse format: only written bytes use real disk.
1766        let size_bytes = std::cmp::max(
1767            (total_tar_bytes as f64 * 2.5) as u64 + 512 * 1024 * 1024,
1768            1024 * 1024 * 1024,
1769        );
1770        let size_gib = size_bytes / (1024 * 1024 * 1024) + 1;
1771        let size_arg = format!("{}g", size_gib);
1772
1773        if debug {
1774            eprintln!(
1775                "debug: creating case-sensitive APFS sparse image ({}g from {} bytes of tars)...",
1776                size_gib, total_tar_bytes
1777            );
1778        }
1779        let output = std::process::Command::new("hdiutil")
1780            .args([
1781                "create",
1782                "-size",
1783                &size_arg,
1784                "-fs",
1785                "Case-sensitive APFS",
1786                "-type",
1787                "SPARSE",
1788                "-volname",
1789                "smolvm-layers",
1790            ])
1791            .arg(&image_path)
1792            .output()?;
1793        if !output.status.success() {
1794            return Err(std::io::Error::other(format!(
1795                "hdiutil create failed: {}",
1796                String::from_utf8_lossy(&output.stderr)
1797            )));
1798        }
1799    }
1800
1801    // Mount it.
1802    fs::create_dir_all(&mount_point)?;
1803    if debug {
1804        eprintln!(
1805            "debug: mounting case-sensitive volume at {}",
1806            mount_point.display()
1807        );
1808    }
1809    let output = std::process::Command::new("hdiutil")
1810        .args(["attach", "-mountpoint"])
1811        .arg(&mount_point)
1812        .args(["-nobrowse", "-noautoopen"])
1813        .arg(&image_path)
1814        .output()?;
1815    if !output.status.success() {
1816        return Err(std::io::Error::other(format!(
1817            "hdiutil attach failed: {}",
1818            String::from_utf8_lossy(&output.stderr)
1819        )));
1820    }
1821
1822    Ok(())
1823}
1824
1825/// Marker file indicating libs extraction is complete.
1826const LIBS_EXTRACTION_MARKER: &str = ".smolvm-libs-extracted";
1827
1828/// Extract runtime libraries from a packed stub binary.
1829///
1830/// Reads the last 32 bytes of the executable looking for a SMOLLIBS footer.
1831/// If found, extracts the compressed libs bundle to a cache directory and
1832/// returns the path to the `lib/` directory containing libkrun/libkrunfw.
1833///
1834/// Returns `None` if the binary has no embedded libs (e.g., the base smolvm binary).
1835pub fn extract_libs_from_binary(exe_path: &Path, debug: bool) -> std::io::Result<Option<PathBuf>> {
1836    use crate::format::{LibsFooter, LIBS_FOOTER_SIZE};
1837
1838    let mut file = File::open(exe_path)?;
1839    let file_size = file.metadata()?.len();
1840    if file_size < LIBS_FOOTER_SIZE as u64 {
1841        return Ok(None);
1842    }
1843
1844    // Read the last 32 bytes
1845    file.seek(SeekFrom::End(-(LIBS_FOOTER_SIZE as i64)))?;
1846    let mut footer_buf = [0u8; LIBS_FOOTER_SIZE];
1847    file.read_exact(&mut footer_buf)?;
1848
1849    let footer = match LibsFooter::from_bytes(&footer_buf) {
1850        Ok(f) => f,
1851        Err(_) => return Ok(None), // No SMOLLIBS footer — no embedded libs
1852    };
1853
1854    if debug {
1855        eprintln!(
1856            "debug: found SMOLLIBS footer: offset={}, size={}",
1857            footer.libs_offset, footer.libs_size
1858        );
1859    }
1860
1861    // Cache key based on libs content hash
1862    file.seek(SeekFrom::Start(footer.libs_offset))?;
1863    let mut hasher = crc32fast::Hasher::new();
1864    let mut remaining = footer.libs_size;
1865    let mut buf = [0u8; 64 * 1024];
1866    while remaining > 0 {
1867        let to_read = remaining.min(buf.len() as u64) as usize;
1868        let n = file.read(&mut buf[..to_read])?;
1869        if n == 0 {
1870            break;
1871        }
1872        hasher.update(&buf[..n]);
1873        remaining -= n as u64;
1874    }
1875    let libs_checksum = hasher.finalize();
1876
1877    let cache_base = dirs::cache_dir()
1878        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no cache directory"))?;
1879    let libs_cache_dir = cache_base
1880        .join("smolvm-libs")
1881        .join(format!("{:08x}", libs_checksum));
1882    let lib_dir = libs_cache_dir.join("lib");
1883
1884    // Acquire exclusive lock to prevent concurrent extraction races.
1885    if let Some(parent) = libs_cache_dir.parent() {
1886        fs::create_dir_all(parent)?;
1887    }
1888    let lock_path = libs_cache_dir.with_extension("lock");
1889    let lock_file = fs::OpenOptions::new()
1890        .create(true)
1891        .write(true)
1892        .truncate(false)
1893        .open(&lock_path)?;
1894
1895    lock_file_exclusive(&lock_file)?;
1896
1897    // Re-check after acquiring lock (another process may have finished)
1898    if libs_cache_dir.join(LIBS_EXTRACTION_MARKER).exists() {
1899        if debug {
1900            eprintln!("debug: libs already extracted at {}", lib_dir.display());
1901        }
1902        // Lock released on drop of lock_file
1903        let _ = lock_file;
1904        return Ok(Some(lib_dir));
1905    }
1906
1907    // Extract
1908    fs::create_dir_all(&libs_cache_dir)?;
1909    file.seek(SeekFrom::Start(footer.libs_offset))?;
1910    let limited_reader = (&mut file).take(footer.libs_size);
1911    let decoder = zstd::stream::Decoder::new(limited_reader)
1912        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1913    let mut archive = tar::Archive::new(decoder);
1914    safe_unpack(&mut archive, &libs_cache_dir)?;
1915
1916    // Make libs executable
1917    if lib_dir.exists() {
1918        #[cfg(unix)]
1919        {
1920            use std::os::unix::fs::PermissionsExt;
1921            for entry in fs::read_dir(&lib_dir)? {
1922                let entry = entry?;
1923                if entry.path().is_file() {
1924                    let mut perms = fs::metadata(entry.path())?.permissions();
1925                    perms.set_mode(0o755);
1926                    fs::set_permissions(entry.path(), perms)?;
1927                }
1928            }
1929        }
1930    }
1931
1932    fs::write(libs_cache_dir.join(LIBS_EXTRACTION_MARKER), "")?;
1933    // Lock released on drop of lock_file
1934    let _ = lock_file;
1935
1936    if debug {
1937        eprintln!("debug: extracted libs to {}", lib_dir.display());
1938    }
1939
1940    Ok(Some(lib_dir))
1941}
1942
1943/// Copy `src` to `dst` while preserving holes (sparseness), regardless of the
1944/// platform or filesystem.
1945///
1946/// `std::fs::copy` is *not* reliably hole-preserving. On Linux it prefers
1947/// `copy_file_range`/`sendfile`, but those fall back to a dense byte-for-byte
1948/// copy when the source and destination live on different mounts or on a
1949/// filesystem where the accelerated path isn't available — both common in CI
1950/// containers and under overlayfs. When that happens, a multi-GiB sparse
1951/// template (e.g. the 20 GiB `storage-template.ext4`, only ~25 MiB of which is
1952/// real data) is rehydrated into its full logical size of literal zeros on
1953/// disk, so two extractions can exhaust the runner and fail with ENOSPC.
1954///
1955/// This copy creates the destination as a sparse skeleton (`set_len` to the
1956/// source's logical size) and then writes only the regions that hold real data,
1957/// leaving every zero run as a hole. It mirrors the write-side
1958/// `assets::sparse_copy_overlay`, so behavior is consistent on both ends and on
1959/// APFS/ext4/xfs/NTFS alike.
1960///
1961/// The holes are located by asking the filesystem (`SEEK_DATA`/`SEEK_HOLE`)
1962/// rather than by reading across them. Reading a hole costs no *disk* I/O — the
1963/// kernel serves zero pages — but it still costs a syscall and a memory scan per
1964/// chunk, and that is not free at scale: the 20 GiB `storage-template.ext4`
1965/// holds ~9 MiB of real data, so a linear scan performed 40,960 read-and-compare
1966/// iterations to find it and took ~11 s, which landed on the critical path of
1967/// every packed `run`. Seeking straight between data extents reduces that to a
1968/// handful of syscalls.
1969///
1970/// Used on both ends: the extract/run side here, and the pack-create side in
1971/// `assets::create_storage_template` when it copies the pre-formatted
1972/// `storage-template.ext4` into the staging directory.
1973pub(crate) fn sparse_copy(src: &Path, dst: &Path) -> std::io::Result<()> {
1974    let mut src_file = File::open(src)?;
1975    let size = src_file.metadata()?.len();
1976
1977    let mut dst_file = File::create(dst)?;
1978    // On Windows/NTFS a fresh file is dense: set_len-ing to a large size and then
1979    // writing chunks at high offsets would allocate the whole gap. Mark it sparse
1980    // first. Unix filesystems are sparse by default.
1981    #[cfg(windows)]
1982    mark_file_sparse(&dst_file)?;
1983    dst_file.set_len(size)?;
1984
1985    // Fast path: copy only the extents the filesystem reports as data. Falls
1986    // through to the scan below on any filesystem that does not implement the
1987    // SEEK_DATA/SEEK_HOLE extension.
1988    #[cfg(unix)]
1989    if copy_data_extents(&mut src_file, &mut dst_file, size)? {
1990        return Ok(());
1991    }
1992
1993    // Portable fallback: read in 512 KiB chunks, writing only chunks that contain
1994    // a non-zero byte. Zero chunks are skipped, so they stay as holes in `dst`.
1995    src_file.seek(SeekFrom::Start(0))?;
1996    let mut buf = vec![0u8; 512 * 1024];
1997    let mut offset: u64 = 0;
1998    while offset < size {
1999        let to_read = (size - offset).min(buf.len() as u64) as usize;
2000        let n = src_file.read(&mut buf[..to_read])?;
2001        if n == 0 {
2002            break;
2003        }
2004        let chunk = &buf[..n];
2005        if chunk.iter().any(|&b| b != 0) {
2006            dst_file.seek(SeekFrom::Start(offset))?;
2007            dst_file.write_all(chunk)?;
2008        }
2009        offset += n as u64;
2010    }
2011
2012    Ok(())
2013}
2014
2015/// Copy just the data extents of `src` into `dst`, skipping holes outright.
2016///
2017/// Returns `Ok(false)` — having written nothing — when the filesystem does not
2018/// support `SEEK_DATA`, so the caller can fall back to scanning. Support is
2019/// per-filesystem rather than per-OS (APFS, ext4, xfs and btrfs have it; some
2020/// network and FUSE filesystems do not), which is why this is detected at run
2021/// time instead of by `cfg`.
2022#[cfg(unix)]
2023fn copy_data_extents(src: &mut File, dst: &mut File, size: u64) -> std::io::Result<bool> {
2024    use std::os::unix::io::AsRawFd;
2025
2026    let fd = src.as_raw_fd();
2027    let seek = |from: u64, whence: libc::c_int| -> Option<i64> {
2028        let r = unsafe { libc::lseek(fd, from as libc::off_t, whence) };
2029        if r < 0 {
2030            None
2031        } else {
2032            Some(r as i64)
2033        }
2034    };
2035
2036    // Probe before writing anything: an unsupported filesystem must leave `dst`
2037    // untouched so the fallback starts from a clean slate. ENXIO means "no data
2038    // at or after this offset", which for offset 0 is a legitimately empty file.
2039    if seek(0, libc::SEEK_DATA).is_none() {
2040        let e = std::io::Error::last_os_error();
2041        if e.raw_os_error() == Some(libc::ENXIO) {
2042            return Ok(true); // entirely holes — the skeleton is already correct
2043        }
2044        return Ok(false);
2045    }
2046
2047    let mut buf = vec![0u8; 512 * 1024];
2048    let mut offset: u64 = 0;
2049    while offset < size {
2050        // Start of the next region containing data.
2051        let Some(data_start) = seek(offset, libc::SEEK_DATA) else {
2052            break; // ENXIO: only holes remain
2053        };
2054        let data_start = data_start as u64;
2055        if data_start >= size {
2056            break;
2057        }
2058        // Where that region ends. A file always has an implicit hole at EOF, so
2059        // this resolves even for a trailing extent; clamp anyway for safety.
2060        let data_end = seek(data_start, libc::SEEK_HOLE)
2061            .map(|v| (v as u64).min(size))
2062            .unwrap_or(size);
2063
2064        src.seek(SeekFrom::Start(data_start))?;
2065        dst.seek(SeekFrom::Start(data_start))?;
2066        let mut remaining = data_end.saturating_sub(data_start);
2067        while remaining > 0 {
2068            let want = remaining.min(buf.len() as u64) as usize;
2069            let n = src.read(&mut buf[..want])?;
2070            if n == 0 {
2071                break;
2072            }
2073            // An extent may be allocated yet zero-filled; keeping the zero test
2074            // means such regions stay holes in `dst` exactly as before.
2075            let chunk = &buf[..n];
2076            if chunk.iter().any(|&b| b != 0) {
2077                dst.write_all(chunk)?;
2078            } else {
2079                dst.seek(SeekFrom::Current(n as i64))?;
2080            }
2081            remaining -= n as u64;
2082        }
2083        offset = data_end.max(data_start + 1);
2084    }
2085
2086    Ok(true)
2087}
2088
2089/// Create a storage disk file (empty sparse file).
2090pub fn create_storage_disk(path: &Path, size: u64) -> std::io::Result<()> {
2091    let file = File::create(path)?;
2092    // On Windows/NTFS, File::create makes a dense file; set_len to a multi-GiB
2093    // size would allocate every block. Mark it sparse first.
2094    #[cfg(windows)]
2095    mark_file_sparse(&file)?;
2096    file.set_len(size)?;
2097    Ok(())
2098}
2099
2100/// Copy overlay disk template from cache to a runtime directory.
2101///
2102/// Copies the overlay template to `dest`, then restores the full sparse
2103/// skeleton if `overlay_logical_size` is set (new packs store a truncated
2104/// copy with the trailing hole stripped), and optionally extends further
2105/// when `size_gb_override` is larger still.
2106///
2107/// Returns an error if the template path is `None` or the template file
2108/// does not exist in the cache.
2109pub fn copy_overlay_template(
2110    cache_dir: &Path,
2111    template_path: Option<&str>,
2112    dest: &Path,
2113    size_gb_override: Option<u64>,
2114    overlay_logical_size: Option<u64>,
2115) -> std::io::Result<()> {
2116    let template = template_path.ok_or_else(|| {
2117        std::io::Error::new(
2118            std::io::ErrorKind::NotFound,
2119            "overlay template not specified in manifest",
2120        )
2121    })?;
2122
2123    let src = resolve_cache_asset_path(cache_dir, template, "overlay template")?;
2124    if !src.exists() {
2125        return Err(std::io::Error::new(
2126            std::io::ErrorKind::NotFound,
2127            format!("overlay template not found: {}", src.display()),
2128        ));
2129    }
2130
2131    // Hole-preserving copy: fs::copy can densify a sparse template on some
2132    // Linux filesystems/mounts, ballooning the overlay to its full logical size.
2133    sparse_copy(&src, dest)?;
2134
2135    // Determine target size: max of the copied size, overlay_logical_size
2136    // (original sparse extent before trailing-hole truncation), and
2137    // size_gb_override (user-requested larger disk).  A single ftruncate
2138    // handles all three cases; ftruncate is instant and allocates no disk
2139    // blocks for the extended region.
2140    let copied_size = fs::metadata(dest)?.len();
2141    let target = [
2142        Some(copied_size),
2143        overlay_logical_size,
2144        size_gb_override.map(|gb| gb * 1024 * 1024 * 1024),
2145    ]
2146    .into_iter()
2147    .flatten()
2148    .max()
2149    .unwrap_or(copied_size);
2150
2151    if target > copied_size {
2152        let file = fs::OpenOptions::new().write(true).open(dest)?;
2153        // On Windows/NTFS, extending with set_len would allocate every byte of
2154        // the gap unless the file is sparse. Mark it sparse first (idempotent).
2155        #[cfg(windows)]
2156        mark_file_sparse(&file)?;
2157        file.set_len(target)?;
2158    }
2159
2160    Ok(())
2161}
2162
2163/// Create or copy storage disk from template.
2164///
2165/// If a pre-formatted template exists in the cache, copy it.
2166/// Otherwise, create an empty sparse file (will be formatted by agent on first boot).
2167///
2168/// `size_gb_override` lets callers specify a custom disk size (in GiB).
2169/// When `None`, falls back to 512 MiB.
2170pub fn create_or_copy_storage_disk(
2171    cache_dir: &Path,
2172    template_path: Option<&str>,
2173    storage_path: &Path,
2174    size_gb_override: Option<u64>,
2175) -> std::io::Result<()> {
2176    if let Some(template) = template_path {
2177        let template_path = resolve_cache_asset_path(cache_dir, template, "storage template")?;
2178        if template_path.exists() {
2179            // Hole-preserving copy: a plain fs::copy densifies the (mostly-empty)
2180            // multi-GiB storage template on some Linux filesystems/mounts,
2181            // turning ~25 MiB of real data into its full logical size of zeros on
2182            // disk and risking ENOSPC when several extractions run.
2183            sparse_copy(&template_path, storage_path)?;
2184            // If a custom size was requested and it's larger than the template,
2185            // extend the sparse file (resize2fs in the agent will expand the FS).
2186            if let Some(gb) = size_gb_override {
2187                let desired = gb * 1024 * 1024 * 1024;
2188                let current = fs::metadata(storage_path)?.len();
2189                if desired > current {
2190                    let file = fs::OpenOptions::new().write(true).open(storage_path)?;
2191                    // On Windows/NTFS, extending with set_len would allocate the
2192                    // whole gap unless the file is sparse. Mark sparse first
2193                    // (idempotent).
2194                    #[cfg(windows)]
2195                    mark_file_sparse(&file)?;
2196                    file.set_len(desired)?;
2197                }
2198            }
2199            return Ok(());
2200        }
2201    }
2202    // Fallback: create empty sparse file (agent will format on first boot)
2203    let size = match size_gb_override {
2204        Some(gb) => gb * 1024 * 1024 * 1024,
2205        None => 512 * 1024 * 1024,
2206    };
2207    create_storage_disk(storage_path, size)
2208}
2209
2210#[cfg(test)]
2211mod tests {
2212    use super::*;
2213
2214    /// Build a single-file tar archive in memory with the given name and data.
2215    fn make_tar(name: &str, data: &[u8]) -> Vec<u8> {
2216        let mut builder = tar::Builder::new(Vec::new());
2217        let mut header = tar::Header::new_gnu();
2218        header.set_size(data.len() as u64);
2219        header.set_mode(0o644);
2220        header.set_cksum();
2221        builder.append_data(&mut header, name, data).unwrap();
2222        builder.into_inner().unwrap()
2223    }
2224
2225    #[cfg(unix)]
2226    #[test]
2227    fn test_unpack_sparse_rejects_symlink_at_destination() {
2228        use std::os::unix::fs::symlink;
2229
2230        let temp_dir = tempfile::tempdir().unwrap();
2231        let outside = temp_dir.path().join("outside.bin");
2232        let dest = temp_dir.path().join("overlay.raw");
2233
2234        fs::write(&outside, b"untouched").unwrap();
2235        symlink(&outside, &dest).unwrap(); // dest is now a symlink → outside
2236
2237        let data = vec![0xFFu8; 512];
2238        let tar_bytes = make_tar("overlay.raw", &data);
2239        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2240        let mut entry = archive.entries().unwrap().next().unwrap().unwrap();
2241
2242        let real_dest = temp_dir.path().canonicalize().unwrap();
2243        let result = unpack_sparse(&mut entry, &dest, data.len() as u64, 0o644, &real_dest);
2244
2245        assert!(result.is_err(), "should reject symlink at destination");
2246        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2247        // The symlink target must not be modified
2248        assert_eq!(fs::read(&outside).unwrap(), b"untouched");
2249    }
2250
2251    /// Build a tar archive carrying a single symlink entry whose link target
2252    /// is `link_target`, plus (optionally) a trailing regular-file entry.
2253    fn make_symlink_tar(name: &str, link_target: &str) -> Vec<u8> {
2254        let mut builder = tar::Builder::new(Vec::new());
2255        let mut header = tar::Header::new_gnu();
2256        header.set_entry_type(tar::EntryType::Symlink);
2257        header.set_size(0);
2258        header.set_mode(0o777);
2259        builder.append_link(&mut header, name, link_target).unwrap();
2260        builder.into_inner().unwrap()
2261    }
2262
2263    #[cfg(unix)]
2264    #[test]
2265    fn test_safe_unpack_rejects_symlink_entry_escaping_dest_relative() {
2266        // A crafted tar entry `evil -> ../../outside.bin` must be rejected by
2267        // safe_unpack before it is materialized: otherwise a later write
2268        // through `evil` would land outside the extraction directory.
2269        let temp_dir = tempfile::tempdir().unwrap();
2270        let outside = temp_dir.path().join("outside.bin");
2271        fs::write(&outside, b"untouched").unwrap();
2272
2273        let dest = temp_dir.path().join("dest");
2274        fs::create_dir(&dest).unwrap();
2275
2276        let tar_bytes = make_symlink_tar("evil", "../../outside.bin");
2277        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2278        let result = safe_unpack(&mut archive, &dest);
2279
2280        assert!(
2281            result.is_err(),
2282            "escaping relative symlink must be rejected"
2283        );
2284        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2285        assert!(!dest.join("evil").exists(), "symlink must not be created");
2286        assert_eq!(fs::read(&outside).unwrap(), b"untouched");
2287    }
2288
2289    #[cfg(unix)]
2290    #[test]
2291    fn test_safe_unpack_rejects_symlink_entry_escaping_dest_absolute() {
2292        // An absolute-target symlink `evil -> /etc/passwd` is jailed to
2293        // `dest/etc/passwd`, staying inside dest, so it is allowed. But an
2294        // absolute target with `..` that climbs out (`/../escape`) must be
2295        // rejected — this guards the absolute-symlink jailing branch.
2296        let temp_dir = tempfile::tempdir().unwrap();
2297        let dest = temp_dir.path().join("dest");
2298        fs::create_dir(&dest).unwrap();
2299
2300        let tar_bytes = make_symlink_tar("evil", "/../../escape");
2301        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2302        let result = safe_unpack(&mut archive, &dest);
2303
2304        assert!(
2305            result.is_err(),
2306            "escaping absolute symlink must be rejected"
2307        );
2308        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2309        assert!(!dest.join("evil").exists(), "symlink must not be created");
2310    }
2311
2312    #[cfg(unix)]
2313    #[test]
2314    fn test_safe_unpack_allows_in_dest_symlink() {
2315        // A well-behaved relative symlink that stays within dest is accepted.
2316        let temp_dir = tempfile::tempdir().unwrap();
2317        let dest = temp_dir.path().join("dest");
2318        fs::create_dir(&dest).unwrap();
2319
2320        let tar_bytes = make_symlink_tar("link", "target");
2321        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2322        safe_unpack(&mut archive, &dest).unwrap();
2323
2324        let meta = fs::symlink_metadata(dest.join("link")).unwrap();
2325        assert!(
2326            meta.file_type().is_symlink(),
2327            "in-dest symlink should exist"
2328        );
2329    }
2330
2331    // ---------------------------------------------------------------------
2332    // Fix 1 (PRIMARY): the sparse-write path must NOT follow a symlink parent
2333    // component out of `dest`. Reproduces the host-escape: a prior entry plants
2334    // `foo -> <abs path OUTSIDE dest>`, then a sparse regular file under
2335    // `foo/...` tries to redirect the write through it. `safe_unpack` must
2336    // error AND write nothing at the escape target.
2337    // ---------------------------------------------------------------------
2338    #[cfg(unix)]
2339    #[test]
2340    fn test_safe_unpack_sparse_symlink_parent_escape_blocked() {
2341        use std::os::unix::fs::symlink;
2342
2343        let temp_dir = tempfile::tempdir().unwrap();
2344
2345        // Sentinel directory OUTSIDE dest (sibling under the temp dir). If the
2346        // escape worked, the payload would land here.
2347        let sentinel = temp_dir.path().join("ESCAPE");
2348        fs::create_dir(&sentinel).unwrap();
2349
2350        let dest = temp_dir.path().join("dest");
2351        fs::create_dir(&dest).unwrap();
2352
2353        // Plant the escaping symlink exactly as a prior tar entry would have:
2354        // `dest/foo` -> the sentinel dir outside dest. (Pre-planting instead of
2355        // relying on the tar crate's symlink-creation semantics keeps the test
2356        // deterministic; it reproduces the same on-disk state.)
2357        symlink(&sentinel, dest.join("foo")).unwrap();
2358
2359        // A sparse regular file under `foo/...`: create_dir_all + open would
2360        // traverse the symlink parent and write into the sentinel. Small payload
2361        // + low sparse threshold forces the `unpack_sparse` path.
2362        let payload = vec![0xABu8; 4096];
2363        let tar_bytes = make_tar("foo/pwned.bin", &payload);
2364
2365        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2366        let limits = SafeUnpackLimits {
2367            max_entries: 1_000,
2368            max_total_bytes: 1 << 30,
2369            sparse_threshold: 512, // 4096-byte payload takes the sparse path
2370        };
2371        let result = safe_unpack_with_limits(&mut archive, &dest, &limits);
2372
2373        assert!(
2374            result.is_err(),
2375            "symlink-parent escape via the sparse path must be rejected"
2376        );
2377        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2378        // Nothing may have been written through the escaping symlink.
2379        assert!(
2380            !sentinel.join("pwned.bin").exists(),
2381            "host file was written OUTSIDE dest — escape not blocked"
2382        );
2383    }
2384
2385    // ---------------------------------------------------------------------
2386    // Fix 1b (root cause): an absolute symlink that aliases the dest ROOT
2387    // itself (`foo -> /`) is the parent-escape primitive and must be rejected
2388    // at creation — it must never appear on disk.
2389    // ---------------------------------------------------------------------
2390    #[cfg(unix)]
2391    #[test]
2392    fn test_safe_unpack_rejects_absolute_symlink_aliasing_root() {
2393        let temp_dir = tempfile::tempdir().unwrap();
2394        let dest = temp_dir.path().join("dest");
2395        fs::create_dir(&dest).unwrap();
2396
2397        let tar_bytes = make_symlink_tar("foo", "/");
2398        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2399        let result = safe_unpack(&mut archive, &dest);
2400
2401        assert!(
2402            result.is_err(),
2403            "absolute symlink aliasing dest root must be rejected"
2404        );
2405        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
2406        assert!(
2407            !dest.join("foo").exists() && fs::symlink_metadata(dest.join("foo")).is_err(),
2408            "root-aliasing symlink must never be created on disk"
2409        );
2410    }
2411
2412    // ---------------------------------------------------------------------
2413    // Fix 1 (G): the sparse path must strip setuid/setgid/sticky bits, matching
2414    // the dense path — a hostile header must not yield a setuid host file.
2415    // ---------------------------------------------------------------------
2416    #[cfg(unix)]
2417    #[test]
2418    fn test_unpack_sparse_strips_setuid() {
2419        use std::os::unix::fs::PermissionsExt;
2420
2421        let temp_dir = tempfile::tempdir().unwrap();
2422        let dest = temp_dir.path().join("suid.bin");
2423        let real_dest = temp_dir.path().canonicalize().unwrap();
2424
2425        let data = vec![0x11u8; 4096];
2426        let tar_bytes = make_tar("suid.bin", &data);
2427        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2428        let mut entry = archive.entries().unwrap().next().unwrap().unwrap();
2429
2430        // Header mode carries setuid (0o4000) + setgid (0o2000) + rwxr-xr-x.
2431        unpack_sparse(&mut entry, &dest, data.len() as u64, 0o6755, &real_dest).unwrap();
2432
2433        let mode = fs::metadata(&dest).unwrap().permissions().mode() & 0o7777;
2434        assert_eq!(
2435            mode, 0o0755,
2436            "setuid/setgid/sticky bits must be stripped on the sparse path"
2437        );
2438    }
2439
2440    // ---------------------------------------------------------------------
2441    // Fix 3: an archive exceeding the entry-count ceiling is rejected.
2442    // ---------------------------------------------------------------------
2443    #[test]
2444    fn test_safe_unpack_rejects_too_many_entries() {
2445        let temp_dir = tempfile::tempdir().unwrap();
2446        let dest = temp_dir.path().join("dest");
2447        fs::create_dir(&dest).unwrap();
2448
2449        let mut builder = tar::Builder::new(Vec::new());
2450        for i in 0..10 {
2451            let data = b"x";
2452            let mut h = tar::Header::new_gnu();
2453            h.set_size(data.len() as u64);
2454            h.set_mode(0o644);
2455            builder
2456                .append_data(&mut h, format!("file{i}.txt"), &data[..])
2457                .unwrap();
2458        }
2459        let tar_bytes = builder.into_inner().unwrap();
2460
2461        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2462        let limits = SafeUnpackLimits {
2463            max_entries: 3,
2464            max_total_bytes: 1 << 30,
2465            sparse_threshold: SPARSE_WRITE_THRESHOLD,
2466        };
2467        let err = safe_unpack_with_limits(&mut archive, &dest, &limits).unwrap_err();
2468        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2469        assert!(err.to_string().contains("max entry count"));
2470    }
2471
2472    // ---------------------------------------------------------------------
2473    // Fix 3: an archive exceeding the total-bytes ceiling is rejected.
2474    // ---------------------------------------------------------------------
2475    #[test]
2476    fn test_safe_unpack_rejects_total_bytes_over_cap() {
2477        let temp_dir = tempfile::tempdir().unwrap();
2478        let dest = temp_dir.path().join("dest");
2479        fs::create_dir(&dest).unwrap();
2480
2481        let data = vec![0u8; 4096];
2482        let tar_bytes = make_tar("big.bin", &data);
2483
2484        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2485        let limits = SafeUnpackLimits {
2486            max_entries: 1_000,
2487            max_total_bytes: 1024, // 4096-byte entry exceeds this
2488            sparse_threshold: SPARSE_WRITE_THRESHOLD,
2489        };
2490        let err = safe_unpack_with_limits(&mut archive, &dest, &limits).unwrap_err();
2491        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
2492        assert!(err.to_string().contains("max total size"));
2493    }
2494
2495    #[test]
2496    fn test_unpack_sparse_preserves_data_integrity() {
2497        let temp_dir = tempfile::tempdir().unwrap();
2498        let dest = temp_dir.path().join("data.raw");
2499
2500        // Alternating 64 KiB zero and non-zero blocks: covers the skip-zero
2501        // path, the write-nonzero path, correct seek offsets, and the
2502        // ftruncate skeleton giving the right final size.
2503        let block = 64 * 1024;
2504        let mut data = vec![0u8; 8 * block];
2505        for i in (0..8).step_by(2) {
2506            data[i * block..(i + 1) * block].fill(0xFF);
2507        }
2508
2509        let tar_bytes = make_tar("data.raw", &data);
2510        let mut archive = tar::Archive::new(tar_bytes.as_slice());
2511        let mut entry = archive.entries().unwrap().next().unwrap().unwrap();
2512
2513        let real_dest = temp_dir.path().canonicalize().unwrap();
2514        unpack_sparse(&mut entry, &dest, data.len() as u64, 0o644, &real_dest).unwrap();
2515
2516        assert_eq!(fs::read(&dest).unwrap(), data);
2517    }
2518
2519    #[test]
2520    fn test_cache_dir_format() {
2521        let dir = get_cache_dir(0xDEADBEEF).unwrap();
2522        assert!(dir.to_string_lossy().contains("deadbeef"));
2523    }
2524
2525    #[test]
2526    fn test_is_extracted() {
2527        let temp_dir = tempfile::tempdir().unwrap();
2528
2529        assert!(!is_extracted(temp_dir.path()));
2530
2531        fs::write(temp_dir.path().join(EXTRACTION_MARKER), "").unwrap();
2532        assert!(is_extracted(temp_dir.path()));
2533    }
2534
2535    #[test]
2536    fn test_is_extracted_partial() {
2537        let temp_dir = tempfile::tempdir().unwrap();
2538
2539        // Simulate partial extraction - files exist but no marker
2540        fs::create_dir_all(temp_dir.path().join("lib")).unwrap();
2541        fs::write(temp_dir.path().join("lib/libkrun.dylib"), "partial").unwrap();
2542
2543        assert!(!is_extracted(temp_dir.path()));
2544    }
2545
2546    #[test]
2547    fn test_sidecar_path_for() {
2548        let exe = Path::new("/path/to/my-app");
2549        let sidecar = sidecar_path_for(exe);
2550        assert_eq!(sidecar, PathBuf::from("/path/to/my-app.smolmachine"));
2551    }
2552
2553    #[test]
2554    fn test_sidecar_mode_detection() {
2555        let sidecar_footer = PackFooter {
2556            stub_size: 0,
2557            assets_offset: 0,
2558            assets_size: 1000,
2559            manifest_offset: 1000,
2560            manifest_size: 500,
2561            checksum: 0x12345678,
2562        };
2563        assert!(is_sidecar_mode(&sidecar_footer));
2564
2565        let embedded_footer = PackFooter {
2566            stub_size: 50000,
2567            assets_offset: 50000,
2568            assets_size: 1000,
2569            manifest_offset: 51000,
2570            manifest_size: 500,
2571            checksum: 0x12345678,
2572        };
2573        assert!(!is_sidecar_mode(&embedded_footer));
2574    }
2575
2576    #[test]
2577    fn test_create_storage_disk() {
2578        let temp_dir = tempfile::tempdir().unwrap();
2579        let disk_path = temp_dir.path().join("test.ext4");
2580
2581        create_storage_disk(&disk_path, 1024 * 1024).unwrap();
2582
2583        assert!(disk_path.exists());
2584        assert_eq!(fs::metadata(&disk_path).unwrap().len(), 1024 * 1024);
2585    }
2586
2587    #[test]
2588    fn test_copy_overlay_template_fails_when_none() {
2589        let temp_dir = tempfile::tempdir().unwrap();
2590        let dest = temp_dir.path().join("overlay.raw");
2591
2592        let result = copy_overlay_template(temp_dir.path(), None, &dest, None, None);
2593        assert!(result.is_err());
2594        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
2595    }
2596
2597    #[test]
2598    fn test_copy_overlay_template_fails_when_missing() {
2599        let temp_dir = tempfile::tempdir().unwrap();
2600        let dest = temp_dir.path().join("overlay.raw");
2601
2602        let result =
2603            copy_overlay_template(temp_dir.path(), Some("nonexistent.raw"), &dest, None, None);
2604        assert!(result.is_err());
2605        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound);
2606    }
2607
2608    #[test]
2609    fn test_copy_overlay_template_copies_and_extends() {
2610        let temp_dir = tempfile::tempdir().unwrap();
2611        let template = temp_dir.path().join("overlay.raw");
2612        let dest = temp_dir.path().join("output.raw");
2613
2614        // Create a small template file (1 KB)
2615        let template_data = vec![0u8; 1024];
2616        fs::write(&template, &template_data).unwrap();
2617
2618        // Copy without any size override or logical size
2619        copy_overlay_template(temp_dir.path(), Some("overlay.raw"), &dest, None, None).unwrap();
2620        assert_eq!(fs::metadata(&dest).unwrap().len(), 1024);
2621
2622        // Copy with overlay_logical_size set — dest should be extended
2623        let dest2 = temp_dir.path().join("output2.raw");
2624        copy_overlay_template(
2625            temp_dir.path(),
2626            Some("overlay.raw"),
2627            &dest2,
2628            None,
2629            Some(4096),
2630        )
2631        .unwrap();
2632        assert_eq!(fs::metadata(&dest2).unwrap().len(), 4096);
2633    }
2634
2635    #[test]
2636    fn test_copy_overlay_template_size_gb_takes_max() {
2637        let temp_dir = tempfile::tempdir().unwrap();
2638        let template = temp_dir.path().join("overlay.raw");
2639        fs::write(&template, vec![0u8; 1024]).unwrap();
2640
2641        // size_gb_override wins when larger than overlay_logical_size
2642        let dest = temp_dir.path().join("out_a.raw");
2643        copy_overlay_template(
2644            temp_dir.path(),
2645            Some("overlay.raw"),
2646            &dest,
2647            Some(1), // 1 GiB
2648            Some(4096),
2649        )
2650        .unwrap();
2651        assert_eq!(fs::metadata(&dest).unwrap().len(), 1024 * 1024 * 1024);
2652
2653        // overlay_logical_size wins when larger than size_gb_override
2654        let dest2 = temp_dir.path().join("out_b.raw");
2655        copy_overlay_template(
2656            temp_dir.path(),
2657            Some("overlay.raw"),
2658            &dest2,
2659            None,
2660            Some(8192), // overlay_logical_size bigger than template but smaller than size_gb_override test above
2661        )
2662        .unwrap();
2663        assert_eq!(fs::metadata(&dest2).unwrap().len(), 8192);
2664    }
2665
2666    #[test]
2667    fn test_copy_overlay_template_rejects_traversal_path() {
2668        let temp_dir = tempfile::tempdir().unwrap();
2669        let outside = temp_dir.path().join("outside.raw");
2670        let dest = temp_dir.path().join("overlay.raw");
2671        fs::write(&outside, b"x").unwrap();
2672
2673        let result =
2674            copy_overlay_template(temp_dir.path(), Some("../outside.raw"), &dest, None, None);
2675        assert!(result.is_err());
2676        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
2677    }
2678
2679    #[cfg(unix)]
2680    #[test]
2681    fn test_create_or_copy_storage_disk_rejects_symlink_escape() {
2682        use std::os::unix::fs::symlink;
2683
2684        let temp_dir = tempfile::tempdir().unwrap();
2685        let outside_dir = tempfile::tempdir().unwrap();
2686        let outside_file = outside_dir.path().join("storage-template.ext4");
2687        fs::write(&outside_file, b"template").unwrap();
2688
2689        symlink(outside_dir.path(), temp_dir.path().join("symlink-out")).unwrap();
2690
2691        let storage_path = temp_dir.path().join("storage.ext4");
2692        let result = create_or_copy_storage_disk(
2693            temp_dir.path(),
2694            Some("symlink-out/storage-template.ext4"),
2695            &storage_path,
2696            None,
2697        );
2698        assert!(result.is_err());
2699        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
2700    }
2701
2702    #[cfg(unix)]
2703    #[test]
2704    fn test_create_or_copy_storage_disk_preserves_sparseness() {
2705        use std::os::unix::fs::MetadataExt;
2706
2707        // Build a sparse template: a small run of real data at the front, then a
2708        // large trailing hole (the shape of the real storage-template.ext4).
2709        let cache_dir = tempfile::tempdir().unwrap();
2710        let template = cache_dir.path().join("storage-template.ext4");
2711        {
2712            let mut f = File::create(&template).unwrap();
2713            // A few real (non-zero) bytes at the front...
2714            f.write_all(b"real ext4 superblock stand-in").unwrap();
2715            // ...then 1 GiB logical size, so the rest is a trailing hole.
2716            f.set_len(1024 * 1024 * 1024).unwrap();
2717        }
2718
2719        let dest = cache_dir.path().join("storage.ext4");
2720        create_or_copy_storage_disk(cache_dir.path(), Some("storage-template.ext4"), &dest, None)
2721            .unwrap();
2722
2723        let meta = fs::metadata(&dest).unwrap();
2724        // Logical size is preserved...
2725        assert_eq!(meta.len(), 1024 * 1024 * 1024);
2726        // ...but the destination must NOT be densified: allocated blocks
2727        // (512-byte units) should be a tiny fraction of the logical size. A
2728        // dense copy would report ~2M blocks (1 GiB); a sparse one only a few.
2729        let allocated_bytes = meta.blocks() * 512;
2730        assert!(
2731            allocated_bytes < 16 * 1024 * 1024,
2732            "storage disk was densified: {allocated_bytes} bytes allocated for a sparse template"
2733        );
2734    }
2735
2736    #[test]
2737    fn test_extract_sidecar_skips_when_already_extracted() {
2738        // Verifies the double-check pattern inside the lock:
2739        // if the marker exists and force=false, extraction is a no-op.
2740        let temp_dir = tempfile::tempdir().unwrap();
2741        let cache_dir = temp_dir.path().join("cache");
2742        fs::create_dir_all(&cache_dir).unwrap();
2743
2744        // Write marker to simulate completed extraction
2745        fs::write(cache_dir.join(EXTRACTION_MARKER), "").unwrap();
2746
2747        let dummy_footer = PackFooter {
2748            stub_size: 0,
2749            assets_offset: 0,
2750            assets_size: 0,
2751            manifest_offset: 0,
2752            manifest_size: 0,
2753            checksum: 0,
2754        };
2755
2756        // Should succeed without trying to open a nonexistent sidecar,
2757        // because the marker check short-circuits.
2758        let result = extract_sidecar(
2759            Path::new("/nonexistent/sidecar.smolmachine"),
2760            &cache_dir,
2761            &dummy_footer,
2762            false, // force=false
2763            false,
2764        );
2765        // The sidecar doesn't exist, but we never try to open it because
2766        // the marker file is already present.
2767        // Note: the exists() check at the top will fail here, so this test
2768        // verifies the locking path only when the sidecar exists.
2769        // Let's adjust: use a real (empty) sidecar file for the existence check.
2770        drop(result);
2771
2772        let dummy_sidecar = temp_dir.path().join("dummy.smolmachine");
2773        fs::write(&dummy_sidecar, b"").unwrap();
2774
2775        let result = extract_sidecar(
2776            &dummy_sidecar,
2777            &cache_dir,
2778            &dummy_footer,
2779            false, // force=false
2780            false,
2781        );
2782        assert!(result.is_ok());
2783    }
2784
2785    #[test]
2786    fn test_extract_sidecar_force_clears_marker() {
2787        // Verifies that force=true re-extracts even when the marker exists.
2788        // We can't do a full extraction without a real sidecar, so we verify
2789        // that force=true proceeds past the marker check (and then fails on
2790        // the actual extraction — which is fine for this test).
2791        let temp_dir = tempfile::tempdir().unwrap();
2792        let cache_dir = temp_dir.path().join("cache-force");
2793        fs::create_dir_all(&cache_dir).unwrap();
2794
2795        // Write marker
2796        fs::write(cache_dir.join(EXTRACTION_MARKER), "").unwrap();
2797        assert!(is_extracted(&cache_dir));
2798
2799        // Create a dummy sidecar (empty — will fail during decompression)
2800        let dummy_sidecar = temp_dir.path().join("force.smolmachine");
2801        fs::write(&dummy_sidecar, b"not-a-real-zstd-stream").unwrap();
2802
2803        let dummy_footer = PackFooter {
2804            stub_size: 0,
2805            assets_offset: 0,
2806            assets_size: 22, // matches "not-a-real-zstd-stream".len()
2807            manifest_offset: 22,
2808            manifest_size: 0,
2809            checksum: 0,
2810        };
2811
2812        let result = extract_sidecar(
2813            &dummy_sidecar,
2814            &cache_dir,
2815            &dummy_footer,
2816            true, // force=true should bypass marker
2817            false,
2818        );
2819
2820        // Should fail during decompression (not short-circuit on marker),
2821        // proving that force=true re-enters the extraction path.
2822        assert!(
2823            result.is_err(),
2824            "force extraction should attempt (and fail on dummy data)"
2825        );
2826    }
2827
2828    /// Builds a tar archive in memory with the given entries.
2829    /// Each entry is (path, is_dir, content).
2830    fn build_tar(entries: &[(&str, bool, &[u8])]) -> Vec<u8> {
2831        let mut builder = tar::Builder::new(Vec::new());
2832        for (path, is_dir, content) in entries {
2833            let mut header = tar::Header::new_gnu();
2834            if *is_dir {
2835                header.set_entry_type(tar::EntryType::Directory);
2836                header.set_size(0);
2837                header.set_mode(0o755);
2838            } else {
2839                header.set_entry_type(tar::EntryType::Regular);
2840                header.set_size(content.len() as u64);
2841                header.set_mode(0o644);
2842            }
2843            header.set_cksum();
2844            builder
2845                .append_data(&mut header, *path, &content[..])
2846                .unwrap();
2847        }
2848        builder.into_inner().unwrap()
2849    }
2850
2851    #[test]
2852    fn test_safe_unpack_normal_tar() {
2853        let temp_dir = tempfile::tempdir().unwrap();
2854        let dest_raw = temp_dir.path().join("out");
2855        fs::create_dir_all(&dest_raw).unwrap();
2856        // Canonicalize to resolve macOS /tmp -> /private/tmp symlink
2857        let dest = dest_raw.canonicalize().unwrap();
2858
2859        let tar_data = build_tar(&[("dir/", true, b""), ("dir/file.txt", false, b"hello")]);
2860        let mut archive = tar::Archive::new(tar_data.as_slice());
2861        safe_unpack(&mut archive, &dest).unwrap();
2862
2863        assert!(dest.join("dir").is_dir());
2864        assert_eq!(
2865            fs::read_to_string(dest.join("dir/file.txt")).unwrap(),
2866            "hello"
2867        );
2868    }
2869
2870    #[test]
2871    #[cfg(target_os = "macos")]
2872    fn test_safe_unpack_case_collision_fails_on_case_insensitive_fs() {
2873        // On macOS case-insensitive APFS, extracting a tar with paths that
2874        // differ only in case (e.g., "lower" file vs "Lower/" directory)
2875        // should fail — callers must use a case-sensitive volume instead.
2876        let temp_dir = tempfile::tempdir().unwrap();
2877        let dest_raw = temp_dir.path().join("out");
2878        fs::create_dir_all(&dest_raw).unwrap();
2879        let dest = dest_raw.canonicalize().unwrap();
2880
2881        let tar_data = build_tar(&[
2882            ("share/", true, b""),
2883            ("share/pkg/", true, b""),
2884            ("share/pkg/lower", false, b"script content"),
2885            ("share/pkg/Lower/", true, b""),
2886            ("share/pkg/Lower/__init__.py", false, b"python code"),
2887        ]);
2888        let mut archive = tar::Archive::new(tar_data.as_slice());
2889
2890        // Should fail on case-insensitive APFS — the caller is responsible
2891        // for providing a case-sensitive destination (via acquire_layers_lease).
2892        let result = safe_unpack(&mut archive, &dest);
2893        assert!(
2894            result.is_err(),
2895            "case collision should fail on case-insensitive FS"
2896        );
2897    }
2898
2899    #[test]
2900    #[cfg(target_os = "macos")]
2901    fn test_layers_lease_creates_and_cleans_volume() {
2902        // Verify that acquire_layers_lease creates a case-sensitive sparse
2903        // image, mounts it, and detaches on lease drop.
2904        // Skips gracefully if hdiutil is unavailable (CI, sandboxed envs).
2905        let temp_dir = tempfile::tempdir().unwrap();
2906        let cache_dir = temp_dir.path().join("cache");
2907        // Create a dummy tar so has_layer_tars() returns true.
2908        fs::create_dir_all(cache_dir.join("layers")).unwrap();
2909        fs::write(cache_dir.join("layers/dummy.tar"), b"").unwrap();
2910
2911        let lease = match acquire_layers_lease(&cache_dir, false) {
2912            Ok(l) => l,
2913            Err(e) => {
2914                eprintln!("SKIP: hdiutil unavailable: {}", e);
2915                return;
2916            }
2917        };
2918        assert!(lease.path.exists());
2919        assert!(is_mount_point(&lease.path));
2920
2921        // Both "lower" and "Lower" should coexist on the case-sensitive volume.
2922        fs::write(lease.path.join("lower"), "file").unwrap();
2923        fs::create_dir_all(lease.path.join("Lower")).unwrap();
2924        assert!(lease.path.join("lower").exists());
2925        assert!(lease.path.join("Lower").is_dir());
2926
2927        // Lease file should exist while lease is held.
2928        let lease_file = cache_dir
2929            .join(LEASES_DIR)
2930            .join(format!("{}", std::process::id()));
2931        assert!(lease_file.exists());
2932
2933        // Drop lease — should detach volume (last lease).
2934        let mount_point = lease.path.clone();
2935        drop(lease);
2936        assert!(
2937            !is_mount_point(&mount_point),
2938            "volume should be detached after last lease drop"
2939        );
2940    }
2941
2942    #[test]
2943    fn test_safe_unpack_skips_char_and_block_devices() {
2944        // Char/Block entries appear in overlayfs exports from Debian images
2945        // (e.g., update-alternatives). They should be skipped, not rejected.
2946        let temp_dir = tempfile::tempdir().unwrap();
2947        let dest_raw = temp_dir.path().join("out");
2948        fs::create_dir_all(&dest_raw).unwrap();
2949        let dest = dest_raw.canonicalize().unwrap();
2950
2951        let mut builder = tar::Builder::new(Vec::new());
2952
2953        // Regular file before device entries
2954        let mut header = tar::Header::new_gnu();
2955        header.set_entry_type(tar::EntryType::Regular);
2956        header.set_size(5);
2957        header.set_mode(0o644);
2958        header.set_path("before.txt").unwrap();
2959        header.set_cksum();
2960        builder
2961            .append_data(&mut header, "before.txt", &b"hello"[..])
2962            .unwrap();
2963
2964        // Char device entry (should be skipped)
2965        let mut header = tar::Header::new_gnu();
2966        header.set_entry_type(tar::EntryType::Char);
2967        header.set_size(0);
2968        header.set_mode(0o644);
2969        header.set_path("etc/alternatives/pager.1.gz").unwrap();
2970        header.set_cksum();
2971        builder
2972            .append_data(&mut header, "etc/alternatives/pager.1.gz", &b""[..])
2973            .unwrap();
2974
2975        // Block device entry (should be skipped)
2976        let mut header = tar::Header::new_gnu();
2977        header.set_entry_type(tar::EntryType::Block);
2978        header.set_size(0);
2979        header.set_mode(0o644);
2980        header.set_path("dev/sda").unwrap();
2981        header.set_cksum();
2982        builder
2983            .append_data(&mut header, "dev/sda", &b""[..])
2984            .unwrap();
2985
2986        // Regular file after device entries (must survive)
2987        let mut header = tar::Header::new_gnu();
2988        header.set_entry_type(tar::EntryType::Regular);
2989        header.set_size(5);
2990        header.set_mode(0o644);
2991        header.set_path("after.txt").unwrap();
2992        header.set_cksum();
2993        builder
2994            .append_data(&mut header, "after.txt", &b"world"[..])
2995            .unwrap();
2996
2997        let tar_data = builder.into_inner().unwrap();
2998
2999        let mut archive = tar::Archive::new(tar_data.as_slice());
3000        let result = safe_unpack(&mut archive, &dest);
3001        assert!(
3002            result.is_ok(),
3003            "Char/Block entries should be skipped: {:?}",
3004            result.err()
3005        );
3006
3007        // Files before AND after device entries are extracted
3008        assert_eq!(
3009            fs::read_to_string(dest.join("before.txt")).unwrap(),
3010            "hello"
3011        );
3012        assert_eq!(fs::read_to_string(dest.join("after.txt")).unwrap(), "world");
3013
3014        // Device entries are not created
3015        assert!(!dest.join("etc/alternatives/pager.1.gz").exists());
3016        assert!(!dest.join("dev/sda").exists());
3017    }
3018
3019    #[test]
3020    fn test_safe_unpack_skips_hardlink_to_whiteout() {
3021        // Overlayfs exports from Fedora produce hardlinks to char-device
3022        // whiteout entries (e.g., .build-id symlinks referencing replaced
3023        // base-layer files). The whiteout is skipped, so the hardlink target
3024        // doesn't exist — the hardlink must be skipped too.
3025        let temp_dir = tempfile::tempdir().unwrap();
3026        let dest_raw = temp_dir.path().join("out");
3027        fs::create_dir_all(&dest_raw).unwrap();
3028        let dest = dest_raw.canonicalize().unwrap();
3029
3030        let mut builder = tar::Builder::new(Vec::new());
3031
3032        // Char device whiteout (will be skipped)
3033        let mut header = tar::Header::new_gnu();
3034        header.set_entry_type(tar::EntryType::Char);
3035        header.set_size(0);
3036        header.set_mode(0o000);
3037        header.set_path("usr/lib/.build-id/84/target").unwrap();
3038        header.set_cksum();
3039        builder
3040            .append_data(&mut header, "usr/lib/.build-id/84/target", &b""[..])
3041            .unwrap();
3042
3043        // Hardlink to the skipped whiteout (should also be skipped)
3044        let mut header = tar::Header::new_gnu();
3045        header.set_entry_type(tar::EntryType::Link);
3046        header.set_size(0);
3047        header.set_mode(0o000);
3048        header.set_path("usr/lib/.build-id/d9/link").unwrap();
3049        header.set_link_name("usr/lib/.build-id/84/target").unwrap();
3050        header.set_cksum();
3051        builder
3052            .append_data(&mut header, "usr/lib/.build-id/d9/link", &b""[..])
3053            .unwrap();
3054
3055        // Regular file after (must survive)
3056        let mut header = tar::Header::new_gnu();
3057        header.set_entry_type(tar::EntryType::Regular);
3058        header.set_size(2);
3059        header.set_mode(0o644);
3060        header.set_path("ok.txt").unwrap();
3061        header.set_cksum();
3062        builder
3063            .append_data(&mut header, "ok.txt", &b"ok"[..])
3064            .unwrap();
3065
3066        let tar_data = builder.into_inner().unwrap();
3067
3068        let mut archive = tar::Archive::new(tar_data.as_slice());
3069        let result = safe_unpack(&mut archive, &dest);
3070        assert!(
3071            result.is_ok(),
3072            "hardlink to skipped whiteout should be skipped: {:?}",
3073            result.err()
3074        );
3075
3076        // Whiteout and hardlink are not created
3077        assert!(!dest.join("usr/lib/.build-id/84/target").exists());
3078        assert!(!dest.join("usr/lib/.build-id/d9/link").exists());
3079        // Regular file survives
3080        assert_eq!(fs::read_to_string(dest.join("ok.txt")).unwrap(), "ok");
3081    }
3082
3083    #[test]
3084    fn test_safe_unpack_readonly_parent_dir_does_not_block_children() {
3085        // Reproduces the Fedora extraction bug: a mode-555 directory entry
3086        // appears before its children in the tar. Without deferred permissions,
3087        // creating files inside the read-only directory fails.
3088        let temp_dir = tempfile::tempdir().unwrap();
3089        let dest_raw = temp_dir.path().join("out");
3090        fs::create_dir_all(&dest_raw).unwrap();
3091        let dest = dest_raw.canonicalize().unwrap();
3092
3093        let mut builder = tar::Builder::new(Vec::new());
3094
3095        // Parent directory with restrictive mode (read-only, no write)
3096        let mut header = tar::Header::new_gnu();
3097        header.set_entry_type(tar::EntryType::Directory);
3098        header.set_size(0);
3099        header.set_mode(0o555); // read + execute only, no write
3100        header.set_path("usr/lib64/pm-utils/").unwrap();
3101        header.set_cksum();
3102        builder
3103            .append_data(&mut header, "usr/lib64/pm-utils/", &b""[..])
3104            .unwrap();
3105
3106        // Child directory inside the read-only parent
3107        let mut header = tar::Header::new_gnu();
3108        header.set_entry_type(tar::EntryType::Directory);
3109        header.set_size(0);
3110        header.set_mode(0o555);
3111        header.set_path("usr/lib64/pm-utils/module.d/").unwrap();
3112        header.set_cksum();
3113        builder
3114            .append_data(&mut header, "usr/lib64/pm-utils/module.d/", &b""[..])
3115            .unwrap();
3116
3117        // File inside the nested read-only directory
3118        let mut header = tar::Header::new_gnu();
3119        header.set_entry_type(tar::EntryType::Regular);
3120        header.set_size(4);
3121        header.set_mode(0o644);
3122        header
3123            .set_path("usr/lib64/pm-utils/module.d/test.conf")
3124            .unwrap();
3125        header.set_cksum();
3126        builder
3127            .append_data(
3128                &mut header,
3129                "usr/lib64/pm-utils/module.d/test.conf",
3130                &b"data"[..],
3131            )
3132            .unwrap();
3133
3134        let tar_data = builder.into_inner().unwrap();
3135
3136        let mut archive = tar::Archive::new(tar_data.as_slice());
3137        let result = safe_unpack(&mut archive, &dest);
3138        assert!(
3139            result.is_ok(),
3140            "read-only parent should not block children: {:?}",
3141            result.err()
3142        );
3143
3144        // Child directory and file must exist
3145        assert!(dest.join("usr/lib64/pm-utils/module.d").is_dir());
3146        assert_eq!(
3147            fs::read_to_string(dest.join("usr/lib64/pm-utils/module.d/test.conf")).unwrap(),
3148            "data"
3149        );
3150
3151        // Final permissions should be restored to the tar's mode (555)
3152        #[cfg(unix)]
3153        {
3154            use std::os::unix::fs::PermissionsExt;
3155            let mode = fs::metadata(dest.join("usr/lib64/pm-utils"))
3156                .unwrap()
3157                .permissions()
3158                .mode()
3159                & 0o777;
3160            assert_eq!(mode, 0o555, "deferred directory mode should be 555");
3161        }
3162    }
3163
3164    #[test]
3165    fn test_safe_unpack_mixed_fedora_overlay_layer() {
3166        // Realistic Fedora overlay layer: regular files interspersed with
3167        // whiteout char devices and hardlinks to those whiteouts.
3168        // All good files should extract; bad entries should be skipped.
3169        let temp_dir = tempfile::tempdir().unwrap();
3170        let dest_raw = temp_dir.path().join("out");
3171        fs::create_dir_all(&dest_raw).unwrap();
3172        let dest = dest_raw.canonicalize().unwrap();
3173
3174        let mut builder = tar::Builder::new(Vec::new());
3175
3176        // Directory
3177        let mut header = tar::Header::new_gnu();
3178        header.set_entry_type(tar::EntryType::Directory);
3179        header.set_size(0);
3180        header.set_mode(0o755);
3181        header.set_path("usr/").unwrap();
3182        header.set_cksum();
3183        builder.append_data(&mut header, "usr/", &b""[..]).unwrap();
3184
3185        // Good file 1
3186        let mut header = tar::Header::new_gnu();
3187        header.set_entry_type(tar::EntryType::Regular);
3188        header.set_size(11);
3189        header.set_mode(0o644);
3190        header.set_path("usr/good1.txt").unwrap();
3191        header.set_cksum();
3192        builder
3193            .append_data(&mut header, "usr/good1.txt", &b"good file 1"[..])
3194            .unwrap();
3195
3196        // Char device whiteout
3197        let mut header = tar::Header::new_gnu();
3198        header.set_entry_type(tar::EntryType::Char);
3199        header.set_size(0);
3200        header.set_mode(0o000);
3201        header.set_device_major(0).unwrap();
3202        header.set_device_minor(0).unwrap();
3203        header.set_path("usr/.wh.removed-pkg").unwrap();
3204        header.set_cksum();
3205        builder
3206            .append_data(&mut header, "usr/.wh.removed-pkg", &b""[..])
3207            .unwrap();
3208
3209        // Good file 2
3210        let mut header = tar::Header::new_gnu();
3211        header.set_entry_type(tar::EntryType::Regular);
3212        header.set_size(11);
3213        header.set_mode(0o644);
3214        header.set_path("usr/good2.txt").unwrap();
3215        header.set_cksum();
3216        builder
3217            .append_data(&mut header, "usr/good2.txt", &b"good file 2"[..])
3218            .unwrap();
3219
3220        // Hardlink to the whiteout (should be skipped)
3221        let mut header = tar::Header::new_gnu();
3222        header.set_entry_type(tar::EntryType::Link);
3223        header.set_size(0);
3224        header.set_mode(0o000);
3225        header.set_path("usr/link-to-removed").unwrap();
3226        header.set_link_name("usr/.wh.removed-pkg").unwrap();
3227        header.set_cksum();
3228        builder
3229            .append_data(&mut header, "usr/link-to-removed", &b""[..])
3230            .unwrap();
3231
3232        // Good file 3
3233        let mut header = tar::Header::new_gnu();
3234        header.set_entry_type(tar::EntryType::Regular);
3235        header.set_size(19); // == len("#!/usr/bin/env bash")
3236        header.set_mode(0o755);
3237        header.set_path("usr/good3.sh").unwrap();
3238        header.set_cksum();
3239        builder
3240            .append_data(&mut header, "usr/good3.sh", &b"#!/usr/bin/env bash"[..])
3241            .unwrap();
3242
3243        // Another char device whiteout
3244        let mut header = tar::Header::new_gnu();
3245        header.set_entry_type(tar::EntryType::Char);
3246        header.set_size(0);
3247        header.set_mode(0o000);
3248        header.set_device_major(0).unwrap();
3249        header.set_device_minor(0).unwrap();
3250        header.set_path("usr/.wh.another-removed").unwrap();
3251        header.set_cksum();
3252        builder
3253            .append_data(&mut header, "usr/.wh.another-removed", &b""[..])
3254            .unwrap();
3255
3256        // Good file 4 (final entry)
3257        let mut header = tar::Header::new_gnu();
3258        header.set_entry_type(tar::EntryType::Regular);
3259        header.set_size(5);
3260        header.set_mode(0o644);
3261        header.set_path("usr/good4.dat").unwrap();
3262        header.set_cksum();
3263        builder
3264            .append_data(&mut header, "usr/good4.dat", &b"final"[..])
3265            .unwrap();
3266
3267        let tar_data = builder.into_inner().unwrap();
3268
3269        let mut archive = tar::Archive::new(tar_data.as_slice());
3270        let result = safe_unpack(&mut archive, &dest);
3271        assert!(
3272            result.is_ok(),
3273            "mixed Fedora overlay should extract cleanly: {:?}",
3274            result.err()
3275        );
3276
3277        // Good files are all extracted
3278        assert_eq!(
3279            fs::read_to_string(dest.join("usr/good1.txt")).unwrap(),
3280            "good file 1"
3281        );
3282        assert_eq!(
3283            fs::read_to_string(dest.join("usr/good2.txt")).unwrap(),
3284            "good file 2"
3285        );
3286        assert_eq!(
3287            fs::read_to_string(dest.join("usr/good3.sh")).unwrap(),
3288            "#!/usr/bin/env bash"
3289        );
3290        assert_eq!(
3291            fs::read_to_string(dest.join("usr/good4.dat")).unwrap(),
3292            "final"
3293        );
3294
3295        // Bad entries are not created
3296        assert!(!dest.join("usr/.wh.removed-pkg").exists());
3297        assert!(!dest.join("usr/link-to-removed").exists());
3298        assert!(!dest.join("usr/.wh.another-removed").exists());
3299    }
3300
3301    #[test]
3302    fn test_safe_unpack_unknown_tar_type_byte() {
3303        // Entry with unknown tar type byte (0x41 = 'A') — a vendor extension
3304        // not recognized by the tar crate (maps to __Nonexhaustive).
3305        // Should be skipped gracefully by safe_unpack's catch-all arm.
3306        // Note: byte '7' maps to EntryType::Continuous which is allowed.
3307        let temp_dir = tempfile::tempdir().unwrap();
3308        let dest_raw = temp_dir.path().join("out");
3309        fs::create_dir_all(&dest_raw).unwrap();
3310        let dest = dest_raw.canonicalize().unwrap();
3311
3312        let mut builder = tar::Builder::new(Vec::new());
3313
3314        // Regular file before the unknown entry
3315        let mut header = tar::Header::new_gnu();
3316        header.set_entry_type(tar::EntryType::Regular);
3317        header.set_size(6);
3318        header.set_mode(0o644);
3319        header.set_path("before.txt").unwrap();
3320        header.set_cksum();
3321        builder
3322            .append_data(&mut header, "before.txt", &b"before"[..])
3323            .unwrap();
3324
3325        // Unknown type byte entry ('A' = 0x41, truly unrecognized)
3326        let mut header = tar::Header::new_gnu();
3327        header.set_entry_type(tar::EntryType::new(b'A'));
3328        header.set_size(0);
3329        header.set_mode(0o644);
3330        header.set_path("unknown-type-entry").unwrap();
3331        header.set_cksum();
3332        builder
3333            .append_data(&mut header, "unknown-type-entry", &b""[..])
3334            .unwrap();
3335
3336        // Regular file after
3337        let mut header = tar::Header::new_gnu();
3338        header.set_entry_type(tar::EntryType::Regular);
3339        header.set_size(5);
3340        header.set_mode(0o644);
3341        header.set_path("after.txt").unwrap();
3342        header.set_cksum();
3343        builder
3344            .append_data(&mut header, "after.txt", &b"after"[..])
3345            .unwrap();
3346
3347        let tar_data = builder.into_inner().unwrap();
3348
3349        let mut archive = tar::Archive::new(tar_data.as_slice());
3350        let result = safe_unpack(&mut archive, &dest);
3351        assert!(
3352            result.is_ok(),
3353            "unknown tar type should be skipped: {:?}",
3354            result.err()
3355        );
3356
3357        assert_eq!(
3358            fs::read_to_string(dest.join("before.txt")).unwrap(),
3359            "before"
3360        );
3361        assert_eq!(fs::read_to_string(dest.join("after.txt")).unwrap(), "after");
3362        assert!(!dest.join("unknown-type-entry").exists());
3363    }
3364
3365    // Sets directory mtimes via libc::utimes to drive the LRU ordering, so it
3366    // only compiles on Unix; the eviction logic under test is platform-agnostic.
3367    #[cfg(unix)]
3368    #[test]
3369    fn test_evict_cache_to_size_lru() {
3370        use std::ffi::CString;
3371        let tmp = tempfile::tempdir().unwrap();
3372        let root = tmp.path();
3373        let mk = |name: &str, mtime_secs: i64| {
3374            let d = root.join(name);
3375            fs::create_dir_all(&d).unwrap();
3376            fs::write(d.join("data"), vec![0u8; 1024 * 1024]).unwrap(); // ~1 MiB real
3377            let c = CString::new(d.to_string_lossy().as_bytes()).unwrap();
3378            let tv = libc::timeval {
3379                tv_sec: mtime_secs,
3380                tv_usec: 0,
3381            };
3382            let times = [tv, tv];
3383            unsafe {
3384                libc::utimes(c.as_ptr(), times.as_ptr());
3385            }
3386            d
3387        };
3388        let old = mk("aaaa", 1_000_000);
3389        let mid = mk("bbbb", 2_000_000);
3390        let new = mk("cccc", 3_000_000);
3391
3392        // Total (~3 MiB) is under the cap → nothing evicted.
3393        assert_eq!(evict_cache_to_size(root, 100 * 1024 * 1024), 0);
3394        assert!(old.exists() && mid.exists() && new.exists());
3395
3396        // Cap (~2.5 MiB) forces evicting the single oldest entry, LRU-first.
3397        let freed = evict_cache_to_size(root, 5 * 1024 * 1024 / 2);
3398        assert!(freed > 0, "expected some bytes freed");
3399        assert!(!old.exists(), "oldest extraction should be evicted");
3400        assert!(mid.exists() && new.exists(), "newer extractions kept");
3401    }
3402
3403    #[cfg(unix)]
3404    #[test]
3405    fn test_evict_cache_protects_current_extraction() {
3406        use std::ffi::CString;
3407        let tmp = tempfile::tempdir().unwrap();
3408        let root = tmp.path();
3409        let mk = |name: &str, mtime_secs: i64| {
3410            let d = root.join(name);
3411            fs::create_dir_all(&d).unwrap();
3412            fs::write(d.join("data"), vec![0u8; 4 * 1024 * 1024]).unwrap(); // ~4 MiB real
3413            let c = CString::new(d.to_string_lossy().as_bytes()).unwrap();
3414            let tv = libc::timeval {
3415                tv_sec: mtime_secs,
3416                tv_usec: 0,
3417            };
3418            let times = [tv, tv];
3419            unsafe {
3420                libc::utimes(c.as_ptr(), times.as_ptr());
3421            }
3422            d
3423        };
3424        // The dir we just wrote is the NEWEST but still larger than the cap on
3425        // its own — the exact torch-pack shape (one ~13 GiB extraction vs a 5 GiB
3426        // cap). Without protection, oldest-first eviction would reach it and
3427        // delete the very assets about to boot.
3428        let old = mk("aaaa", 1_000_000);
3429        let current = mk("cccc", 3_000_000);
3430
3431        // Cap smaller than `current` alone: everything is "over cap".
3432        let freed = evict_cache_to_size_protecting(root, 1024 * 1024, Some(&current));
3433        assert!(freed > 0, "the old entry should still be evicted");
3434        assert!(!old.exists(), "oldest unprotected extraction evicted");
3435        assert!(
3436            current.exists() && current.join("data").exists(),
3437            "the protected (just-extracted) dir must survive even over cap"
3438        );
3439    }
3440
3441    // Lease tracking is a macOS case-sensitive-volume concept; on Linux
3442    // `has_active_leases` is always false (layers live at cache_dir/layers).
3443    #[cfg(target_os = "macos")]
3444    #[test]
3445    fn test_evict_cache_skips_active_lease() {
3446        let tmp = tempfile::tempdir().unwrap();
3447        let root = tmp.path();
3448        let d = root.join("aaaa");
3449        fs::create_dir_all(&d).unwrap();
3450        fs::write(d.join("data"), vec![0u8; 2 * 1024 * 1024]).unwrap();
3451        // Simulate a running pack: a live daemon lease file (current PID).
3452        let leases = d.join(LEASES_DIR);
3453        fs::create_dir_all(&leases).unwrap();
3454        fs::write(leases.join("daemon"), format!("{}", std::process::id())).unwrap();
3455        assert!(has_active_leases(&d), "live lease should be detected");
3456        // Cap of 0 would evict everything — but the active lease must be spared.
3457        let freed = evict_cache_to_size(root, 0);
3458        assert_eq!(freed, 0, "leased extraction must not be evicted");
3459        assert!(d.exists());
3460    }
3461}
3462
3463/// Reaping stranded case-sensitive volumes (macOS-only lease protocol).
3464#[cfg(all(test, target_os = "macos"))]
3465mod orphan_reap_tests {
3466    use super::*;
3467
3468    /// Standalone packs live at `<root>/<hash>`, so peers are the other hashes.
3469    #[test]
3470    fn flat_layout_sees_its_peers() {
3471        let tmp = tempfile::tempdir().expect("tempdir");
3472        let root = tmp.path();
3473        for h in ["aaa", "bbb", "ccc"] {
3474            fs::create_dir_all(root.join(h)).expect("mkdir");
3475        }
3476        let peers = sibling_cache_dirs(&root.join("aaa"));
3477
3478        assert_eq!(peers.len(), 2, "both peers, and never itself");
3479        assert!(peers.contains(&root.join("bbb")));
3480        assert!(peers.contains(&root.join("ccc")));
3481        assert!(!peers.contains(&root.join("aaa")));
3482    }
3483
3484    /// VM-backed packs live at `<root>/<hash>/pack` — a peer is another hash's
3485    /// `pack` subdir, not the hash directory itself.
3486    #[test]
3487    fn nested_layout_keeps_the_pack_suffix() {
3488        let tmp = tempfile::tempdir().expect("tempdir");
3489        let root = tmp.path();
3490        for h in ["aaa", "bbb"] {
3491            fs::create_dir_all(root.join(h).join("pack")).expect("mkdir");
3492        }
3493        let peers = sibling_cache_dirs(&root.join("aaa").join("pack"));
3494
3495        assert_eq!(peers, vec![root.join("bbb").join("pack")]);
3496    }
3497
3498    /// A hash directory with no `pack` subdir is simply skipped, rather than
3499    /// yielding a path that does not exist.
3500    #[test]
3501    fn nested_layout_skips_peers_without_a_pack_dir() {
3502        let tmp = tempfile::tempdir().expect("tempdir");
3503        let root = tmp.path();
3504        fs::create_dir_all(root.join("aaa").join("pack")).expect("mkdir");
3505        fs::create_dir_all(root.join("bbb")).expect("mkdir");
3506
3507        assert!(sibling_cache_dirs(&root.join("aaa").join("pack")).is_empty());
3508    }
3509
3510    /// The reaper must never block: a peer mid-acquire holds its lock, and
3511    /// waiting on it would stall an unrelated artifact's run.
3512    #[test]
3513    fn a_held_lock_is_skipped_not_waited_on() {
3514        let tmp = tempfile::tempdir().expect("tempdir");
3515        let dir = tmp.path();
3516        let held = lock_leases(dir).expect("first lock");
3517
3518        assert!(
3519            try_lock_leases(dir).is_err(),
3520            "must fail immediately while held"
3521        );
3522
3523        drop(held);
3524        assert!(try_lock_leases(dir).is_ok(), "succeeds once released");
3525    }
3526
3527    /// Reaping is a no-op when nothing is mounted — the common case, and it must
3528    /// not disturb a peer's directory contents.
3529    #[test]
3530    fn unmounted_peers_are_left_alone() {
3531        let tmp = tempfile::tempdir().expect("tempdir");
3532        let root = tmp.path();
3533        let peer = root.join("bbb");
3534        fs::create_dir_all(peer.join(LEASES_DIR)).expect("mkdir");
3535        fs::create_dir_all(root.join("aaa")).expect("mkdir");
3536        let marker = peer.join(LEASES_DIR).join("12345");
3537        fs::write(&marker, "").expect("write lease");
3538
3539        reap_orphan_volumes(&root.join("aaa"));
3540
3541        assert!(peer.exists(), "peer directory must survive");
3542    }
3543}
3544
3545/// `sparse_copy` must reproduce the source exactly while preserving holes.
3546#[cfg(test)]
3547mod sparse_copy_tests {
3548    use super::*;
3549
3550    /// Write `src`, copy it, and assert the bytes round-trip identically.
3551    fn roundtrip(build: impl FnOnce(&mut File)) -> (Vec<u8>, Vec<u8>, u64) {
3552        let tmp = tempfile::tempdir().expect("tempdir");
3553        let src = tmp.path().join("src.img");
3554        let dst = tmp.path().join("dst.img");
3555        let mut f = File::create(&src).expect("create src");
3556        build(&mut f);
3557        f.sync_all().expect("sync");
3558        drop(f);
3559
3560        sparse_copy(&src, &dst).expect("sparse_copy");
3561
3562        let want = fs::read(&src).expect("read src");
3563        let got = fs::read(&dst).expect("read dst");
3564        let blocks = dst_blocks(&dst);
3565        (want, got, blocks)
3566    }
3567
3568    #[cfg(unix)]
3569    fn dst_blocks(p: &Path) -> u64 {
3570        use std::os::unix::fs::MetadataExt;
3571        fs::metadata(p).expect("stat").blocks()
3572    }
3573    #[cfg(not(unix))]
3574    fn dst_blocks(_p: &Path) -> u64 {
3575        0
3576    }
3577
3578    /// Data at both ends with a large hole between — the storage-template shape.
3579    #[test]
3580    fn data_separated_by_a_large_hole_round_trips() {
3581        let (want, got, _) = roundtrip(|f| {
3582            f.write_all(b"HEAD").expect("head");
3583            f.seek(SeekFrom::Start(64 * 1024 * 1024)).expect("seek");
3584            f.write_all(b"TAIL").expect("tail");
3585        });
3586        assert_eq!(want.len(), 64 * 1024 * 1024 + 4);
3587        assert_eq!(want, got, "copy must be byte-identical across the hole");
3588    }
3589
3590    /// The hole must survive the copy rather than being filled with zeros.
3591    #[cfg(unix)]
3592    #[test]
3593    fn the_hole_is_not_materialized() {
3594        let (want, _got, blocks) = roundtrip(|f| {
3595            f.write_all(b"HEAD").expect("head");
3596            f.seek(SeekFrom::Start(64 * 1024 * 1024)).expect("seek");
3597            f.write_all(b"TAIL").expect("tail");
3598        });
3599        // A dense 64 MiB copy would be ~131072 512-byte blocks; a sparse one is
3600        // a few. Assert well under a tenth to stay robust across filesystems.
3601        let dense = (want.len() as u64) / 512;
3602        assert!(
3603            blocks < dense / 10,
3604            "destination should stay sparse: {blocks} blocks vs {dense} if dense"
3605        );
3606    }
3607
3608    /// A fully-zero file has no data extents at all — the SEEK_DATA probe
3609    /// returns ENXIO immediately, which must not be mistaken for failure.
3610    #[test]
3611    fn an_all_zero_file_round_trips() {
3612        let (want, got, _) = roundtrip(|f| {
3613            f.set_len(8 * 1024 * 1024).expect("set_len");
3614        });
3615        assert_eq!(want.len(), 8 * 1024 * 1024);
3616        assert_eq!(got, want, "all-zero file must copy as all zeros");
3617    }
3618
3619    /// Dense, non-zero content must copy verbatim — the no-holes case.
3620    #[test]
3621    fn a_dense_file_round_trips() {
3622        let (want, got, _) = roundtrip(|f| {
3623            let data: Vec<u8> = (0..=255u8).cycle().take(3 * 1024 * 1024).collect();
3624            f.write_all(&data).expect("write");
3625        });
3626        assert_eq!(want, got, "dense content must be preserved exactly");
3627        assert!(want.iter().any(|&b| b != 0));
3628    }
3629
3630    /// An empty file is a degenerate case both paths must survive.
3631    #[test]
3632    fn an_empty_file_round_trips() {
3633        let (want, got, _) = roundtrip(|_f| {});
3634        assert!(want.is_empty() && got.is_empty());
3635    }
3636
3637    /// Data crossing the 512 KiB buffer boundary must not be truncated or
3638    /// misaligned by the chunked read inside an extent.
3639    #[test]
3640    fn an_extent_larger_than_the_buffer_round_trips() {
3641        let (want, got, _) = roundtrip(|f| {
3642            let data: Vec<u8> = (0..=255u8).cycle().take(1_500_000).collect();
3643            f.write_all(&data).expect("write");
3644            f.seek(SeekFrom::Start(32 * 1024 * 1024)).expect("seek");
3645            f.write_all(b"END").expect("end");
3646        });
3647        assert_eq!(want, got, "multi-chunk extent must copy exactly");
3648    }
3649}
3650
3651/// Differential tests: the extent-based copy must agree with the exhaustive
3652/// scan it replaced, on every shape of sparse file.
3653#[cfg(test)]
3654mod sparse_copy_differential_tests {
3655    use super::*;
3656    /// The algorithm exactly as it was before the SEEK_DATA change, kept here as
3657    /// the reference oracle. Any divergence from this is a regression.
3658    fn reference_scan_copy(src: &Path, dst: &Path) -> std::io::Result<()> {
3659        let mut src_file = File::open(src)?;
3660        let size = src_file.metadata()?.len();
3661        let mut dst_file = File::create(dst)?;
3662        dst_file.set_len(size)?;
3663        let mut buf = vec![0u8; 512 * 1024];
3664        let mut offset: u64 = 0;
3665        while offset < size {
3666            let to_read = (size - offset).min(buf.len() as u64) as usize;
3667            let n = src_file.read(&mut buf[..to_read])?;
3668            if n == 0 {
3669                break;
3670            }
3671            let chunk = &buf[..n];
3672            if chunk.iter().any(|&b| b != 0) {
3673                dst_file.seek(SeekFrom::Start(offset))?;
3674                dst_file.write_all(chunk)?;
3675            }
3676            offset += n as u64;
3677        }
3678        Ok(())
3679    }
3680
3681    /// Stream-compare two files. `memcmp` stays fast even in the unoptimized
3682    /// test profile, unlike a cryptographic digest — and comparing the bytes is
3683    /// a stronger check than comparing hashes of them.
3684    fn files_equal(a: &Path, b: &Path) -> bool {
3685        let (mut fa, mut fb) = (
3686            File::open(a).expect("open a"),
3687            File::open(b).expect("open b"),
3688        );
3689        if fa.metadata().expect("meta").len() != fb.metadata().expect("meta").len() {
3690            return false;
3691        }
3692        let (mut ba, mut bb) = (vec![0u8; 4 << 20], vec![0u8; 4 << 20]);
3693        loop {
3694            let na = fa.read(&mut ba).expect("read a");
3695            let nb = fb.read(&mut bb).expect("read b");
3696            if na != nb {
3697                return false;
3698            }
3699            if na == 0 {
3700                return true;
3701            }
3702            if ba[..na] != bb[..nb] {
3703                return false;
3704            }
3705        }
3706    }
3707
3708    #[cfg(unix)]
3709    fn blocks(p: &Path) -> u64 {
3710        use std::os::unix::fs::MetadataExt;
3711        fs::metadata(p).expect("stat").blocks()
3712    }
3713
3714    /// Deterministic pseudo-random layouts: same seed, same file, every run.
3715    struct Lcg(u64);
3716    impl Lcg {
3717        fn next(&mut self) -> u64 {
3718            self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
3719            self.0 >> 11
3720        }
3721    }
3722
3723    /// Fuzz many sparse shapes; the new copy must agree with the old one and
3724    /// with the source, byte for byte, on every one.
3725    #[test]
3726    fn fuzz_layouts_agree_with_the_old_algorithm() {
3727        let tmp = tempfile::tempdir().expect("tempdir");
3728        let mut rng = Lcg(0x5EED);
3729
3730        for case in 0..60 {
3731            let src = tmp.path().join(format!("s{case}.img"));
3732            let fast = tmp.path().join(format!("f{case}.img"));
3733            let slow = tmp.path().join(format!("r{case}.img"));
3734
3735            let size = 1 + (rng.next() % (24 * 1024 * 1024));
3736            let mut f = File::create(&src).expect("create");
3737            f.set_len(size).expect("set_len");
3738            // Scatter a handful of data runs, some tiny, some spanning chunks.
3739            let runs = rng.next() % 7;
3740            for _ in 0..runs {
3741                let at = rng.next() % size;
3742                let len = (1 + rng.next() % (2 * 1024 * 1024)).min(size - at);
3743                let byte = (1 + (rng.next() % 254)) as u8;
3744                f.seek(SeekFrom::Start(at)).expect("seek");
3745                f.write_all(&vec![byte; len as usize]).expect("write");
3746            }
3747            f.sync_all().expect("sync");
3748            drop(f);
3749
3750            sparse_copy(&src, &fast).expect("fast");
3751            reference_scan_copy(&src, &slow).expect("slow");
3752
3753            assert!(
3754                files_equal(&fast, &src),
3755                "case {case}: fast path diverged from source"
3756            );
3757            assert!(
3758                files_equal(&fast, &slow),
3759                "case {case}: fast path diverged from old algorithm"
3760            );
3761            #[cfg(unix)]
3762            assert!(
3763                blocks(&fast) <= blocks(&slow),
3764                "case {case}: fast path is less sparse than the old one"
3765            );
3766        }
3767    }
3768
3769    /// Offsets beyond 4 GiB must not truncate through any 32-bit path.
3770    #[test]
3771    fn data_beyond_four_gib_round_trips() {
3772        let tmp = tempfile::tempdir().expect("tempdir");
3773        let src = tmp.path().join("big.img");
3774        let dst = tmp.path().join("big-copy.img");
3775        let far: u64 = 6 * 1024 * 1024 * 1024; // 6 GiB, sparse
3776
3777        let mut f = File::create(&src).expect("create");
3778        f.write_all(b"START").expect("start");
3779        f.seek(SeekFrom::Start(far)).expect("seek");
3780        f.write_all(b"BEYOND-4GIB").expect("far write");
3781        f.sync_all().expect("sync");
3782        drop(f);
3783
3784        sparse_copy(&src, &dst).expect("copy");
3785
3786        assert!(files_equal(&dst, &src), "6 GiB sparse file must round-trip");
3787        #[cfg(unix)]
3788        assert!(
3789            blocks(&dst) < 4096,
3790            "must stay sparse, got {}",
3791            blocks(&dst)
3792        );
3793    }
3794
3795    /// Data touching the final byte: SEEK_HOLE has no hole to report past it.
3796    #[test]
3797    fn data_at_the_very_end_round_trips() {
3798        let tmp = tempfile::tempdir().expect("tempdir");
3799        let src = tmp.path().join("tail.img");
3800        let dst = tmp.path().join("tail-copy.img");
3801        let mut f = File::create(&src).expect("create");
3802        f.set_len(8 * 1024 * 1024).expect("len");
3803        f.seek(SeekFrom::Start(8 * 1024 * 1024 - 3)).expect("seek");
3804        f.write_all(b"EOF").expect("write");
3805        f.sync_all().expect("sync");
3806        drop(f);
3807
3808        sparse_copy(&src, &dst).expect("copy");
3809        assert!(files_equal(&dst, &src));
3810    }
3811}