1use 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
38const DONE_SENTINEL: &str = ".done";
42
43const DONE_SENTINEL_FUSE: &str = ".done-fuse";
48
49static EXTRACT_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
58
59async 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
70pub 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
106pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum OverlayBackend {
145 Kernel,
147 Fuse,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum RootfsStrategy {
156 KernelOverlay,
158 FuseOverlay,
160 FullCopy,
162}
163
164#[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 const fn fs_subdir(self) -> &'static str {
185 match self {
186 Self::Kernel => "fs",
187 Self::Fuse => "fs-fuse",
188 }
189 }
190
191 const fn sentinel(self) -> &'static str {
193 match self {
194 Self::Kernel => DONE_SENTINEL,
195 Self::Fuse => DONE_SENTINEL_FUSE,
196 }
197 }
198}
199
200async 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 if sentinel.exists() {
217 return Ok(fs_dir);
218 }
219
220 let lock = digest_lock(&format!("{key}:{}", backend.fs_subdir())).await;
225 let _held = lock.lock().await;
226
227 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 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 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
304pub 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 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
368pub 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 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
454pub fn unmount_overlay_rootfs(target: &Path) -> Result<()> {
469 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
481fn 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
503fn unmount_fuse_overlay_rootfs(target: &Path) -> Result<()> {
522 const MAX_BUSY_RETRIES: u32 = 5;
525 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 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 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 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 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 if attempt < MAX_BUSY_RETRIES {
580 std::thread::sleep(BUSY_BACKOFF * (attempt + 1));
581 }
582 }
583
584 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
606fn 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 const TAR_MT: &str = "application/vnd.oci.image.layer.v1.tar";
640
641 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 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 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 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 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 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 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_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 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 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 assert_eq!(
792 select_rootfs_strategy(false, true),
793 RootfsStrategy::FuseOverlay
794 );
795 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 #[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 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 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 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 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 #[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 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 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 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]; 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 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 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 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}