Skip to main content

zlayer_agent/runtimes/
overlay_rootfs.rs

1//! Overlayfs-backed container rootfs (Wave 1 foundations — not yet wired).
2//!
3//! Today every container's rootfs is a full unpacked COPY of all of its image's
4//! layers (see `runtimes::youki`'s use of
5//! [`LayerUnpacker::unpack_layers_from_files`](zlayer_registry::LayerUnpacker::unpack_layers_from_files)).
6//! That duplicates every layer's bytes per container and re-runs the whole
7//! extraction on each `create_container`.
8//!
9//! This module provides the additive building blocks for an overlayfs rootfs
10//! instead: each image layer is extracted ONCE into a shared, read-only
11//! `lowerdir` under [`ZLayerDirs::layer_store`](zlayer_paths::ZLayerDirs::layer_store),
12//! and a container's rootfs becomes an `overlay` mount stacking those shared
13//! lowerdirs under a fresh per-container `upperdir` (+ `workdir`). N containers
14//! of one image then share one copy of each layer and pay only the cheap mount.
15//!
16//! NOTHING here is wired into `create_container` yet — Wave 2 does that. This
17//! file only defines the pieces and proves them with root-gated tests, so
18//! runtime behaviour is unchanged.
19//!
20//! Linux-only: it relies on the kernel `overlay` filesystem and on
21//! [`LayerUnpacker::new_for_overlay`](zlayer_registry::LayerUnpacker::new_for_overlay),
22//! which writes `0:0` char-device / `trusted.overlay.opaque` whiteout markers
23//! that need `CAP_MKNOD` / `CAP_SYS_ADMIN`.
24//!
25//! The `mod overlay_rootfs;` declaration in `runtimes/mod.rs` is already
26//! `#[cfg(target_os = "linux")]`-gated, so this file only ever compiles on
27//! Linux — no in-file `cfg` guard is needed.
28
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, OnceLock};
32
33use tokio::sync::Mutex;
34use zlayer_registry::LayerUnpacker;
35
36use crate::error::{AgentError, Result};
37
38/// Sentinel file written beside an extracted layer's `fs/` dir once extraction
39/// has fully completed and been atomically published. Its presence means the
40/// layer is ready to use as a lowerdir; its absence means "extract me".
41const DONE_SENTINEL: &str = ".done";
42
43/// Sentinel for the ROOTLESS `fuse-overlayfs` extraction form. Distinct from
44/// [`DONE_SENTINEL`] so the kernel `fs/` and fuse `fs-fuse/` extractions of the
45/// same digest are tracked independently (their on-disk whiteout markers
46/// differ).
47const DONE_SENTINEL_FUSE: &str = ".done-fuse";
48
49/// Process-global single-flight registry of per-digest extraction locks.
50///
51/// Mirrors the single-flight ethos of `zlayer-registry`'s image-pull registry:
52/// two containers of the same image must not race to first-extract the SAME
53/// layer (double work, and a half-written temp dir). Keyed by the sanitized
54/// layer digest; the value is a per-digest async mutex held only for the
55/// duration of that digest's first extraction. Different digests extract fully
56/// in parallel.
57static EXTRACT_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
58
59/// Get (or lazily create) the per-digest async lock for `key`.
60async fn digest_lock(key: &str) -> Arc<Mutex<()>> {
61    let registry = EXTRACT_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
62    let mut guard = registry.lock().await;
63    Arc::clone(
64        guard
65            .entry(key.to_string())
66            .or_insert_with(|| Arc::new(Mutex::new(()))),
67    )
68}
69
70/// Extract one image layer into the shared layer store exactly once, returning
71/// the path to its read-only overlay `lowerdir` (`<layer_store>/<digest>/fs`).
72///
73/// `digest` is the layer's compressed digest (the dedup key); it is sanitized
74/// via [`zlayer_paths::sanitize_digest`] so the store directory name matches the
75/// staged blob filename used by the registry. If the `.done` sentinel already
76/// exists beside the `fs/` dir, the layer is reused as-is (no re-extraction).
77///
78/// Concurrency: the FIRST extraction of a given digest is serialized by a
79/// process-global per-digest lock (so two containers of one image don't both
80/// extract it); a second waiter re-checks the sentinel after acquiring the lock
81/// and short-circuits. Extraction is crash-safe: it writes into a sibling temp
82/// dir, then atomically `rename(2)`s it into place and only then writes the
83/// sentinel — a partially-extracted dir is never visible as `<digest>/fs`.
84///
85/// # Errors
86///
87/// Returns an error if the store dirs can't be created, the layer can't be
88/// extracted (decompression / whiteout-marker failures, e.g. missing
89/// `CAP_MKNOD`), or the atomic publish (rename) fails.
90pub async fn extract_layer_once(
91    layer_store: &Path,
92    digest: &str,
93    layer_file: &Path,
94    media_type: &str,
95) -> Result<PathBuf> {
96    extract_layer_once_inner(
97        layer_store,
98        digest,
99        layer_file,
100        media_type,
101        OverlayBackend::Kernel,
102    )
103    .await
104}
105
106/// Rootless (`fuse-overlayfs`) counterpart of [`extract_layer_once`].
107///
108/// Identical single-flight / atomic-publish machinery, but the layer is
109/// extracted with the ROOTLESS whiteout convention (`.wh.<name>` files +
110/// `user.overlay.opaque` xattr) into a SEPARATE per-digest subdir
111/// (`<layer_store>/<digest>/fs-fuse`) so it can never collide with the kernel
112/// path's `fs` subdir — the two on-disk whiteout representations differ, and a
113/// host that flips between backends must not reuse the wrong-form layer. The
114/// `.done-fuse` sentinel is likewise distinct from the kernel `.done`.
115///
116/// Needs no `CAP_MKNOD` / `CAP_SYS_ADMIN`.
117///
118/// # Errors
119///
120/// Same shape as [`extract_layer_once`]: store-dir creation, extraction, or the
121/// atomic publish failing.
122pub async fn extract_layer_once_rootless(
123    layer_store: &Path,
124    digest: &str,
125    layer_file: &Path,
126    media_type: &str,
127) -> Result<PathBuf> {
128    extract_layer_once_inner(
129        layer_store,
130        digest,
131        layer_file,
132        media_type,
133        OverlayBackend::Fuse,
134    )
135    .await
136}
137
138/// Which overlay backend a layer-store extraction targets. The on-disk whiteout
139/// representation differs between the two (kernel: `0:0` char devices +
140/// `trusted.overlay.opaque`; fuse: `.wh.<name>` files + `user.overlay.opaque`),
141/// so each backend gets its OWN per-digest subdir + sentinel and the two never
142/// share bytes.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum OverlayBackend {
145    /// Kernel `overlay` filesystem (root / `CAP_SYS_ADMIN`).
146    Kernel,
147    /// Userspace `fuse-overlayfs` (rootless).
148    Fuse,
149}
150
151/// The rootfs build strategy chosen for a container, given the daemon's overlay
152/// capabilities. Pure selection so the order is unit-testable without building a
153/// whole runtime.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum RootfsStrategy {
156    /// Kernel `overlay` mount (shared layers; needs root / `CAP_SYS_ADMIN`).
157    KernelOverlay,
158    /// Rootless `fuse-overlayfs` mount (shared layers; no privilege).
159    FuseOverlay,
160    /// Full per-container copy of every layer (no dedup; last resort).
161    FullCopy,
162}
163
164/// Choose the rootfs strategy: kernel overlay first (cheapest, most native),
165/// else rootless fuse-overlayfs (still shared-layer dedup), else a full copy.
166/// Both overlay strategies share the per-digest layer store; only `FullCopy`
167/// duplicates layer bytes per container.
168#[must_use]
169pub const fn select_rootfs_strategy(
170    kernel_overlay_available: bool,
171    fuse_overlay_available: bool,
172) -> RootfsStrategy {
173    if kernel_overlay_available {
174        RootfsStrategy::KernelOverlay
175    } else if fuse_overlay_available {
176        RootfsStrategy::FuseOverlay
177    } else {
178        RootfsStrategy::FullCopy
179    }
180}
181
182impl OverlayBackend {
183    /// Per-digest subdir name holding the extracted layer for this backend.
184    const fn fs_subdir(self) -> &'static str {
185        match self {
186            Self::Kernel => "fs",
187            Self::Fuse => "fs-fuse",
188        }
189    }
190
191    /// Sentinel file name (beside the subdir) marking a completed extraction.
192    const fn sentinel(self) -> &'static str {
193        match self {
194            Self::Kernel => DONE_SENTINEL,
195            Self::Fuse => DONE_SENTINEL_FUSE,
196        }
197    }
198}
199
200/// Shared single-flight + atomic-publish extraction for both overlay backends.
201/// The only backend-specific bits are the per-digest subdir name, the sentinel
202/// name, and which whiteout-preserving unpacker is used.
203async fn extract_layer_once_inner(
204    layer_store: &Path,
205    digest: &str,
206    layer_file: &Path,
207    media_type: &str,
208    backend: OverlayBackend,
209) -> Result<PathBuf> {
210    let key = zlayer_paths::sanitize_digest(digest);
211    let layer_dir = layer_store.join(&key);
212    let fs_dir = layer_dir.join(backend.fs_subdir());
213    let sentinel = layer_dir.join(backend.sentinel());
214
215    // Fast path: already extracted and published.
216    if sentinel.exists() {
217        return Ok(fs_dir);
218    }
219
220    // Serialize the first extraction of THIS (digest, backend) pair. Keying on
221    // both means the kernel and fuse extraction of the same digest can proceed
222    // in parallel (they write disjoint subdirs) instead of needlessly blocking
223    // each other.
224    let lock = digest_lock(&format!("{key}:{}", backend.fs_subdir())).await;
225    let _held = lock.lock().await;
226
227    // Re-check under the lock: a peer may have published while we waited.
228    if sentinel.exists() {
229        return Ok(fs_dir);
230    }
231
232    std::fs::create_dir_all(&layer_dir).map_err(|e| {
233        AgentError::Configuration(format!(
234            "failed to create layer-store dir {}: {e}",
235            layer_dir.display()
236        ))
237    })?;
238
239    // Extract into a sibling temp dir (same parent => same filesystem => the
240    // publish is an atomic rename, never a cross-device copy). A leftover temp
241    // from a previous crash is cleared first. The staging name is
242    // backend-specific so a concurrent kernel+fuse extract of one digest can't
243    // clobber each other's partial tree.
244    let staging = layer_dir.join(format!("{}.partial", backend.fs_subdir()));
245    if staging.exists() {
246        std::fs::remove_dir_all(&staging).map_err(|e| {
247            AgentError::Configuration(format!(
248                "failed to clear stale staging dir {}: {e}",
249                staging.display()
250            ))
251        })?;
252    }
253
254    match backend {
255        OverlayBackend::Kernel => {
256            let mut unpacker = LayerUnpacker::new_for_overlay(staging.clone());
257            unpacker
258                .unpack_layer_to_overlay_dir(layer_file, media_type)
259                .map_err(|e| {
260                    let _ = std::fs::remove_dir_all(&staging);
261                    AgentError::Configuration(format!(
262                        "failed to extract layer {digest} into overlay lowerdir: {e}"
263                    ))
264                })?;
265        }
266        OverlayBackend::Fuse => {
267            let mut unpacker = LayerUnpacker::new_for_overlay_rootless(staging.clone());
268            unpacker
269                .unpack_layer_to_overlay_dir_rootless(layer_file, media_type)
270                .map_err(|e| {
271                    let _ = std::fs::remove_dir_all(&staging);
272                    AgentError::Configuration(format!(
273                        "failed to extract layer {digest} into rootless overlay lowerdir: {e}"
274                    ))
275                })?;
276        }
277    }
278
279    // Atomic publish: rename the completed tree into place, then drop the
280    // sentinel. If the target subdir somehow already exists (e.g. an aborted
281    // prior rename), remove it first so the rename is clean.
282    if fs_dir.exists() {
283        let _ = std::fs::remove_dir_all(&fs_dir);
284    }
285    std::fs::rename(&staging, &fs_dir).map_err(|e| {
286        let _ = std::fs::remove_dir_all(&staging);
287        AgentError::Configuration(format!(
288            "failed to publish extracted layer {} -> {}: {e}",
289            staging.display(),
290            fs_dir.display()
291        ))
292    })?;
293
294    std::fs::write(&sentinel, b"").map_err(|e| {
295        AgentError::Configuration(format!(
296            "failed to write layer sentinel {}: {e}",
297            sentinel.display()
298        ))
299    })?;
300
301    Ok(fs_dir)
302}
303
304/// Mount an overlayfs rootfs at `target`, stacking `lower_dirs` under `upper`
305/// (+ `work`).
306///
307/// `lower_dirs` MUST be in OCI base-first order (the order layers are applied
308/// when merging). The overlay `lowerdir=` option lists layers TOP-first
309/// (leftmost = highest priority), so this reverses the slice: the last
310/// (topmost) image layer ends up leftmost. `upper` and `work` are created if
311/// absent (the caller owns their lifetime — they hold the container's writes).
312///
313/// # Errors
314///
315/// Returns an error if there are no lower dirs, the upper/work dirs can't be
316/// created, or the `mount(2)` fails (needs root / `CAP_SYS_ADMIN`).
317pub fn mount_overlay_rootfs(
318    lower_dirs: &[PathBuf],
319    upper: &Path,
320    work: &Path,
321    target: &Path,
322) -> Result<()> {
323    use nix::mount::{mount, MsFlags};
324
325    if lower_dirs.is_empty() {
326        return Err(AgentError::Configuration(
327            "overlay rootfs requires at least one lower dir".to_string(),
328        ));
329    }
330
331    for d in [upper, work, target] {
332        std::fs::create_dir_all(d).map_err(|e| {
333            AgentError::Configuration(format!("failed to create overlay dir {}: {e}", d.display()))
334        })?;
335    }
336
337    // overlay `lowerdir=` is TOP-first; OCI layers are base-first, so reverse.
338    let lower_joined = lower_dirs
339        .iter()
340        .rev()
341        .map(|p| p.display().to_string())
342        .collect::<Vec<_>>()
343        .join(":");
344
345    let opts = format!(
346        "lowerdir={lower_joined},upperdir={},workdir={}",
347        upper.display(),
348        work.display()
349    );
350
351    mount(
352        Some("overlay"),
353        target,
354        Some("overlay"),
355        MsFlags::empty(),
356        Some(opts.as_str()),
357    )
358    .map_err(|e| {
359        AgentError::Configuration(format!(
360            "failed to mount overlay rootfs at {} (opts: {opts}): {e}",
361            target.display()
362        ))
363    })?;
364
365    Ok(())
366}
367
368/// Mount a ROOTLESS `fuse-overlayfs` rootfs at `target`, stacking `lower_dirs`
369/// under `upper` (+ `work`) by spawning the `fuse-overlayfs` userspace binary.
370///
371/// Like [`mount_overlay_rootfs`] but uses the FUSE backend, so it needs neither
372/// `CAP_SYS_ADMIN` nor `CAP_MKNOD` — only the `fuse-overlayfs` binary and
373/// `/dev/fuse`. The lowerdirs MUST have been extracted with the ROOTLESS
374/// whiteout convention (see [`extract_layer_once_rootless`]).
375///
376/// Lowerdir ordering matches the kernel path: `lower_dirs` is OCI base-first and
377/// the `lowerdir=` option is TOP-first, so the slice is reversed.
378///
379/// Lifecycle: `fuse-overlayfs` self-daemonizes (the backing FUSE daemon
380/// reparents to PID 1) and the spawned process returns promptly, so the mount
381/// survives the short-lived `zlayer runtime create` subprocess that calls this
382/// — we wait on the spawned process (it exits as soon as the daemon is up) and
383/// do NOT hold a child handle. The `-o noacl` option avoids ACL-passthrough
384/// surprises on backing filesystems that don't support them.
385///
386/// # Errors
387///
388/// Returns an error if there are no lower dirs, the upper/work/target dirs can't
389/// be created, the `fuse-overlayfs` binary can't be located or spawned, or it
390/// exits non-zero.
391pub fn mount_fuse_overlay_rootfs(
392    fuse_overlayfs_bin: &Path,
393    lower_dirs: &[PathBuf],
394    upper: &Path,
395    work: &Path,
396    target: &Path,
397) -> Result<()> {
398    if lower_dirs.is_empty() {
399        return Err(AgentError::Configuration(
400            "fuse overlay rootfs requires at least one lower dir".to_string(),
401        ));
402    }
403
404    for d in [upper, work, target] {
405        std::fs::create_dir_all(d).map_err(|e| {
406            AgentError::Configuration(format!(
407                "failed to create fuse overlay dir {}: {e}",
408                d.display()
409            ))
410        })?;
411    }
412
413    // `lowerdir=` is TOP-first; OCI layers are base-first, so reverse.
414    let lower_joined = lower_dirs
415        .iter()
416        .rev()
417        .map(|p| p.display().to_string())
418        .collect::<Vec<_>>()
419        .join(":");
420
421    let opts = format!(
422        "lowerdir={lower_joined},upperdir={},workdir={},noacl",
423        upper.display(),
424        work.display()
425    );
426
427    let output = std::process::Command::new(fuse_overlayfs_bin)
428        .arg("-o")
429        .arg(&opts)
430        .arg(target)
431        .stdin(std::process::Stdio::null())
432        .output()
433        .map_err(|e| {
434            AgentError::Configuration(format!(
435                "failed to spawn fuse-overlayfs ({}) for {}: {e}",
436                fuse_overlayfs_bin.display(),
437                target.display()
438            ))
439        })?;
440
441    if !output.status.success() {
442        let stderr = String::from_utf8_lossy(&output.stderr);
443        return Err(AgentError::Configuration(format!(
444            "fuse-overlayfs mount at {} failed (opts: {opts}): status {}, stderr: {}",
445            target.display(),
446            output.status,
447            stderr.trim()
448        )));
449    }
450
451    Ok(())
452}
453
454/// Unmount an overlay rootfs previously mounted at `target`, handling BOTH the
455/// kernel `overlay` backend and the userspace `fuse.fuse-overlayfs` backend.
456///
457/// The mount's filesystem TYPE (read from `/proc/mounts`, the 3rd field)
458/// decides the teardown: a `fuse.*` mount is unmounted with `fusermount -u`
459/// (the only way to release a FUSE mount without `CAP_SYS_ADMIN`); a kernel
460/// `overlay` mount uses `umount(2)` with an `MNT_DETACH` lazy-unmount fallback
461/// on `EBUSY`. A target that is not currently a mountpoint is an idempotent
462/// no-op (covers the plain-copy fallback bundles).
463///
464/// # Errors
465///
466/// Returns an error only if the relevant unmount path fails for a reason other
467/// than "not mounted".
468pub fn unmount_overlay_rootfs(target: &Path) -> Result<()> {
469    // Idempotent: nothing mounted here.
470    let Some(fstype) = mount_fstype(target) else {
471        return Ok(());
472    };
473
474    if fstype.starts_with("fuse") {
475        return unmount_fuse_overlay_rootfs(target);
476    }
477
478    unmount_kernel_overlay_rootfs(target)
479}
480
481/// Unmount a kernel `overlay` mount: eager `umount(2)`, lazy `MNT_DETACH` on
482/// `EBUSY`, treating "not a mount" as idempotent success.
483fn unmount_kernel_overlay_rootfs(target: &Path) -> Result<()> {
484    use nix::errno::Errno;
485    use nix::mount::{umount, umount2, MntFlags};
486
487    match umount(target) {
488        Ok(()) | Err(Errno::EINVAL | Errno::ENOENT) => Ok(()),
489        Err(Errno::EBUSY) => match umount2(target, MntFlags::MNT_DETACH) {
490            Ok(()) | Err(Errno::EINVAL | Errno::ENOENT) => Ok(()),
491            Err(e) => Err(AgentError::Configuration(format!(
492                "failed to lazily unmount overlay rootfs at {}: {e}",
493                target.display()
494            ))),
495        },
496        Err(e) => Err(AgentError::Configuration(format!(
497            "failed to unmount overlay rootfs at {}: {e}",
498            target.display()
499        ))),
500    }
501}
502
503/// Unmount a `fuse.fuse-overlayfs` mount via `fusermount3 -u` (falling back to
504/// `fusermount -u`). This is the privilege-free way to release a FUSE mount and
505/// reap its backing daemon. If the helper exits non-zero but the target is no
506/// longer a mountpoint (raced with another unmount), it's treated as success.
507///
508/// **`EBUSY` retry + lazy fallback.** A FUSE unmount races the kernel and the
509/// `fuse-overlayfs` daemon: right after the last user of the merged tree goes
510/// away, the kernel may still hold a transient reference (a just-closed fd, an
511/// in-flight `getdents`, a child that hasn't fully reaped) and `fusermount -u`
512/// reports `Device or resource busy`. This is not a real failure — the
513/// reference drains within milliseconds. So we retry the plain `-u` a few times
514/// with a short backoff, and if it is *still* busy on the final attempt we issue
515/// a lazy unmount (`-u -z`, the FUSE analogue of `umount2(MNT_DETACH)`): the
516/// mount is detached from the namespace immediately and the backing daemon reaps
517/// once the last reference drops. A target that is no longer a mountpoint at any
518/// point is idempotent success. This mirrors the eager-then-lazy strategy
519/// [`unmount_kernel_overlay_rootfs`] uses for kernel `overlay` mounts and makes
520/// teardown robust under load (concurrent container stops, prune sweeps).
521fn unmount_fuse_overlay_rootfs(target: &Path) -> Result<()> {
522    /// How many times to retry a plain `-u` that reports the mount busy before
523    /// escalating to a lazy `-u -z` detach.
524    const MAX_BUSY_RETRIES: u32 = 5;
525    /// Base backoff between busy retries (grows linearly: 20ms, 40ms, …).
526    const BUSY_BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
527
528    let Some(fusermount) = crate::capability::fusermount_binary() else {
529        return Err(AgentError::Configuration(format!(
530            "cannot unmount fuse overlay rootfs at {}: neither fusermount3 nor fusermount on PATH",
531            target.display()
532        )));
533    };
534
535    // Helper: run `fusermount <extra-args> -u <target>` once.
536    let run = |lazy: bool| -> Result<std::process::Output> {
537        let mut cmd = std::process::Command::new(&fusermount);
538        cmd.arg("-u");
539        if lazy {
540            // `-z` = lazy/detach unmount (supported by both fusermount and
541            // fusermount3); detach now, reap the daemon when refs drain.
542            cmd.arg("-z");
543        }
544        cmd.arg(target)
545            .stdin(std::process::Stdio::null())
546            .output()
547            .map_err(|e| {
548                AgentError::Configuration(format!(
549                    "failed to spawn {} -u {}: {e}",
550                    fusermount.display(),
551                    target.display()
552                ))
553            })
554    };
555
556    let mut last_stderr = String::new();
557    for attempt in 0..=MAX_BUSY_RETRIES {
558        let output = run(false)?;
559        // Success, or the target is already not a mountpoint (raced with another
560        // unmount / the daemon already exited) — both are clean.
561        if output.status.success() || mount_fstype(target).is_none() {
562            return Ok(());
563        }
564
565        last_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
566        let busy = last_stderr.to_ascii_lowercase().contains("busy");
567        if !busy {
568            // A non-busy failure won't clear by waiting; report it now.
569            return Err(AgentError::Configuration(format!(
570                "fusermount -u failed for {}: status {}, stderr: {}",
571                target.display(),
572                output.status,
573                last_stderr
574            )));
575        }
576
577        // Busy: back off and retry the eager unmount, unless we've exhausted
578        // the retry budget (then fall through to the lazy detach below).
579        if attempt < MAX_BUSY_RETRIES {
580            std::thread::sleep(BUSY_BACKOFF * (attempt + 1));
581        }
582    }
583
584    // Still busy after every eager retry: lazy-detach so teardown completes.
585    let lazy = run(true)?;
586    if lazy.status.success() || mount_fstype(target).is_none() {
587        tracing::debug!(
588            target = %target.display(),
589            "fuse overlay rootfs was busy; completed teardown via lazy (-z) unmount",
590        );
591        return Ok(());
592    }
593
594    let lazy_stderr = String::from_utf8_lossy(&lazy.stderr);
595    Err(AgentError::Configuration(format!(
596        "fusermount -u failed for {} after {} busy retries (last stderr: {}); \
597         lazy -uz also failed: status {}, stderr: {}",
598        target.display(),
599        MAX_BUSY_RETRIES,
600        last_stderr,
601        lazy.status,
602        lazy_stderr.trim()
603    )))
604}
605
606/// The filesystem TYPE (3rd `/proc/mounts` field, e.g. `overlay` or
607/// `fuse.fuse-overlayfs`) of the mount whose mountpoint (2nd field) equals
608/// `path`, or `None` if `path` is not currently a mountpoint.
609///
610/// Octal escapes (`\040` for space, etc.) in the mountpoint field are left as-is
611/// because overlay rootfs targets under the data dir never contain such
612/// characters. When a path is mounted more than once (stacked), the LAST
613/// matching line wins — that's the mount currently visible at the path, the one
614/// a single `umount`/`fusermount -u` pops.
615fn mount_fstype(path: &Path) -> Option<String> {
616    let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
617    let want = path.to_string_lossy();
618    let mut found: Option<String> = None;
619    for line in mounts.lines() {
620        let mut fields = line.split_whitespace();
621        let _src = fields.next();
622        let mountpoint = fields.next();
623        let fstype = fields.next();
624        if mountpoint == Some(want.as_ref()) {
625            if let Some(ty) = fstype {
626                found = Some(ty.to_string());
627            }
628        }
629    }
630    found
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    /// Uncompressed-tar OCI layer media type (zlayer-agent has no gzip dep, so
638    /// the tests build plain tars and pass this).
639    const TAR_MT: &str = "application/vnd.oci.image.layer.v1.tar";
640
641    /// Root-gated helper: skip (and say why) when not uid 0, since extraction
642    /// (mknod) and the overlay mount need `CAP_MKNOD` / `CAP_SYS_ADMIN`.
643    fn require_root_or_skip(test: &str) -> bool {
644        if zlayer_paths::is_root() {
645            return true;
646        }
647        eprintln!("skipping {test}: requires root (CAP_MKNOD / CAP_SYS_ADMIN)");
648        false
649    }
650
651    /// Build a plain (uncompressed) layer tar in memory.
652    fn tar_layer(files: &[(&str, &[u8])]) -> Vec<u8> {
653        let mut builder = tar::Builder::new(Vec::new());
654        for (path, content) in files {
655            let mut h = tar::Header::new_gnu();
656            h.set_path(path).unwrap();
657            h.set_size(content.len() as u64);
658            h.set_mode(0o644);
659            h.set_cksum();
660            builder.append(&h, *content).unwrap();
661        }
662        builder.into_inner().unwrap()
663    }
664
665    #[tokio::test]
666    async fn extract_layer_once_is_idempotent_and_writes_sentinel() {
667        if !require_root_or_skip("extract_layer_once_is_idempotent_and_writes_sentinel") {
668            return;
669        }
670        let tmp = tempfile::tempdir().unwrap();
671        let store = tmp.path().join("layers");
672        let layer = tar_layer(&[("etc/hostname", b"host\n")]);
673        let layer_file = tmp.path().join("layer0.tar");
674        std::fs::write(&layer_file, &layer).unwrap();
675
676        let digest = "sha256:deadbeef";
677        let fs1 = extract_layer_once(&store, digest, &layer_file, TAR_MT)
678            .await
679            .expect("first extract");
680        assert!(fs1.join("etc/hostname").exists(), "layer content extracted");
681        let sentinel = store
682            .join(zlayer_paths::sanitize_digest(digest))
683            .join(".done");
684        assert!(sentinel.exists(), ".done sentinel must be written");
685
686        // Second call short-circuits to the same path and leaves no staging.
687        let fs2 = extract_layer_once(&store, digest, &layer_file, TAR_MT)
688            .await
689            .expect("second extract (idempotent)");
690        assert_eq!(fs1, fs2);
691        assert!(
692            !fs1.with_file_name("fs.partial").exists(),
693            "no leftover staging dir"
694        );
695    }
696
697    #[tokio::test]
698    async fn mount_overlay_merges_lowers_and_writes_land_in_upper() {
699        if !require_root_or_skip("mount_overlay_merges_lowers_and_writes_land_in_upper") {
700            return;
701        }
702        let tmp = tempfile::tempdir().unwrap();
703        let store = tmp.path().join("layers");
704
705        // Two synthetic layers: base provides `base.txt`; top provides
706        // `top.txt` and overrides `shared.txt`.
707        let base = tar_layer(&[("base.txt", b"base"), ("shared.txt", b"from-base")]);
708        let top = tar_layer(&[("top.txt", b"top"), ("shared.txt", b"from-top")]);
709        let base_file = tmp.path().join("base.tar");
710        let top_file = tmp.path().join("top.tar");
711        std::fs::write(&base_file, &base).unwrap();
712        std::fs::write(&top_file, &top).unwrap();
713
714        let base_fs = extract_layer_once(&store, "sha256:base", &base_file, TAR_MT)
715            .await
716            .unwrap();
717        let top_fs = extract_layer_once(&store, "sha256:top", &top_file, TAR_MT)
718            .await
719            .unwrap();
720
721        // Base-first order; mount_overlay_rootfs reverses for `lowerdir=`.
722        let lowers = vec![base_fs, top_fs];
723        let upper = tmp.path().join("upper");
724        let work = tmp.path().join("work");
725        let merged = tmp.path().join("merged");
726
727        mount_overlay_rootfs(&lowers, &upper, &work, &merged).expect("overlay mount");
728
729        // Merged view: both files visible, the TOP layer wins for `shared.txt`.
730        assert_eq!(std::fs::read(merged.join("base.txt")).unwrap(), b"base");
731        assert_eq!(std::fs::read(merged.join("top.txt")).unwrap(), b"top");
732        assert_eq!(
733            std::fs::read(merged.join("shared.txt")).unwrap(),
734            b"from-top",
735            "topmost layer must win in the merged overlay view"
736        );
737
738        // A write into the merged view must land in the upperdir, not a lower.
739        std::fs::write(merged.join("written.txt"), b"hi").unwrap();
740        assert_eq!(
741            std::fs::read(upper.join("written.txt")).unwrap(),
742            b"hi",
743            "writes through the overlay must materialize in the upperdir"
744        );
745
746        // Unmount and confirm /proc/mounts no longer lists the target.
747        unmount_overlay_rootfs(&merged).expect("unmount");
748        let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
749        let merged_str = merged.display().to_string();
750        assert!(
751            !mounts.lines().any(|l| l.contains(&merged_str)),
752            "target must no longer appear in /proc/mounts after unmount"
753        );
754    }
755
756    #[tokio::test]
757    async fn unmount_is_idempotent_on_unmounted_target() {
758        // No root needed: unmounting a never-mounted dir must be a no-op.
759        let tmp = tempfile::tempdir().unwrap();
760        let target = tmp.path().join("not-a-mount");
761        std::fs::create_dir_all(&target).unwrap();
762        unmount_overlay_rootfs(&target).expect("unmount of non-mount is success");
763    }
764
765    #[test]
766    fn mount_requires_at_least_one_lower() {
767        let tmp = tempfile::tempdir().unwrap();
768        let err = mount_overlay_rootfs(
769            &[],
770            &tmp.path().join("u"),
771            &tmp.path().join("w"),
772            &tmp.path().join("m"),
773        )
774        .expect_err("empty lowers must error");
775        assert!(err.to_string().contains("at least one lower"));
776    }
777
778    #[test]
779    fn selection_order_kernel_then_fuse_then_copy() {
780        // Kernel wins whenever available, regardless of fuse.
781        assert_eq!(
782            select_rootfs_strategy(true, true),
783            RootfsStrategy::KernelOverlay
784        );
785        assert_eq!(
786            select_rootfs_strategy(true, false),
787            RootfsStrategy::KernelOverlay
788        );
789        // Fuse is chosen only when kernel is unavailable but fuse is — the exact
790        // rootless case this feature adds.
791        assert_eq!(
792            select_rootfs_strategy(false, true),
793            RootfsStrategy::FuseOverlay
794        );
795        // Neither overlay backend → full per-container copy.
796        assert_eq!(
797            select_rootfs_strategy(false, false),
798            RootfsStrategy::FullCopy
799        );
800    }
801
802    #[test]
803    fn fuse_mount_requires_at_least_one_lower() {
804        let tmp = tempfile::tempdir().unwrap();
805        let err = mount_fuse_overlay_rootfs(
806            std::path::Path::new("/nonexistent/fuse-overlayfs"),
807            &[],
808            &tmp.path().join("u"),
809            &tmp.path().join("w"),
810            &tmp.path().join("m"),
811        )
812        .expect_err("empty lowers must error before any spawn");
813        assert!(err.to_string().contains("at least one lower"));
814    }
815
816    /// The two backends key into DISJOINT per-digest subdirs (`fs` vs `fs-fuse`)
817    /// and sentinels (`.done` vs `.done-fuse`), so a kernel and a rootless
818    /// extract of the same digest never collide. No privilege needed — the fuse
819    /// extract writes `.wh.` files + `user.*` xattrs.
820    #[tokio::test]
821    async fn kernel_and_fuse_layer_store_dirs_do_not_collide() {
822        let tmp = tempfile::tempdir().unwrap();
823        let store = tmp.path().join("layers");
824        let layer = tar_layer(&[("etc/hostname", b"host\n")]);
825        let layer_file = tmp.path().join("layer.tar");
826        std::fs::write(&layer_file, &layer).unwrap();
827        let digest = "sha256:cafe";
828        let key = zlayer_paths::sanitize_digest(digest);
829
830        // Rootless extract works without root.
831        let fuse_fs = extract_layer_once_rootless(&store, digest, &layer_file, TAR_MT)
832            .await
833            .expect("rootless extract");
834        assert_eq!(fuse_fs, store.join(&key).join("fs-fuse"));
835        assert!(fuse_fs.join("etc/hostname").exists());
836        assert!(
837            store.join(&key).join(".done-fuse").exists(),
838            ".done-fuse sentinel written"
839        );
840        // The kernel `fs/` form must NOT have been created by the fuse extract.
841        assert!(
842            !store.join(&key).join("fs").exists(),
843            "fuse extract must not populate the kernel `fs` subdir"
844        );
845        assert!(!store.join(&key).join(".done").exists());
846
847        // Idempotent: a second rootless extract short-circuits, no leftover stage.
848        let fuse_fs2 = extract_layer_once_rootless(&store, digest, &layer_file, TAR_MT)
849            .await
850            .expect("second rootless extract");
851        assert_eq!(fuse_fs, fuse_fs2);
852        assert!(!store.join(&key).join("fs-fuse.partial").exists());
853    }
854
855    /// Locate the `fuse-overlayfs` binary, or `None` to skip the live tests.
856    fn fuse_overlayfs_bin_or_skip(test: &str) -> Option<std::path::PathBuf> {
857        let path_var = std::env::var("PATH").unwrap_or_default();
858        for dir in path_var.split(':').filter(|d| !d.is_empty()) {
859            let candidate = std::path::Path::new(dir).join("fuse-overlayfs");
860            if candidate.exists() {
861                return Some(candidate);
862            }
863        }
864        eprintln!("skipping {test}: fuse-overlayfs not installed (PATH scan found nothing)");
865        None
866    }
867
868    /// End-to-end ROOTLESS path (no root, no `CAP_SYS_ADMIN`): extract two layers
869    /// with the rootless whiteout convention, mount them via `fuse-overlayfs`,
870    /// assert the merged view (topmost layer wins, whiteout hides a lower file),
871    /// assert writes land in the upperdir, then `fusermount -u` and confirm the
872    /// mount is gone. Skips with a message when `fuse-overlayfs` is absent.
873    #[tokio::test]
874    async fn fuse_overlay_end_to_end_rootless_merge_write_unmount() {
875        let Some(bin) =
876            fuse_overlayfs_bin_or_skip("fuse_overlay_end_to_end_rootless_merge_write_unmount")
877        else {
878            return;
879        };
880        // /dev/fuse must be usable; otherwise the mount cannot back itself.
881        if std::fs::OpenOptions::new()
882            .read(true)
883            .write(true)
884            .open("/dev/fuse")
885            .is_err()
886        {
887            eprintln!("skipping: /dev/fuse not openable");
888            return;
889        }
890
891        let tmp = tempfile::tempdir().unwrap();
892        let store = tmp.path().join("layers");
893
894        // base: provides base.txt, shared.txt, and gone.txt.
895        // top: provides top.txt, overrides shared.txt, and whites out gone.txt.
896        let base = tar_layer(&[
897            ("base.txt", b"base"),
898            ("shared.txt", b"from-base"),
899            ("gone.txt", b"doomed"),
900        ]);
901        let mut top_builder = tar::Builder::new(Vec::new());
902        for (p, c) in [("top.txt", &b"top"[..]), ("shared.txt", &b"from-top"[..])] {
903            let mut h = tar::Header::new_gnu();
904            h.set_path(p).unwrap();
905            h.set_size(c.len() as u64);
906            h.set_mode(0o644);
907            h.set_cksum();
908            top_builder.append(&h, c).unwrap();
909        }
910        // Whiteout gone.txt (rootless `.wh.gone.txt` file).
911        let mut wh = tar::Header::new_gnu();
912        wh.set_path(".wh.gone.txt").unwrap();
913        wh.set_size(0);
914        wh.set_mode(0o644);
915        wh.set_cksum();
916        top_builder.append(&wh, std::io::empty()).unwrap();
917        let top = top_builder.into_inner().unwrap();
918
919        let base_file = tmp.path().join("base.tar");
920        let top_file = tmp.path().join("top.tar");
921        std::fs::write(&base_file, &base).unwrap();
922        std::fs::write(&top_file, &top).unwrap();
923
924        let base_fs = extract_layer_once_rootless(&store, "sha256:base", &base_file, TAR_MT)
925            .await
926            .unwrap();
927        let top_fs = extract_layer_once_rootless(&store, "sha256:top", &top_file, TAR_MT)
928            .await
929            .unwrap();
930
931        let lowers = vec![base_fs, top_fs]; // base-first
932        let upper = tmp.path().join("upper");
933        let work = tmp.path().join("work");
934        let merged = tmp.path().join("merged");
935
936        mount_fuse_overlay_rootfs(&bin, &lowers, &upper, &work, &merged)
937            .expect("rootless fuse-overlayfs mount");
938
939        // Merged view assertions.
940        assert_eq!(std::fs::read(merged.join("base.txt")).unwrap(), b"base");
941        assert_eq!(std::fs::read(merged.join("top.txt")).unwrap(), b"top");
942        assert_eq!(
943            std::fs::read(merged.join("shared.txt")).unwrap(),
944            b"from-top",
945            "topmost layer wins"
946        );
947        assert!(
948            !merged.join("gone.txt").exists(),
949            "rootless .wh. whiteout must hide the lower gone.txt"
950        );
951
952        // Write goes to the upperdir.
953        std::fs::write(merged.join("written.txt"), b"hi").unwrap();
954        assert_eq!(
955            std::fs::read(upper.join("written.txt")).unwrap(),
956            b"hi",
957            "writes materialize in the upperdir"
958        );
959
960        // /proc/mounts reports the fuse type, and unmount uses fusermount.
961        assert!(
962            mount_fstype(&merged).is_some_and(|t| t.starts_with("fuse")),
963            "merged must be a fuse.* mount"
964        );
965        unmount_overlay_rootfs(&merged).expect("fusermount -u via unmount_overlay_rootfs");
966        assert!(
967            mount_fstype(&merged).is_none(),
968            "target must be unmounted after unmount_overlay_rootfs"
969        );
970    }
971}