use super::api::{Context, ContextCommand, ContextDriver, LineSink}; use super::shell::shell_quote; use log::debug; use std::fs; use std::io; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; 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. /// /// Symlinks are copied as symlinks (preserving the link target rather than /// following it), so that dangling links do not abort the copy. fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> { // Create the destination directory std::fs::create_dir_all(dest).map_err(|e| { io::Error::new( e.kind(), format!("Failed to create directory '{}': {}", dest.display(), e), ) })?; // Iterate through the source directory let read = std::fs::read_dir(src).map_err(|e| { io::Error::new( e.kind(), format!("Failed to read directory '{}': {}", src.display(), e), ) })?; for entry in read { let entry = entry.map_err(|e| { io::Error::new( e.kind(), format!("Failed to read entry in '{}': {}", src.display(), e), ) })?; let src_path = entry.path(); let dest_path = dest.join(entry.file_name()); // Handle symlinks explicitly: reproduce the link itself instead of // following it. This avoids failing on dangling/absolute symlinks. let metadata = match std::fs::symlink_metadata(&src_path) { Ok(m) => m, Err(e) => { return Err(io::Error::new( e.kind(), format!("Failed to read metadata of '{}': {}", src_path.display(), e), )); } }; // Never ship VCS metadata into the build tree: its presence flips // autotools 'building from VCS' detection (see is_vcs_dir_name) and // activates maintainer-only regeneration rules requiring tools that // are not declared build-dependencies (e.g. help2man). if metadata.is_dir() && super::is_vcs_dir_name(&entry.file_name()) { continue; } if metadata.file_type().is_symlink() { let target = std::fs::read_link(&src_path).map_err(|e| { io::Error::new( e.kind(), format!("Failed to read symlink '{}': {}", src_path.display(), e), ) })?; // Remove an existing destination entry (e.g. from a previous attempt) let _ = fs::remove_file(&dest_path); symlink(&target, &dest_path).map_err(|e| { io::Error::new( e.kind(), format!( "Failed to create symlink '{}' -> '{}': {}", dest_path.display(), target.display(), e ), ) })?; } else if src_path.is_dir() { // Recursively copy subdirectories copy_dir_recursive(&src_path, &dest_path)?; } else { // Copy regular files, preserving the source modification and // access times. This is important for autotools/gnulib-based // packages (e.g. 'hello' from Debian sid) that ship pre-generated // files alongside their prerequisites: if the copy resets the // mtime to "now", the prerequisites appear as new as the // generated targets and `make` tries to regenerate them using // tools (like gperf) that are not declared build-dependencies. copy_file_with_times(&src_path, &dest_path)?; } } Ok(()) } /// Copy a single regular file from `src` to `dest`, preserving the source's /// modification and access times. /// /// `std::fs::copy` resets the destination mtime to "now", which breaks /// timestamp-based build systems (autotools/gnulib) that ship pre-generated /// files alongside their prerequisites. Restoring the original timestamps /// prevents `make` from needlessly regenerating those files with tools that /// may not be installed (e.g. `gperf`). fn copy_file_with_times(src: &Path, dest: &Path) -> io::Result<()> { std::fs::copy(src, dest).map_err(|e| { io::Error::new( e.kind(), format!( "Failed to copy '{}' to '{}': {}", src.display(), dest.display(), e ), ) })?; // Restore the original timestamps on the destination file. let metadata = std::fs::metadata(src).map_err(|e| { io::Error::new( e.kind(), format!("Failed to read metadata of '{}': {}", src.display(), e), ) })?; let mut times = std::fs::FileTimes::new(); let mut have_times = false; if let Ok(mtime) = metadata.modified() { times = times.set_modified(mtime); have_times = true; } if let Ok(atime) = metadata.accessed() { times = times.set_accessed(atime); have_times = true; } if have_times && let Ok(dest_file) = std::fs::File::open(dest) { let _ = dest_file.set_times(times).map_err(|e| { io::Error::new( e.kind(), format!("Failed to set times on '{}': {}", dest.display(), e), ) }); } 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('/')); // Ensure the destination directory exists std::fs::create_dir_all(&dest_dir)?; // Get the filename from the source path let filename = src .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid source path"))?; // Construct the full destination path let dest_path = dest_dir.join(filename); // 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!( "Copied directory {} to {}", src.display(), dest_path.display() ); } else { copy_file_with_times(src, &dest_path)?; debug!("Copied file {} to {}", src.display(), dest_path.display()); } // Return the path as it appears inside the chroot (without the chroot prefix) 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) } fn list_files(&self, path: &Path) -> io::Result> { let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/')); let host_entries = self.parent().list_files(&host_path)?; let mut entries = Vec::new(); let prefix = Path::new(&self.path); for entry in host_entries { if let Ok(rel_path) = entry.strip_prefix(prefix) { entries.push(Path::new("/").join(rel_path)); } else { entries.push(entry); } } Ok(entries) } fn run( &self, program: &str, args: &[String], env: &[(String, String)], cwd: Option<&str>, ) -> io::Result { self.command(program, args, env, cwd).status() } fn run_captured( &self, program: &str, args: &[String], env: &[(String, String)], cwd: Option<&str>, sink: Arc, ) -> io::Result { // Forward the sink to the wrapping command: the parent context driver // is responsible for the actual capture (and may chain further, e.g. // unshare over ssh). let mut cmd = self.command(program, args, env, cwd); cmd.capture(sink); cmd.status() } fn run_output( &self, program: &str, args: &[String], env: &[(String, String)], cwd: Option<&str>, ) -> io::Result { self.command(program, args, env, cwd).output() } fn create_temp_dir(&self) -> io::Result { // Create a temporary directory inside the chroot with unique naming let base_timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); let mut attempt = 0; loop { let work_dir_name = if attempt == 0 { format!("pkh-build-{}", base_timestamp) } else { format!("pkh-build-{}-{}", base_timestamp, attempt) }; let work_dir_inside_chroot = format!("/tmp/{}", work_dir_name); let host_path = Path::new(&self.path).join("tmp").join(&work_dir_name); // Check if directory already exists if host_path.exists() { attempt += 1; continue; } // Create the directory on the host filesystem std::fs::create_dir_all(&host_path)?; debug!( "Created work directory: {} (host: {})", work_dir_inside_chroot, host_path.display() ); // Return the path as it appears inside the chroot return Ok(work_dir_inside_chroot); } } fn copy_path(&self, src: &Path, dest: &Path) -> io::Result<()> { let host_src = Path::new(&self.path).join(src.to_string_lossy().trim_start_matches('/')); let host_dest = Path::new(&self.path).join(dest.to_string_lossy().trim_start_matches('/')); self.parent().copy_path(&host_src, &host_dest) } fn read_file(&self, path: &Path) -> io::Result { let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/')); self.parent().read_file(&host_path) } fn write_file(&self, path: &Path, content: &str) -> io::Result<()> { let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/')); self.parent().write_file(&host_path, content) } fn exists(&self, path: &Path) -> io::Result { let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/')); self.parent().exists(&host_path) } } impl UnshareDriver { fn parent(&self) -> &Context { self.parent .as_ref() .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()); // The overlay exposes the source tree verbatim: strip VCS metadata // (e.g. '.git') whose presence would flip autotools 'building from // git' detection and activate maintainer-only regeneration rules // (see is_vcs_dir_name). Removals land as whiteouts in the upper // layer, leaving the lowerdir untouched. if let Err(e) = super::prune_vcs_dirs(dest_path) { log::warn!( "Failed to prune VCS metadata from '{}': {}", dest_path.display(), e ); } Ok(()) } fn command( &self, program: &str, args: &[String], env: &[(String, String)], cwd: Option<&str>, ) -> ContextCommand<'_> { let mut cmd = self.parent().command("unshare"); cmd.envs(env.iter().cloned()); cmd.arg("--map-user=65536") .arg("--map-group=65536") .arg("--pid") .arg("--ipc") .arg("--uts") .arg("--user") .arg("--cgroup") .arg("--map-auto") .arg("-r") .arg("--mount") .arg("--fork") .arg("-R") .arg(&self.path); if let Some(dir) = cwd { cmd.arg("-w").arg(dir); } // Build the bash command: set up /dev/pts and run the program // /proc should already be bind-mounted from the host before entering the namespace cmd.arg("--") .arg("bash") .arg("-c") .arg(build_namespace_script(program, args)); cmd } } /// Build the shell script executed by `bash -c` inside the user namespace: /// bring up `/dev/pts`, then run `program` with `args`. /// /// The script is parsed by bash, so the program and every argument are /// POSIX-shell-quoted (see [`shell_quote`]): quotes, `$`, backticks or /// whitespace inside them can neither split the command into different /// words nor trigger expansion. (Previously arguments were wrapped in /// unescaped double quotes, so a `"` in an argument broke out and /// `$`/backticks still expanded.) fn build_namespace_script(program: &str, args: &[String]) -> String { let mut script = String::from( "mkdir -p /dev/pts; mount -t devpts devpts /dev/pts 2>/dev/null || true; touch /dev/ptmx; mount --bind /dev/pts/ptmx /dev/ptmx 2>/dev/null || true; ", ); script.push_str(&shell_quote(program)); for arg in args { script.push(' '); script.push_str(&shell_quote(arg)); } script } #[cfg(test)] mod tests { use super::build_namespace_script; fn tail_after_devpts_setup(script: &str) -> &str { script .rsplit_once("|| true; ") .map(|(_, rest)| rest) .unwrap() .trim_end() } /// Program and arguments must each be a single, quoted word at the end /// of the `/dev/pts` setup script. #[test] fn script_quotes_program_and_args() { let script = build_namespace_script( "make", &[ "install".to_string(), "a b".to_string(), "PREFIX=/opt/my app".to_string(), ], ); assert_eq!( tail_after_devpts_setup(&script), "'make' 'install' 'a b' 'PREFIX=/opt/my app'" ); } /// An argument containing a double quote must not break out of the /// script (arguments used to be wrapped in unescaped `"`), and `$`/ /// backticks must stay literal for bash. #[test] fn script_neutralizes_quotes_and_expansions() { let script = build_namespace_script( "echo", &["$(touch /tmp/pwned) `id` \"; rm -rf /\"".to_string()], ); assert_eq!( tail_after_devpts_setup(&script), "'echo' '$(touch /tmp/pwned) `id` \"; rm -rf /\"'" ); } /// Empty arguments must survive as one empty word (`''`), not vanish. #[test] fn script_preserves_empty_args() { let script = build_namespace_script("prog", &[String::new(), "x".to_string()]); assert_eq!(tail_after_devpts_setup(&script), "'prog' '' 'x'"); } }