Files
pkh/src/context/unshare.rs
T
vhaudiquet f508f20846 context: quote program, args, cwd and env in ssh, schroot and unshare drivers
All three non-local drivers assembled remote/chroot command strings by
raw concatenation: ssh pushed args verbatim (TODO: escape), schroot
interpolated env values raw so DEB_BUILD_OPTIONS='parallel=4 nocheck'
made sh treat 'nocheck' as the command, and unshare wrapped args in
unescaped double quotes letting quotes break out and $/backticks
expand. Add a shared POSIX shell_quote helper and use it for every
component interpolated into a shell string, including ssh copy_path
(which used Rust's {:?}, not shell quoting) and schroot write_file
(which also switches echo -ne to printf %s so backslash sequences in
content are no longer interpreted).
2026-09-16 00:36:02 +02:00

570 lines
20 KiB
Rust

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<Arc<super::api::Context>>,
/// Host-side paths where overlay filesystems are mounted, tracked for cleanup.
pub overlay_mounts: Mutex<Vec<PathBuf>>,
}
/// 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<PathBuf> {
// 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<Vec<PathBuf>> {
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<std::process::ExitStatus> {
self.command(program, args, env, cwd).status()
}
fn run_captured(
&self,
program: &str,
args: &[String],
env: &[(String, String)],
cwd: Option<&str>,
sink: Arc<dyn LineSink>,
) -> io::Result<std::process::ExitStatus> {
// 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<std::process::Output> {
self.command(program, args, env, cwd).output()
}
fn create_temp_dir(&self) -> io::Result<String> {
// 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<String> {
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<bool> {
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'");
}
}