From 4f669fb5ec2137c60aa89d4e2872f31d18593b86 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 20 Sep 2026 23:34:54 +0200 Subject: [PATCH] feat(exec): exec API with caller envp, bind targets and arch Add ecr::exec: run a command inside a prepared rootfs in fresh user/PID/mount/UTS namespaces, with the caller composing the full environment (ExecOptions::env), the bind targets (BindTarget, with explicit absolute mount points inside the rootfs) and the target architecture (binfmt_misc is verified for foreign arches). mount::setup_mounts now takes &[BindTarget] instead of parallel read-only/read-write path lists; chroot::run_chroot takes the envp and a resolved working directory, and chroot::default_env composes the previous hardcoded environment as a starting point for callers. The CLI maps its flags onto the new API; behavior is unchanged (overlay at /root/, rw bind at /mnt/, cwd default). --- crates/ecr-cli/src/main.rs | 189 +++++++++-------------- crates/ecr/src/chroot.rs | 170 ++++++++++----------- crates/ecr/src/exec.rs | 305 +++++++++++++++++++++++++++++++++++++ crates/ecr/src/lib.rs | 8 + crates/ecr/src/mount.rs | 122 ++++++++++----- 5 files changed, 547 insertions(+), 247 deletions(-) create mode 100644 crates/ecr/src/exec.rs diff --git a/crates/ecr-cli/src/main.rs b/crates/ecr-cli/src/main.rs index f56dafa..30df7a1 100644 --- a/crates/ecr-cli/src/main.rs +++ b/crates/ecr-cli/src/main.rs @@ -1,5 +1,7 @@ mod cli; +use std::path::{Path, PathBuf}; + use anyhow::{Context, Result}; use clap::Parser; @@ -10,8 +12,10 @@ use ecr::distro::{ map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource, }; use ecr::download::{digest_sidecar, download_image, fetch_oci_digest}; +use ecr::exec::ExecOptions; use ecr::extract::extract_tarball; -use ecr::{kernel, mount, namespace, qemu, qemu_vm, utils, veprintln, verbose}; +use ecr::mount::BindTarget; +use ecr::{kernel, qemu, qemu_vm, utils, veprintln, verbose}; fn main() -> Result<()> { let args = Args::parse(); @@ -158,7 +162,7 @@ fn main() -> Result<()> { result } else { // Namespace/chroot mode - let exit_code = namespace_mode(args, rootfs, config)?; + let exit_code = namespace_mode(&args, &rootfs, &config, &arch)?; // Propagate the command's exit code, cleaning up the extracted rootfs // first: process::exit does not run destructors. drop(temp_dir); @@ -170,70 +174,80 @@ fn main() -> Result<()> { } /// Run in namespace/chroot mode, returning the command's exit code -fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Result { - // Check user namespace availability - namespace::check_user_namespace()?; +fn namespace_mode(args: &Args, rootfs: &Path, config: &Config, arch: &str) -> Result { + let opts = ExecOptions { + arch: arch.to_string(), + binds: cli_bind_targets(args)?, + env: chroot::default_env(rootfs), + dns: config.dns.clone(), + command: args.command.clone(), + working_dir: None, + }; - // Process bind paths - use current directory if none specified - let cwd = std::env::current_dir().expect("Could not get current directory"); - let bind_paths: Vec = if args.bind.is_empty() && !args.no_bind { - vec![cwd.clone()] + let exit_code = ecr::exec(rootfs, &opts); + + // Cleanup happens automatically via tempfile + if exit_code.is_ok() { + veprintln!("Cleanup complete."); + } + + exit_code +} + +/// Translate --bind/--bind-rw/--no-bind into explicit bind targets: +/// --bind overlays read-only at /root/, --bind-rw bind-mounts +/// read-write at /mnt/ (taking precedence over --bind for the +/// same source path). With no --bind given, the current directory is +/// overlaid by default. +fn cli_bind_targets(args: &Args) -> Result> { + // --no-bind means "skip mounting any directory". Combining it with an + // explicit --bind-rw is contradictory; error rather than silently ignoring + // the flag the user asked for. + if args.no_bind { + if !args.bind_rw.is_empty() { + return Err(anyhow::anyhow!( + "--no-bind and --bind-rw cannot be used together: \ + --no-bind skips all mounts, including read-write ones" + )); + } + return Ok(Vec::new()); + } + + let mut binds = Vec::new(); + + let ro_sources: Vec = if args.bind.is_empty() { + vec![std::env::current_dir().context("Could not get current directory")?] } else { args.bind.clone() }; - // --no-bind means "skip mounting any directory". Combining it with an - // explicit --bind-rw is contradictory; error rather than silently ignoring - // the flag the user asked for. - if args.no_bind && !args.bind_rw.is_empty() { - return Err(anyhow::anyhow!( - "--no-bind and --bind-rw cannot be used together: \ - --no-bind skips all mounts, including read-write ones" - )); - } - let bind_rw_paths: Vec = args.bind_rw.clone(); - - // Prepare data for the closure - let bind_paths_clone = bind_paths.clone(); - let bind_rw_paths_clone = bind_rw_paths.clone(); - let args_clone = args.clone(); - let rootfs_clone = rootfs.clone(); - let dns_clone = config.dns.clone(); - - // Run in namespace - let result = namespace::setup_namespaces(move || -> Result<()> { - // Setup mounts - overlay_temps must be kept alive for overlay to work - let overlay_temps = mount::setup_mounts( - &rootfs_clone, - &bind_paths_clone, - &bind_rw_paths_clone, - args_clone.no_bind, - )?; - - // Write resolv.conf with DNS from config - write_resolv_conf(&rootfs_clone, &dns_clone)?; - - // Run chroot - let command = if args_clone.command.is_empty() { - None - } else { - Some(args_clone.command.clone()) - }; - - let result = chroot::run_chroot(&rootfs_clone, command, &bind_rw_paths_clone); - - // Keep overlay_temps alive until chroot exits - drop(overlay_temps); - - result - }); - - // Cleanup happens automatically via tempfile - if result.is_ok() { - veprintln!("Cleanup complete."); + for source in ro_sources { + if args.bind_rw.contains(&source) { + continue; + } + binds.push(BindTarget { + target: cli_mount_target(&source, "root")?, + source, + read_only: true, + }); } - result + for source in &args.bind_rw { + binds.push(BindTarget { + target: cli_mount_target(source, "mnt")?, + source: source.clone(), + read_only: false, + }); + } + + Ok(binds) +} + +fn cli_mount_target(source: &Path, parent: &str) -> Result { + let basename = source + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid bind path: {}", source.display()))?; + Ok(Path::new("/").join(parent).join(basename)) } /// Generate a cache filename based on the image source @@ -292,60 +306,3 @@ fn get_host_arch() -> String { // Use the consolidated architecture detection from utils utils::get_host_arch().debian_name().to_string() } - -fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> { - use std::io::Write; - - let resolv_conf = rootfs.join("etc/resolv.conf"); - - // Create /etc if it doesn't exist - if let Some(parent) = resolv_conf.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("Failed to create directory: {}", parent.display()))?; - } - - // Copy host's resolv.conf if dns is empty, otherwise use provided DNS - let content = if dns.is_empty() { - // Try to copy from host - match std::fs::read_to_string("/etc/resolv.conf") { - Ok(host_resolv) => host_resolv, - Err(_) => "nameserver 1.1.1.1\nnameserver 8.8.8.8\n".to_string(), - } - } else { - let mut c = dns - .iter() - .map(|s| format!("nameserver {}", s)) - .collect::>() - .join("\n"); - c.push('\n'); - c - }; - - // Remove any existing file or symlink before writing so that we always - // create a plain file. Without this, an absolute symlink such as - // /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf would cause the - // write to follow the symlink through the *host* root (chroot() has not been - // called yet) and corrupt the host's DNS configuration. - // - // Use atomic file creation with O_CREAT | O_EXCL to prevent TOCTOU race: - // if an attacker creates a symlink between our remove_file and write, the - // exclusive create will fail rather than writing to the symlink target. - let _ = std::fs::remove_file(&resolv_conf); // ignore ENOENT - - // Use OpenOptions with create_new(true) for atomic exclusive creation - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&resolv_conf) - .with_context(|| { - format!( - "Failed to create resolv.conf at {} (symlink attack prevented)", - resolv_conf.display() - ) - })?; - - file.write_all(content.as_bytes()) - .with_context(|| format!("Failed to write to {}", resolv_conf.display()))?; - - Ok(()) -} diff --git a/crates/ecr/src/chroot.rs b/crates/ecr/src/chroot.rs index e406f21..678f1be 100644 --- a/crates/ecr/src/chroot.rs +++ b/crates/ecr/src/chroot.rs @@ -1,18 +1,41 @@ use crate::veprintln; use anyhow::{anyhow, Context, Result}; use nix::unistd::{chroot, execve}; -use std::collections::HashMap; use std::path::Path; -/// Run a command in the chroot environment +/// Compose the default environment for a rootfs: HOME, USER, SHELL (detected +/// inside `rootfs`), TERM (inherited from the host process) and PATH. +/// Callers use this as a starting point for their own envp, overriding or +/// extending entries before passing them to [`run_chroot`] / [`crate::exec`]. +pub fn default_env(rootfs: &Path) -> Vec<(String, String)> { + let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()); + let shell = crate::utils::detect_shell(rootfs); + + vec![ + ("HOME".to_string(), "/root".to_string()), + ("USER".to_string(), "root".to_string()), + ("SHELL".to_string(), shell.to_string()), + ("TERM".to_string(), host_term), + ( + "PATH".to_string(), + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(), + ), + ] +} + +/// Run a command in the chroot environment. +/// +/// `env` is the full environment (envp) of the exec'd process — the host +/// environment is never inherited. `working_dir` must already be resolved +/// (absolute, inside the rootfs); exec fails if it does not exist there. +/// `command` defaults to the rootfs shell when empty or None. On success +/// this function never returns (execve replaces the process). pub fn run_chroot( rootfs: &Path, - command: Option>, - bind_rw_paths: &[std::path::PathBuf], + command: Option<&[String]>, + env: &[(String, String)], + working_dir: &Path, ) -> Result<()> { - // Get TERM from host before chroot - let host_term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string()); - // Set hostname in UTS namespace if let Err(e) = crate::namespace::set_hostname("chroot") { eprintln!("Warning: Failed to set hostname: {}", e); @@ -24,10 +47,7 @@ pub fn run_chroot( // Change to root directory in chroot chroot(rootfs).context("Failed to chroot")?; - // Now we're inside the chroot - set up environment based on chroot filesystem - - // Set up environment variables (after chroot, so paths are correct) - let env = setup_environment(shell, &host_term); + // Now we're inside the chroot // Determine the command to run let (program, args) = match command { @@ -51,9 +71,8 @@ pub fn run_chroot( } }; - // Build an explicit envp from setup_environment so the host environment - // is never inherited. execve takes this array directly; the host process - // environment is not touched at all. + // Build the envp from the caller-composed pairs. execve takes this array + // directly; the host process environment is not touched at all. let env_cstrings = env .iter() .map(|(k, v)| { @@ -62,39 +81,21 @@ pub fn run_chroot( }) .collect::>>()?; - // Change to first bind_rw directory if available, otherwise /root, otherwise / - // bind_rw paths are mounted at /mnt/ (see mount.rs setup_bind_rw) - let working_dir = if let Some(first_bind_rw) = bind_rw_paths.first() { - let dest_dir = Path::new("/mnt").join(first_bind_rw.file_name().unwrap_or_default()); - if dest_dir.exists() { - dest_dir - } else if Path::new("/root").exists() { - Path::new("/root").to_path_buf() - } else { - Path::new("/").to_path_buf() - } - } else if Path::new("/root").exists() { - Path::new("/root").to_path_buf() - } else { - Path::new("/").to_path_buf() - }; - std::env::set_current_dir(&working_dir).context("Failed to change to working directory")?; + std::env::set_current_dir(working_dir).with_context(|| { + format!( + "Failed to change to working directory {}", + working_dir.display() + ) + })?; // Print welcome message veprintln!("Entering chroot at {}", rootfs.display()); - for path in bind_rw_paths { - let basename = path - .file_name() - .map(|n| n.to_string_lossy()) - .unwrap_or_default(); - veprintln!("Read-write mount: /mnt/{}", basename); - } veprintln!("Working directory: {}", working_dir.display()); // Check if the program exists if !Path::new(&program).exists() { - // Try to find it in PATH - let found = env.get("PATH").and_then(|path| { + // Try to find it in the caller-composed PATH + let found = env.iter().find(|(k, _)| k == "PATH").and_then(|(_, path)| { path.split(':') .map(|p| std::path::PathBuf::from(p).join(&program)) .find(|p| p.exists()) @@ -105,7 +106,7 @@ pub fn run_chroot( } } - // Exec the program directly with an explicit, isolated environment. + // Exec the program directly with the caller-composed, isolated environment. // execve never returns on success. let program_cstr = std::ffi::CString::new(program.as_str()).context("Invalid program name")?; @@ -117,68 +118,41 @@ pub fn run_chroot( } } -/// Setup default environment variables for chroot -/// Must be called AFTER chroot so paths are resolved inside the chroot -fn setup_environment(shell: &str, term: &str) -> HashMap<&'static str, String> { - let mut env = HashMap::new(); - - env.insert("HOME", "/root".to_string()); - env.insert("USER", "root".to_string()); - env.insert("SHELL", shell.to_string()); - env.insert("TERM", term.to_string()); - env.insert( - "PATH", - "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(), - ); - - env -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_setup_environment_defaults() { - let env = setup_environment("/bin/bash", "xterm-256color"); - - assert_eq!(env.get("HOME"), Some(&"/root".to_string())); - assert_eq!(env.get("USER"), Some(&"root".to_string())); - assert_eq!(env.get("SHELL"), Some(&"/bin/bash".to_string())); - assert_eq!(env.get("TERM"), Some(&"xterm-256color".to_string())); - assert_eq!( - env.get("PATH"), - Some(&"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string()) - ); + fn env_get<'a>(env: &'a [(String, String)], key: &str) -> Option<&'a str> { + env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) } #[test] - fn test_setup_environment_custom_shell() { - let env = setup_environment("/usr/bin/zsh", "screen"); + fn test_default_env_entries() { + let dir = tempfile::tempdir().unwrap(); + let env = default_env(dir.path()); - assert_eq!(env.get("SHELL"), Some(&"/usr/bin/zsh".to_string())); - assert_eq!(env.get("TERM"), Some(&"screen".to_string())); - } - - #[test] - fn test_environment_isolation() { - // Verify that setup_environment creates a clean environment - // without inheriting from the host - let env = setup_environment("/bin/sh", "dumb"); - - // Should have exactly 5 environment variables assert_eq!(env.len(), 5); - - // Should NOT have any host-specific variables - assert!(!env.contains_key("LANG")); - assert!(!env.contains_key("DISPLAY")); - assert!(!env.contains_key("PWD")); + assert_eq!(env_get(&env, "HOME"), Some("/root")); + assert_eq!(env_get(&env, "USER"), Some("root")); + assert_eq!(env_get(&env, "SHELL"), Some("/bin/sh")); } #[test] - fn test_path_contains_standard_directories() { - let env = setup_environment("/bin/bash", "xterm"); - let path = env.get("PATH").expect("PATH should be set"); + fn test_default_env_shell_detection() { + // A rootfs with bash present must select /bin/bash + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("bin")).unwrap(); + std::fs::write(dir.path().join("bin/bash"), b"#!").unwrap(); + + let env = default_env(dir.path()); + assert_eq!(env_get(&env, "SHELL"), Some("/bin/bash")); + } + + #[test] + fn test_default_env_path_contains_standard_directories() { + let dir = tempfile::tempdir().unwrap(); + let env = default_env(dir.path()); + let path = env_get(&env, "PATH").expect("PATH should be set"); // Verify essential directories are in PATH assert!(path.contains("/bin")); @@ -186,4 +160,18 @@ mod tests { assert!(path.contains("/sbin")); assert!(path.contains("/usr/sbin")); } + + #[test] + fn test_default_env_has_no_host_specific_variables() { + // Verify the default env is a clean environment without inheriting + // anything from the host beyond TERM + let dir = tempfile::tempdir().unwrap(); + let env = default_env(dir.path()); + + let keys: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect(); + assert!(!keys.contains(&"LANG")); + assert!(!keys.contains(&"DISPLAY")); + assert!(!keys.contains(&"PWD")); + assert!(keys.contains(&"TERM")); + } } diff --git a/crates/ecr/src/exec.rs b/crates/ecr/src/exec.rs new file mode 100644 index 0000000..4eedae8 --- /dev/null +++ b/crates/ecr/src/exec.rs @@ -0,0 +1,305 @@ +//! Namespace-mode execution: mount a prepared rootfs and run a command +//! inside fresh user/PID/mount/UTS namespaces. +//! +//! The caller composes everything: the environment ([`ExecOptions::env`]), +//! the bind targets ([`ExecOptions::binds`]) and the target architecture +//! ([`ExecOptions::arch`]). Fields left empty fall back to the CLI +//! defaults (see each field's docs). + +use std::collections::HashSet; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::chroot; +use crate::mount::{self, BindTarget}; +use crate::namespace; +use crate::qemu; +use crate::utils::{self, Arch}; +use crate::veprintln; + +/// Options for [`exec`]. +#[derive(Debug, Clone)] +pub struct ExecOptions { + /// Target architecture of the rootfs (e.g. "amd64", "arm64"). When it + /// differs from the host, exec verifies binfmt_misc registration for + /// QEMU user emulation before starting. Empty selects the host arch. + pub arch: String, + + /// Bind targets mounted into the rootfs before the command runs. + pub binds: Vec, + + /// Environment of the exec'd process, as (key, value) pairs. The host + /// environment is never inherited. Compose from + /// [`chroot::default_env`] to start from the CLI defaults. Empty + /// selects [`chroot::default_env`] as-is. + pub env: Vec<(String, String)>, + + /// DNS servers written to /etc/resolv.conf. Empty copies the host + /// resolver (falling back to public resolvers when unreadable). + pub dns: Vec, + + /// argv to exec inside the rootfs. Empty runs the rootfs default shell + /// (bash if present, else sh). + pub command: Vec, + + /// Working directory inside the rootfs. Empty picks the first + /// read-write bind target that exists, then /root, then /. + pub working_dir: Option, +} + +impl ExecOptions { + pub fn new() -> Self { + ExecOptions { + arch: String::new(), + binds: Vec::new(), + env: Vec::new(), + dns: Vec::new(), + command: Vec::new(), + working_dir: None, + } + } +} + +impl Default for ExecOptions { + fn default() -> Self { + Self::new() + } +} + +/// Run a command inside `rootfs` in fresh user/PID/mount/UTS namespaces and +/// return its exit code (128+signal when the command is killed by a signal). +/// Setup failures are returned as Err. +pub fn exec(rootfs: &Path, opts: &ExecOptions) -> Result { + let arch = if opts.arch.is_empty() { + utils::get_host_arch().debian_name().to_string() + } else { + opts.arch.clone() + }; + + // Foreign-architecture rootfs needs QEMU user emulation to run its + // binaries; fail before doing any mount work. + if Arch::from_str(&arch) != utils::get_host_arch() { + qemu::check_binfmt(&arch)?; + } + + namespace::check_user_namespace()?; + + let mut opts = opts.clone(); + if opts.env.is_empty() { + opts.env = chroot::default_env(rootfs); + } + + let rootfs_buf = rootfs.to_path_buf(); + + namespace::setup_namespaces(move || { + // Setup mounts - overlay_temps must be kept alive for overlays to work + let overlay_temps = mount::setup_mounts(&rootfs_buf, &opts.binds)?; + + for bind in &opts.binds { + if !bind.read_only { + veprintln!("Read-write mount: {}", bind.target.display()); + } + } + + write_resolv_conf(&rootfs_buf, &opts.dns)?; + + let working_dir = resolve_working_dir(&rootfs_buf, &opts)?; + + let command = if opts.command.is_empty() { + None + } else { + Some(opts.command.clone()) + }; + + let result = chroot::run_chroot(&rootfs_buf, command.as_deref(), &opts.env, &working_dir); + + // Keep overlay_temps alive until chroot exits + drop(overlay_temps); + + result + }) +} + +/// Resolve the working directory for the exec'd command. +/// +/// Existence checks happen against `rootfs`-prefixed paths, which is +/// equivalent to checking the absolute paths after chroot(2) — same tree, +/// same mounts (this runs after setup_mounts). +fn resolve_working_dir(rootfs: &Path, opts: &ExecOptions) -> Result { + if let Some(dir) = &opts.working_dir { + return Ok(dir.clone()); + } + + let mut candidates: Vec = Vec::new(); + if let Some(bind) = opts.binds.iter().find(|b| !b.read_only) { + candidates.push(bind.target.clone()); + } + candidates.push("/root".into()); + candidates.push("/".into()); + + // Deduplicate while keeping first-seen order + let mut seen = HashSet::new(); + candidates.retain(|c| seen.insert(c.clone())); + + for candidate in candidates { + let exists = mount::in_rootfs(rootfs, &candidate) + .map(|p| p.exists()) + .unwrap_or(false); + if exists { + return Ok(candidate); + } + } + + Ok(PathBuf::from("/")) +} + +/// Write /etc/resolv.conf with the caller's DNS servers (host resolver when +/// empty), atomically and never through a symlink. +pub fn write_resolv_conf(rootfs: &Path, dns: &[String]) -> Result<()> { + let resolv_conf = rootfs.join("etc/resolv.conf"); + + // Create /etc if it doesn't exist + if let Some(parent) = resolv_conf.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + + // Copy host's resolv.conf if dns is empty, otherwise use provided DNS + let content = if dns.is_empty() { + // Try to copy from host + match std::fs::read_to_string("/etc/resolv.conf") { + Ok(host_resolv) => host_resolv, + Err(_) => "nameserver 1.1.1.1\nnameserver 8.8.8.8\n".to_string(), + } + } else { + let mut c = dns + .iter() + .map(|s| format!("nameserver {}", s)) + .collect::>() + .join("\n"); + c.push('\n'); + c + }; + + // Remove any existing file or symlink before writing so that we always + // create a plain file. Without this, an absolute symlink such as + // /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf would cause the + // write to follow the symlink through the *host* root (chroot() has not been + // called yet) and corrupt the host's DNS configuration. + // + // Use atomic file creation with O_CREAT | O_EXCL to prevent TOCTOU race: + // if an attacker creates a symlink between our remove_file and write, the + // exclusive create will fail rather than writing to the symlink target. + let _ = std::fs::remove_file(&resolv_conf); // ignore ENOENT + + // Use OpenOptions with create_new(true) for atomic exclusive creation + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&resolv_conf) + .with_context(|| { + format!( + "Failed to create resolv.conf at {} (symlink attack prevented)", + resolv_conf.display() + ) + })?; + + file.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write to {}", resolv_conf.display()))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(binds: Vec, working_dir: Option) -> ExecOptions { + ExecOptions { + binds, + working_dir, + ..ExecOptions::default() + } + } + + #[test] + fn explicit_working_dir_wins() { + let dir = tempfile::tempdir().unwrap(); + let resolved = + resolve_working_dir(dir.path(), &opts(Vec::new(), Some("/custom".into()))).unwrap(); + assert_eq!(resolved, PathBuf::from("/custom")); + } + + #[test] + fn first_read_write_bind_target_preferred() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("mnt/data")).unwrap(); + let binds = vec![ + BindTarget { + source: "/host/ro".into(), + target: "/root/ro".into(), + read_only: true, + }, + BindTarget { + source: "/host/data".into(), + target: "/mnt/data".into(), + read_only: false, + }, + ]; + let resolved = resolve_working_dir(dir.path(), &opts(binds, None)).unwrap(); + assert_eq!(resolved, PathBuf::from("/mnt/data")); + } + + #[test] + fn falls_back_to_root_dir_when_no_binds() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("root")).unwrap(); + let resolved = resolve_working_dir(dir.path(), &opts(Vec::new(), None)).unwrap(); + assert_eq!(resolved, PathBuf::from("/root")); + } + + #[test] + fn missing_read_write_target_falls_through_to_root_dir() { + // The bind target does not exist under rootfs (e.g. bind skipped): + // the candidate list must move on to /root rather than failing. + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("root")).unwrap(); + let binds = vec![BindTarget { + source: "/host/data".into(), + target: "/mnt/data".into(), + read_only: false, + }]; + let resolved = resolve_working_dir(dir.path(), &opts(binds, None)).unwrap(); + assert_eq!(resolved, PathBuf::from("/root")); + } + + #[test] + fn last_resort_is_slash() { + let dir = tempfile::tempdir().unwrap(); + let resolved = resolve_working_dir(dir.path(), &opts(Vec::new(), None)).unwrap(); + assert_eq!(resolved, PathBuf::from("/")); + } + + #[test] + fn write_resolv_conf_uses_caller_dns() { + let dir = tempfile::tempdir().unwrap(); + write_resolv_conf(dir.path(), &["8.8.8.8".to_string(), "1.0.0.1".to_string()]).unwrap(); + + let content = std::fs::read_to_string(dir.path().join("etc/resolv.conf")).unwrap(); + assert_eq!(content, "nameserver 8.8.8.8\nnameserver 1.0.0.1\n"); + } + + #[test] + fn write_resolv_conf_replaces_existing_file() { + // Re-running exec on the same rootfs must overwrite, not fail on + // the exclusive create. + let dir = tempfile::tempdir().unwrap(); + write_resolv_conf(dir.path(), &["8.8.8.8".to_string()]).unwrap(); + write_resolv_conf(dir.path(), &["9.9.9.9".to_string()]).unwrap(); + + let content = std::fs::read_to_string(dir.path().join("etc/resolv.conf")).unwrap(); + assert_eq!(content, "nameserver 9.9.9.9\n"); + } +} diff --git a/crates/ecr/src/lib.rs b/crates/ecr/src/lib.rs index 531a15b..9a9e1ab 100644 --- a/crates/ecr/src/lib.rs +++ b/crates/ecr/src/lib.rs @@ -5,11 +5,16 @@ //! the rootfs into a scratch directory, and runs commands inside //! unprivileged user/PID/mount/UTS namespaces — or boots it in a QEMU VM //! (see the `qemu_vm` module). +//! +//! The main entry point is [`exec`]: it runs a command inside a prepared +//! rootfs with a caller-composed environment, explicit bind targets and a +//! target architecture. pub mod chroot; pub mod config; pub mod distro; pub mod download; +pub mod exec; pub mod extract; pub mod kernel; pub mod mount; @@ -28,3 +33,6 @@ macro_rules! veprintln { } }; } + +pub use exec::{exec, ExecOptions}; +pub use mount::BindTarget; diff --git a/crates/ecr/src/mount.rs b/crates/ecr/src/mount.rs index 3bf396f..236b9cb 100644 --- a/crates/ecr/src/mount.rs +++ b/crates/ecr/src/mount.rs @@ -21,14 +21,41 @@ fn escape_overlay_path(path: &Path) -> Result { Ok(s.replace('\\', "\\\\").replace(',', "\\,")) } +/// A host directory to expose inside the rootfs. +/// +/// Read-only targets are overlaid (host content stays pristine, chroot +/// writes go to a scratch upperdir that dies with the session); read-write +/// targets are plain bind mounts (chroot writes land on the host). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindTarget { + /// Directory on the host. + pub source: std::path::PathBuf, + /// Absolute mount point inside the rootfs (e.g. `/root/myapp`). + pub target: std::path::PathBuf, + /// Mount read-write (bind) instead of read-only (overlay). + pub read_only: bool, +} + +/// Map an absolute in-chroot path to its location under `rootfs`. +/// Rejects targets containing `..` components, which would escape the rootfs +/// and let a mount clobber host paths outside it. +pub fn in_rootfs(rootfs: &Path, target: &Path) -> Result { + let relative = target.strip_prefix("/").unwrap_or(target); + let escapes = relative + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)); + if escapes { + return Err(anyhow!( + "Path '{}' escapes the rootfs: '..' components are not allowed", + target.display() + )); + } + Ok(rootfs.join(relative)) +} + /// Setup all required mounts inside the chroot /// Returns a TempDir that must be kept alive for the duration of the chroot -pub fn setup_mounts( - rootfs: &Path, - bind_paths: &[std::path::PathBuf], - bind_rw_paths: &[std::path::PathBuf], - no_bind: bool, -) -> Result> { +pub fn setup_mounts(rootfs: &Path, binds: &[BindTarget]) -> Result> { // Keep all overlay temp dirs alive let mut overlay_temps: Vec = Vec::new(); @@ -57,22 +84,15 @@ pub fn setup_mounts( eprintln!("Warning: Could not mount /sys: {}", e); } - // Setup overlay mounts for bind paths (read-only via overlay) - if !no_bind { - for bind_path in bind_paths { - // Skip if this path is also in bind_rw (bind_rw takes precedence) - if !bind_rw_paths.contains(bind_path) { - let temp = setup_overlay(rootfs, bind_path)?; - overlay_temps.push(temp); - } + for bind in binds { + let mount_point = in_rootfs(rootfs, &bind.target)?; + if bind.read_only { + overlay_temps.push(setup_overlay(&bind.source, &mount_point)?); + } else { + setup_bind_rw(&bind.source, &mount_point)?; } } - // Setup read-write bind mounts (these override regular bind for same paths) - for bind_rw_path in bind_rw_paths { - setup_bind_rw(rootfs, bind_rw_path)?; - } - Ok(overlay_temps) } @@ -162,16 +182,10 @@ fn mount_devpts(rootfs: &Path) -> Result<()> { Ok(()) } -/// Setup overlay mount for workspace directory +/// Setup overlay mount for a workspace directory /// Returns a TempDir that must be kept alive for the overlay to work -fn setup_overlay(rootfs: &Path, source: &Path) -> Result { - let basename = source - .file_name() - .ok_or_else(|| anyhow!("Invalid bind path"))? - .to_string_lossy(); - - let mount_point = rootfs.join("root").join(basename.as_ref()); - std::fs::create_dir_all(&mount_point)?; +fn setup_overlay(source: &Path, mount_point: &Path) -> Result { + std::fs::create_dir_all(mount_point)?; // Create temp directories for overlay let temp_dir = tempfile::tempdir()?; @@ -194,7 +208,7 @@ fn setup_overlay(rootfs: &Path, source: &Path) -> Result { mount( Some("overlay"), - &mount_point, + mount_point, Some("overlay"), MsFlags::empty(), Some(options.as_str()), @@ -206,20 +220,14 @@ fn setup_overlay(rootfs: &Path, source: &Path) -> Result { } /// Setup read-write bind mount -fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> { - let basename = source - .file_name() - .ok_or_else(|| anyhow!("Invalid bind-rw path"))? - .to_string_lossy(); - - let mount_point = rootfs.join("mnt").join(basename.as_ref()); - std::fs::create_dir_all(&mount_point)?; +fn setup_bind_rw(source: &Path, mount_point: &Path) -> Result<()> { + std::fs::create_dir_all(mount_point)?; let source = source.canonicalize()?; mount( Some(&source), - &mount_point, + mount_point, None::<&str>, MsFlags::MS_BIND | MsFlags::MS_REC, None::<&str>, @@ -237,8 +245,42 @@ fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> { #[cfg(test)] mod tests { - use super::escape_overlay_path; - use std::path::Path; + use super::{escape_overlay_path, in_rootfs, BindTarget}; + use std::path::{Path, PathBuf}; + + fn bind(source: &str, target: &str, read_only: bool) -> BindTarget { + BindTarget { + source: PathBuf::from(source), + target: PathBuf::from(target), + read_only, + } + } + + #[test] + fn absolute_target_joins_under_rootfs() { + let p = in_rootfs(Path::new("/rootfs"), Path::new("/mnt/data")).unwrap(); + assert_eq!(p, Path::new("/rootfs/mnt/data")); + } + + #[test] + fn root_target_maps_to_rootfs() { + let p = in_rootfs(Path::new("/rootfs"), Path::new("/")).unwrap(); + assert_eq!(p, Path::new("/rootfs")); + } + + #[test] + fn parent_components_rejected() { + assert!(in_rootfs(Path::new("/rootfs"), Path::new("/../etc")).is_err()); + assert!(in_rootfs(Path::new("/rootfs"), Path::new("/mnt/../../host")).is_err()); + } + + #[test] + fn bind_target_fields_roundtrip() { + let b = bind("/host/dir", "/mnt/dir", false); + assert_eq!(b.source, PathBuf::from("/host/dir")); + assert_eq!(b.target, PathBuf::from("/mnt/dir")); + assert!(!b.read_only); + } #[test] fn plain_path_unchanged() {