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).
570 lines
20 KiB
Rust
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'");
|
|
}
|
|
}
|