use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use tokio::sync::Mutex;
use zlayer_registry::LayerUnpacker;
use crate::error::{AgentError, Result};
const DONE_SENTINEL: &str = ".done";
const DONE_SENTINEL_FUSE: &str = ".done-fuse";
static EXTRACT_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
async fn digest_lock(key: &str) -> Arc<Mutex<()>> {
let registry = EXTRACT_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = registry.lock().await;
Arc::clone(
guard
.entry(key.to_string())
.or_insert_with(|| Arc::new(Mutex::new(()))),
)
}
pub async fn extract_layer_once(
layer_store: &Path,
digest: &str,
layer_file: &Path,
media_type: &str,
) -> Result<PathBuf> {
extract_layer_once_inner(
layer_store,
digest,
layer_file,
media_type,
OverlayBackend::Kernel,
)
.await
}
pub async fn extract_layer_once_rootless(
layer_store: &Path,
digest: &str,
layer_file: &Path,
media_type: &str,
) -> Result<PathBuf> {
extract_layer_once_inner(
layer_store,
digest,
layer_file,
media_type,
OverlayBackend::Fuse,
)
.await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverlayBackend {
Kernel,
Fuse,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RootfsStrategy {
KernelOverlay,
FuseOverlay,
FullCopy,
}
#[must_use]
pub const fn select_rootfs_strategy(
kernel_overlay_available: bool,
fuse_overlay_available: bool,
) -> RootfsStrategy {
if kernel_overlay_available {
RootfsStrategy::KernelOverlay
} else if fuse_overlay_available {
RootfsStrategy::FuseOverlay
} else {
RootfsStrategy::FullCopy
}
}
impl OverlayBackend {
const fn fs_subdir(self) -> &'static str {
match self {
Self::Kernel => "fs",
Self::Fuse => "fs-fuse",
}
}
const fn sentinel(self) -> &'static str {
match self {
Self::Kernel => DONE_SENTINEL,
Self::Fuse => DONE_SENTINEL_FUSE,
}
}
}
async fn extract_layer_once_inner(
layer_store: &Path,
digest: &str,
layer_file: &Path,
media_type: &str,
backend: OverlayBackend,
) -> Result<PathBuf> {
let key = zlayer_paths::sanitize_digest(digest);
let layer_dir = layer_store.join(&key);
let fs_dir = layer_dir.join(backend.fs_subdir());
let sentinel = layer_dir.join(backend.sentinel());
if sentinel.exists() {
return Ok(fs_dir);
}
let lock = digest_lock(&format!("{key}:{}", backend.fs_subdir())).await;
let _held = lock.lock().await;
if sentinel.exists() {
return Ok(fs_dir);
}
std::fs::create_dir_all(&layer_dir).map_err(|e| {
AgentError::Configuration(format!(
"failed to create layer-store dir {}: {e}",
layer_dir.display()
))
})?;
let staging = layer_dir.join(format!("{}.partial", backend.fs_subdir()));
if staging.exists() {
std::fs::remove_dir_all(&staging).map_err(|e| {
AgentError::Configuration(format!(
"failed to clear stale staging dir {}: {e}",
staging.display()
))
})?;
}
match backend {
OverlayBackend::Kernel => {
let mut unpacker = LayerUnpacker::new_for_overlay(staging.clone());
unpacker
.unpack_layer_to_overlay_dir(layer_file, media_type)
.map_err(|e| {
let _ = std::fs::remove_dir_all(&staging);
AgentError::Configuration(format!(
"failed to extract layer {digest} into overlay lowerdir: {e}"
))
})?;
}
OverlayBackend::Fuse => {
let mut unpacker = LayerUnpacker::new_for_overlay_rootless(staging.clone());
unpacker
.unpack_layer_to_overlay_dir_rootless(layer_file, media_type)
.map_err(|e| {
let _ = std::fs::remove_dir_all(&staging);
AgentError::Configuration(format!(
"failed to extract layer {digest} into rootless overlay lowerdir: {e}"
))
})?;
}
}
if fs_dir.exists() {
let _ = std::fs::remove_dir_all(&fs_dir);
}
std::fs::rename(&staging, &fs_dir).map_err(|e| {
let _ = std::fs::remove_dir_all(&staging);
AgentError::Configuration(format!(
"failed to publish extracted layer {} -> {}: {e}",
staging.display(),
fs_dir.display()
))
})?;
std::fs::write(&sentinel, b"").map_err(|e| {
AgentError::Configuration(format!(
"failed to write layer sentinel {}: {e}",
sentinel.display()
))
})?;
Ok(fs_dir)
}
pub fn mount_overlay_rootfs(
lower_dirs: &[PathBuf],
upper: &Path,
work: &Path,
target: &Path,
) -> Result<()> {
use nix::mount::{mount, MsFlags};
if lower_dirs.is_empty() {
return Err(AgentError::Configuration(
"overlay rootfs requires at least one lower dir".to_string(),
));
}
for d in [upper, work, target] {
std::fs::create_dir_all(d).map_err(|e| {
AgentError::Configuration(format!("failed to create overlay dir {}: {e}", d.display()))
})?;
}
let lower_joined = lower_dirs
.iter()
.rev()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(":");
let opts = format!(
"lowerdir={lower_joined},upperdir={},workdir={}",
upper.display(),
work.display()
);
mount(
Some("overlay"),
target,
Some("overlay"),
MsFlags::empty(),
Some(opts.as_str()),
)
.map_err(|e| {
AgentError::Configuration(format!(
"failed to mount overlay rootfs at {} (opts: {opts}): {e}",
target.display()
))
})?;
Ok(())
}
pub fn mount_fuse_overlay_rootfs(
fuse_overlayfs_bin: &Path,
lower_dirs: &[PathBuf],
upper: &Path,
work: &Path,
target: &Path,
) -> Result<()> {
if lower_dirs.is_empty() {
return Err(AgentError::Configuration(
"fuse overlay rootfs requires at least one lower dir".to_string(),
));
}
for d in [upper, work, target] {
std::fs::create_dir_all(d).map_err(|e| {
AgentError::Configuration(format!(
"failed to create fuse overlay dir {}: {e}",
d.display()
))
})?;
}
let lower_joined = lower_dirs
.iter()
.rev()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(":");
let opts = format!(
"lowerdir={lower_joined},upperdir={},workdir={},noacl",
upper.display(),
work.display()
);
let output = std::process::Command::new(fuse_overlayfs_bin)
.arg("-o")
.arg(&opts)
.arg(target)
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| {
AgentError::Configuration(format!(
"failed to spawn fuse-overlayfs ({}) for {}: {e}",
fuse_overlayfs_bin.display(),
target.display()
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AgentError::Configuration(format!(
"fuse-overlayfs mount at {} failed (opts: {opts}): status {}, stderr: {}",
target.display(),
output.status,
stderr.trim()
)));
}
Ok(())
}
pub fn unmount_overlay_rootfs(target: &Path) -> Result<()> {
let Some(fstype) = mount_fstype(target) else {
return Ok(());
};
if fstype.starts_with("fuse") {
return unmount_fuse_overlay_rootfs(target);
}
unmount_kernel_overlay_rootfs(target)
}
fn unmount_kernel_overlay_rootfs(target: &Path) -> Result<()> {
use nix::errno::Errno;
use nix::mount::{umount, umount2, MntFlags};
match umount(target) {
Ok(()) | Err(Errno::EINVAL | Errno::ENOENT) => Ok(()),
Err(Errno::EBUSY) => match umount2(target, MntFlags::MNT_DETACH) {
Ok(()) | Err(Errno::EINVAL | Errno::ENOENT) => Ok(()),
Err(e) => Err(AgentError::Configuration(format!(
"failed to lazily unmount overlay rootfs at {}: {e}",
target.display()
))),
},
Err(e) => Err(AgentError::Configuration(format!(
"failed to unmount overlay rootfs at {}: {e}",
target.display()
))),
}
}
fn unmount_fuse_overlay_rootfs(target: &Path) -> Result<()> {
const MAX_BUSY_RETRIES: u32 = 5;
const BUSY_BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
let Some(fusermount) = crate::capability::fusermount_binary() else {
return Err(AgentError::Configuration(format!(
"cannot unmount fuse overlay rootfs at {}: neither fusermount3 nor fusermount on PATH",
target.display()
)));
};
let run = |lazy: bool| -> Result<std::process::Output> {
let mut cmd = std::process::Command::new(&fusermount);
cmd.arg("-u");
if lazy {
cmd.arg("-z");
}
cmd.arg(target)
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| {
AgentError::Configuration(format!(
"failed to spawn {} -u {}: {e}",
fusermount.display(),
target.display()
))
})
};
let mut last_stderr = String::new();
for attempt in 0..=MAX_BUSY_RETRIES {
let output = run(false)?;
if output.status.success() || mount_fstype(target).is_none() {
return Ok(());
}
last_stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let busy = last_stderr.to_ascii_lowercase().contains("busy");
if !busy {
return Err(AgentError::Configuration(format!(
"fusermount -u failed for {}: status {}, stderr: {}",
target.display(),
output.status,
last_stderr
)));
}
if attempt < MAX_BUSY_RETRIES {
std::thread::sleep(BUSY_BACKOFF * (attempt + 1));
}
}
let lazy = run(true)?;
if lazy.status.success() || mount_fstype(target).is_none() {
tracing::debug!(
target = %target.display(),
"fuse overlay rootfs was busy; completed teardown via lazy (-z) unmount",
);
return Ok(());
}
let lazy_stderr = String::from_utf8_lossy(&lazy.stderr);
Err(AgentError::Configuration(format!(
"fusermount -u failed for {} after {} busy retries (last stderr: {}); \
lazy -uz also failed: status {}, stderr: {}",
target.display(),
MAX_BUSY_RETRIES,
last_stderr,
lazy.status,
lazy_stderr.trim()
)))
}
fn mount_fstype(path: &Path) -> Option<String> {
let mounts = std::fs::read_to_string("/proc/mounts").ok()?;
let want = path.to_string_lossy();
let mut found: Option<String> = None;
for line in mounts.lines() {
let mut fields = line.split_whitespace();
let _src = fields.next();
let mountpoint = fields.next();
let fstype = fields.next();
if mountpoint == Some(want.as_ref()) {
if let Some(ty) = fstype {
found = Some(ty.to_string());
}
}
}
found
}
#[cfg(test)]
mod tests {
use super::*;
const TAR_MT: &str = "application/vnd.oci.image.layer.v1.tar";
fn require_root_or_skip(test: &str) -> bool {
if zlayer_paths::is_root() {
return true;
}
eprintln!("skipping {test}: requires root (CAP_MKNOD / CAP_SYS_ADMIN)");
false
}
fn tar_layer(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
for (path, content) in files {
let mut h = tar::Header::new_gnu();
h.set_path(path).unwrap();
h.set_size(content.len() as u64);
h.set_mode(0o644);
h.set_cksum();
builder.append(&h, *content).unwrap();
}
builder.into_inner().unwrap()
}
#[tokio::test]
async fn extract_layer_once_is_idempotent_and_writes_sentinel() {
if !require_root_or_skip("extract_layer_once_is_idempotent_and_writes_sentinel") {
return;
}
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path().join("layers");
let layer = tar_layer(&[("etc/hostname", b"host\n")]);
let layer_file = tmp.path().join("layer0.tar");
std::fs::write(&layer_file, &layer).unwrap();
let digest = "sha256:deadbeef";
let fs1 = extract_layer_once(&store, digest, &layer_file, TAR_MT)
.await
.expect("first extract");
assert!(fs1.join("etc/hostname").exists(), "layer content extracted");
let sentinel = store
.join(zlayer_paths::sanitize_digest(digest))
.join(".done");
assert!(sentinel.exists(), ".done sentinel must be written");
let fs2 = extract_layer_once(&store, digest, &layer_file, TAR_MT)
.await
.expect("second extract (idempotent)");
assert_eq!(fs1, fs2);
assert!(
!fs1.with_file_name("fs.partial").exists(),
"no leftover staging dir"
);
}
#[tokio::test]
async fn mount_overlay_merges_lowers_and_writes_land_in_upper() {
if !require_root_or_skip("mount_overlay_merges_lowers_and_writes_land_in_upper") {
return;
}
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path().join("layers");
let base = tar_layer(&[("base.txt", b"base"), ("shared.txt", b"from-base")]);
let top = tar_layer(&[("top.txt", b"top"), ("shared.txt", b"from-top")]);
let base_file = tmp.path().join("base.tar");
let top_file = tmp.path().join("top.tar");
std::fs::write(&base_file, &base).unwrap();
std::fs::write(&top_file, &top).unwrap();
let base_fs = extract_layer_once(&store, "sha256:base", &base_file, TAR_MT)
.await
.unwrap();
let top_fs = extract_layer_once(&store, "sha256:top", &top_file, TAR_MT)
.await
.unwrap();
let lowers = vec![base_fs, top_fs];
let upper = tmp.path().join("upper");
let work = tmp.path().join("work");
let merged = tmp.path().join("merged");
mount_overlay_rootfs(&lowers, &upper, &work, &merged).expect("overlay mount");
assert_eq!(std::fs::read(merged.join("base.txt")).unwrap(), b"base");
assert_eq!(std::fs::read(merged.join("top.txt")).unwrap(), b"top");
assert_eq!(
std::fs::read(merged.join("shared.txt")).unwrap(),
b"from-top",
"topmost layer must win in the merged overlay view"
);
std::fs::write(merged.join("written.txt"), b"hi").unwrap();
assert_eq!(
std::fs::read(upper.join("written.txt")).unwrap(),
b"hi",
"writes through the overlay must materialize in the upperdir"
);
unmount_overlay_rootfs(&merged).expect("unmount");
let mounts = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
let merged_str = merged.display().to_string();
assert!(
!mounts.lines().any(|l| l.contains(&merged_str)),
"target must no longer appear in /proc/mounts after unmount"
);
}
#[tokio::test]
async fn unmount_is_idempotent_on_unmounted_target() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("not-a-mount");
std::fs::create_dir_all(&target).unwrap();
unmount_overlay_rootfs(&target).expect("unmount of non-mount is success");
}
#[test]
fn mount_requires_at_least_one_lower() {
let tmp = tempfile::tempdir().unwrap();
let err = mount_overlay_rootfs(
&[],
&tmp.path().join("u"),
&tmp.path().join("w"),
&tmp.path().join("m"),
)
.expect_err("empty lowers must error");
assert!(err.to_string().contains("at least one lower"));
}
#[test]
fn selection_order_kernel_then_fuse_then_copy() {
assert_eq!(
select_rootfs_strategy(true, true),
RootfsStrategy::KernelOverlay
);
assert_eq!(
select_rootfs_strategy(true, false),
RootfsStrategy::KernelOverlay
);
assert_eq!(
select_rootfs_strategy(false, true),
RootfsStrategy::FuseOverlay
);
assert_eq!(
select_rootfs_strategy(false, false),
RootfsStrategy::FullCopy
);
}
#[test]
fn fuse_mount_requires_at_least_one_lower() {
let tmp = tempfile::tempdir().unwrap();
let err = mount_fuse_overlay_rootfs(
std::path::Path::new("/nonexistent/fuse-overlayfs"),
&[],
&tmp.path().join("u"),
&tmp.path().join("w"),
&tmp.path().join("m"),
)
.expect_err("empty lowers must error before any spawn");
assert!(err.to_string().contains("at least one lower"));
}
#[tokio::test]
async fn kernel_and_fuse_layer_store_dirs_do_not_collide() {
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path().join("layers");
let layer = tar_layer(&[("etc/hostname", b"host\n")]);
let layer_file = tmp.path().join("layer.tar");
std::fs::write(&layer_file, &layer).unwrap();
let digest = "sha256:cafe";
let key = zlayer_paths::sanitize_digest(digest);
let fuse_fs = extract_layer_once_rootless(&store, digest, &layer_file, TAR_MT)
.await
.expect("rootless extract");
assert_eq!(fuse_fs, store.join(&key).join("fs-fuse"));
assert!(fuse_fs.join("etc/hostname").exists());
assert!(
store.join(&key).join(".done-fuse").exists(),
".done-fuse sentinel written"
);
assert!(
!store.join(&key).join("fs").exists(),
"fuse extract must not populate the kernel `fs` subdir"
);
assert!(!store.join(&key).join(".done").exists());
let fuse_fs2 = extract_layer_once_rootless(&store, digest, &layer_file, TAR_MT)
.await
.expect("second rootless extract");
assert_eq!(fuse_fs, fuse_fs2);
assert!(!store.join(&key).join("fs-fuse.partial").exists());
}
fn fuse_overlayfs_bin_or_skip(test: &str) -> Option<std::path::PathBuf> {
let path_var = std::env::var("PATH").unwrap_or_default();
for dir in path_var.split(':').filter(|d| !d.is_empty()) {
let candidate = std::path::Path::new(dir).join("fuse-overlayfs");
if candidate.exists() {
return Some(candidate);
}
}
eprintln!("skipping {test}: fuse-overlayfs not installed (PATH scan found nothing)");
None
}
#[tokio::test]
async fn fuse_overlay_end_to_end_rootless_merge_write_unmount() {
let Some(bin) =
fuse_overlayfs_bin_or_skip("fuse_overlay_end_to_end_rootless_merge_write_unmount")
else {
return;
};
if std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/fuse")
.is_err()
{
eprintln!("skipping: /dev/fuse not openable");
return;
}
let tmp = tempfile::tempdir().unwrap();
let store = tmp.path().join("layers");
let base = tar_layer(&[
("base.txt", b"base"),
("shared.txt", b"from-base"),
("gone.txt", b"doomed"),
]);
let mut top_builder = tar::Builder::new(Vec::new());
for (p, c) in [("top.txt", &b"top"[..]), ("shared.txt", &b"from-top"[..])] {
let mut h = tar::Header::new_gnu();
h.set_path(p).unwrap();
h.set_size(c.len() as u64);
h.set_mode(0o644);
h.set_cksum();
top_builder.append(&h, c).unwrap();
}
let mut wh = tar::Header::new_gnu();
wh.set_path(".wh.gone.txt").unwrap();
wh.set_size(0);
wh.set_mode(0o644);
wh.set_cksum();
top_builder.append(&wh, std::io::empty()).unwrap();
let top = top_builder.into_inner().unwrap();
let base_file = tmp.path().join("base.tar");
let top_file = tmp.path().join("top.tar");
std::fs::write(&base_file, &base).unwrap();
std::fs::write(&top_file, &top).unwrap();
let base_fs = extract_layer_once_rootless(&store, "sha256:base", &base_file, TAR_MT)
.await
.unwrap();
let top_fs = extract_layer_once_rootless(&store, "sha256:top", &top_file, TAR_MT)
.await
.unwrap();
let lowers = vec![base_fs, top_fs]; let upper = tmp.path().join("upper");
let work = tmp.path().join("work");
let merged = tmp.path().join("merged");
mount_fuse_overlay_rootfs(&bin, &lowers, &upper, &work, &merged)
.expect("rootless fuse-overlayfs mount");
assert_eq!(std::fs::read(merged.join("base.txt")).unwrap(), b"base");
assert_eq!(std::fs::read(merged.join("top.txt")).unwrap(), b"top");
assert_eq!(
std::fs::read(merged.join("shared.txt")).unwrap(),
b"from-top",
"topmost layer wins"
);
assert!(
!merged.join("gone.txt").exists(),
"rootless .wh. whiteout must hide the lower gone.txt"
);
std::fs::write(merged.join("written.txt"), b"hi").unwrap();
assert_eq!(
std::fs::read(upper.join("written.txt")).unwrap(),
b"hi",
"writes materialize in the upperdir"
);
assert!(
mount_fstype(&merged).is_some_and(|t| t.starts_with("fuse")),
"merged must be a fuse.* mount"
);
unmount_overlay_rootfs(&merged).expect("fusermount -u via unmount_overlay_rootfs");
assert!(
mount_fstype(&merged).is_none(),
"target must be unmounted after unmount_overlay_rootfs"
);
}
}