From 317dbcd0622cdbdc3f7164d955ff0b88399a9a27 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 12 Aug 2026 15:44:13 +0200 Subject: [PATCH] deb: use overlayfs instead of copy --- src/context/api.rs | 13 ++++ src/context/unshare.rs | 142 +++++++++++++++++++++++++++++++++++++++-- src/deb/ephemeral.rs | 10 +++ 3 files changed, 158 insertions(+), 7 deletions(-) diff --git a/src/context/api.rs b/src/context/api.rs index 71d41b1..ba2570f 100644 --- a/src/context/api.rs +++ b/src/context/api.rs @@ -34,6 +34,12 @@ pub trait ContextDriver { fn read_file(&self, path: &Path) -> io::Result; fn write_file(&self, path: &Path, content: &str) -> io::Result<()>; fn exists(&self, path: &Path) -> io::Result; + + /// Clean up any resources held by the driver (e.g. unmount overlay filesystems). + /// Called before the chroot directory is removed. + fn cleanup(&self) -> io::Result<()> { + Ok(()) // default no-op + } } /// Represents an execution environment (Local or via SSH). @@ -186,6 +192,12 @@ impl Context { self.driver().as_ref().unwrap().exists(path) } + /// Clean up any resources held by the driver (e.g. unmount overlay filesystems). + /// Called before the chroot directory is removed. + pub fn cleanup(&self) -> io::Result<()> { + self.driver().as_ref().unwrap().cleanup() + } + /// Create and obtain a specific driver for the context pub fn driver( &self, @@ -207,6 +219,7 @@ impl Context { ContextConfig::Unshare { path, .. } => Box::new(UnshareDriver { path: path.clone(), parent: self.parent.clone(), + overlay_mounts: std::sync::Mutex::new(Vec::new()), }), }; *driver_lock = Some(driver); diff --git a/src/context/unshare.rs b/src/context/unshare.rs index c63dc2d..d516bcd 100644 --- a/src/context/unshare.rs +++ b/src/context/unshare.rs @@ -4,11 +4,13 @@ use std::fs; use std::io; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; pub struct UnshareDriver { pub path: String, pub parent: Option>, + /// Host-side paths where overlay filesystems are mounted, tracked for cleanup. + pub overlay_mounts: Mutex>, } /// Recursively copy a directory and all its contents. @@ -141,15 +143,16 @@ fn copy_file_with_times(src: &Path, dest: &Path) -> io::Result<()> { Ok(()) } +/// Check whether the overlay filesystem is available on this system. +fn is_overlayfs_available() -> bool { + let content = std::fs::read_to_string("/proc/filesystems").unwrap_or_default(); + content.contains("overlay\n") || content.contains("overlay\t") +} + impl ContextDriver for UnshareDriver { fn ensure_available(&self, src: &Path, dest_root: &str) -> io::Result { // Construct the destination path inside the chroot let dest_dir = Path::new(&self.path).join(dest_root.trim_start_matches('/')); - debug!( - "unshare/ensure_available: copy '{}' to '{}'", - src.display(), - dest_dir.display() - ); // Ensure the destination directory exists std::fs::create_dir_all(&dest_dir)?; @@ -162,7 +165,34 @@ impl ContextDriver for UnshareDriver { // Construct the full destination path let dest_path = dest_dir.join(filename); - // Copy the file or directory into the chroot + // Try overlayfs for directories first — avoids a potentially expensive full copy + if src.is_dir() && is_overlayfs_available() { + match self.try_overlay_mount(src, &dest_path) { + Ok(()) => { + debug!( + "Mounted overlay for {} at {}", + src.display(), + dest_path.display() + ); + return Ok(Path::new(dest_root).join(filename)); + } + Err(e) => { + debug!( + "Overlay mount failed for '{}', falling back to copy: {}", + src.display(), + e + ); + } + } + } + + // Fallback: copy the file or directory into the chroot + debug!( + "unshare/ensure_available: copy '{}' to '{}'", + src.display(), + dest_dir.display() + ); + if src.is_dir() { copy_dir_recursive(src, &dest_path)?; debug!( @@ -179,6 +209,25 @@ impl ContextDriver for UnshareDriver { Ok(Path::new(dest_root).join(filename)) } + fn cleanup(&self) -> io::Result<()> { + let mounts = self.overlay_mounts.lock().unwrap(); + for mount_path in mounts.iter() { + debug!("Unmounting overlay at {}", mount_path.display()); + let is_root = crate::utils::root::is_root().unwrap_or(false); + let mut cmd = self + .parent() + .command(if is_root { "umount" } else { "sudo" }); + if !is_root { + cmd.arg("umount"); + } + let status = cmd.arg(mount_path.to_string_lossy().to_string()).status()?; + if !status.success() { + log::warn!("Failed to unmount overlay at {}", mount_path.display()); + } + } + Ok(()) + } + fn retrieve_path(&self, src: &Path, dest: &Path) -> io::Result<()> { let host_src = Path::new(&self.path).join(src.to_string_lossy().trim_start_matches('/')); self.parent().retrieve_path(&host_src, dest) @@ -287,6 +336,85 @@ impl UnshareDriver { .expect("UnshareDriver requires a parent context") } + /// Try to mount `src` as an overlay at `dest_path` inside the chroot. + /// + /// On success, the overlay is tracked in `overlay_mounts` for later cleanup. + /// On failure, the caller should fall back to the copy-based approach. + fn try_overlay_mount(&self, src: &Path, dest_path: &Path) -> io::Result<()> { + // Create unique upper/work dirs inside the chroot + let overlay_base = Path::new(&self.path).join("pkh-overlay"); + let id = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let upper_dir = overlay_base.join(format!("upper-{}", id)); + let work_dir = overlay_base.join(format!("work-{}", id)); + + fs::create_dir_all(&upper_dir)?; + fs::create_dir_all(&work_dir)?; + fs::create_dir_all(dest_path)?; + + // Canonicalize the source path so overlayfs can find it reliably + let src_canonical = src.canonicalize().map_err(|e| { + io::Error::new( + e.kind(), + format!("Failed to canonicalize '{}': {}", src.display(), e), + ) + })?; + + // Mount overlay from host (requires CAP_SYS_ADMIN, same as bind_mount_proc). + // Use .output() instead of .status() so that mount errors are captured + // rather than printed to stderr — the caller will fall back to a copy. + let is_root = crate::utils::root::is_root().unwrap_or(false); + let mut cmd = self + .parent() + .command(if is_root { "mount" } else { "sudo" }); + if !is_root { + cmd.arg("mount"); + } + let output = cmd + .arg("-t") + .arg("overlay") + .arg("overlay") + .arg("-o") + .arg(format!( + "lowerdir={},upperdir={},workdir={}", + src_canonical.display(), + upper_dir.display(), + work_dir.display() + )) + .arg(dest_path.to_string_lossy().to_string()) + .output()?; + + if !output.status.success() { + // Clean up dirs we created + let _ = fs::remove_dir_all(&upper_dir); + let _ = fs::remove_dir_all(&work_dir); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(io::Error::other(format!( + "Overlay mount of '{}' at '{}' failed: {}", + src.display(), + dest_path.display(), + stderr.trim() + ))); + } + + debug!( + "Overlay-mounted {} at {} (upper: {}, work: {})", + src.display(), + dest_path.display(), + upper_dir.display(), + work_dir.display() + ); + + // Track for cleanup + self.overlay_mounts + .lock() + .unwrap() + .push(dest_path.to_path_buf()); + Ok(()) + } + fn command( &self, program: &str, diff --git a/src/deb/ephemeral.rs b/src/deb/ephemeral.rs index a80ad59..bc0f43c 100644 --- a/src/deb/ephemeral.rs +++ b/src/deb/ephemeral.rs @@ -337,6 +337,16 @@ impl EphemeralContextGuard { impl Drop for EphemeralContextGuard { fn drop(&mut self) { log::debug!("Cleaning up ephemeral context ({:?})...", self.chroot_path); + + // Clean up any overlay mounts before resetting the context. + // This must happen while the ephemeral context is still current so its + // driver is accessible. The actual unmount commands run via the parent + // (base) context, so they work regardless. + let ephemeral_ctx = context::current(); + if let Err(e) = ephemeral_ctx.cleanup() { + log::warn!("Failed to clean up overlay mounts: {}", e); + } + // Reset to normal context if let Err(e) = context::manager().set_current(&self.previous_context) { log::error!("Failed to restore context {}: {}", self.previous_context, e);