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/<basename>, rw bind at /mnt/<basename>, cwd default).
This commit is contained in:
+73
-116
@@ -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<i32> {
|
||||
// Check user namespace availability
|
||||
namespace::check_user_namespace()?;
|
||||
fn namespace_mode(args: &Args, rootfs: &Path, config: &Config, arch: &str) -> Result<i32> {
|
||||
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<std::path::PathBuf> = 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/<basename>, --bind-rw bind-mounts
|
||||
/// read-write at /mnt/<basename> (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<Vec<BindTarget>> {
|
||||
// --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<PathBuf> = 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<std::path::PathBuf> = 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<PathBuf> {
|
||||
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::<Vec<_>>()
|
||||
.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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user