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:
+68
-111
@@ -1,5 +1,7 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
@@ -10,8 +12,10 @@ use ecr::distro::{
|
|||||||
map_arch, parse_image_ref, resolve_distro_url, resolve_distro_version, Distro, ImageSource,
|
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::download::{digest_sidecar, download_image, fetch_oci_digest};
|
||||||
|
use ecr::exec::ExecOptions;
|
||||||
use ecr::extract::extract_tarball;
|
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<()> {
|
fn main() -> Result<()> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
@@ -158,7 +162,7 @@ fn main() -> Result<()> {
|
|||||||
result
|
result
|
||||||
} else {
|
} else {
|
||||||
// Namespace/chroot mode
|
// 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
|
// Propagate the command's exit code, cleaning up the extracted rootfs
|
||||||
// first: process::exit does not run destructors.
|
// first: process::exit does not run destructors.
|
||||||
drop(temp_dir);
|
drop(temp_dir);
|
||||||
@@ -170,70 +174,80 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run in namespace/chroot mode, returning the command's exit code
|
/// Run in namespace/chroot mode, returning the command's exit code
|
||||||
fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Result<i32> {
|
fn namespace_mode(args: &Args, rootfs: &Path, config: &Config, arch: &str) -> Result<i32> {
|
||||||
// Check user namespace availability
|
let opts = ExecOptions {
|
||||||
namespace::check_user_namespace()?;
|
arch: arch.to_string(),
|
||||||
|
binds: cli_bind_targets(args)?,
|
||||||
// Process bind paths - use current directory if none specified
|
env: chroot::default_env(rootfs),
|
||||||
let cwd = std::env::current_dir().expect("Could not get current directory");
|
dns: config.dns.clone(),
|
||||||
let bind_paths: Vec<std::path::PathBuf> = if args.bind.is_empty() && !args.no_bind {
|
command: args.command.clone(),
|
||||||
vec![cwd.clone()]
|
working_dir: None,
|
||||||
} else {
|
|
||||||
args.bind.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
|
// --no-bind means "skip mounting any directory". Combining it with an
|
||||||
// explicit --bind-rw is contradictory; error rather than silently ignoring
|
// explicit --bind-rw is contradictory; error rather than silently ignoring
|
||||||
// the flag the user asked for.
|
// the flag the user asked for.
|
||||||
if args.no_bind && !args.bind_rw.is_empty() {
|
if args.no_bind {
|
||||||
|
if !args.bind_rw.is_empty() {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"--no-bind and --bind-rw cannot be used together: \
|
"--no-bind and --bind-rw cannot be used together: \
|
||||||
--no-bind skips all mounts, including read-write ones"
|
--no-bind skips all mounts, including read-write ones"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let bind_rw_paths: Vec<std::path::PathBuf> = args.bind_rw.clone();
|
return Ok(Vec::new());
|
||||||
|
|
||||||
// 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.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
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()
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
/// 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
|
// Use the consolidated architecture detection from utils
|
||||||
utils::get_host_arch().debian_name().to_string()
|
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(())
|
|
||||||
}
|
|
||||||
|
|||||||
+79
-91
@@ -1,18 +1,41 @@
|
|||||||
use crate::veprintln;
|
use crate::veprintln;
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use nix::unistd::{chroot, execve};
|
use nix::unistd::{chroot, execve};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::Path;
|
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(
|
pub fn run_chroot(
|
||||||
rootfs: &Path,
|
rootfs: &Path,
|
||||||
command: Option<Vec<String>>,
|
command: Option<&[String]>,
|
||||||
bind_rw_paths: &[std::path::PathBuf],
|
env: &[(String, String)],
|
||||||
|
working_dir: &Path,
|
||||||
) -> Result<()> {
|
) -> 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
|
// Set hostname in UTS namespace
|
||||||
if let Err(e) = crate::namespace::set_hostname("chroot") {
|
if let Err(e) = crate::namespace::set_hostname("chroot") {
|
||||||
eprintln!("Warning: Failed to set hostname: {}", e);
|
eprintln!("Warning: Failed to set hostname: {}", e);
|
||||||
@@ -24,10 +47,7 @@ pub fn run_chroot(
|
|||||||
// Change to root directory in chroot
|
// Change to root directory in chroot
|
||||||
chroot(rootfs).context("Failed to chroot")?;
|
chroot(rootfs).context("Failed to chroot")?;
|
||||||
|
|
||||||
// Now we're inside the chroot - set up environment based on chroot filesystem
|
// Now we're inside the chroot
|
||||||
|
|
||||||
// Set up environment variables (after chroot, so paths are correct)
|
|
||||||
let env = setup_environment(shell, &host_term);
|
|
||||||
|
|
||||||
// Determine the command to run
|
// Determine the command to run
|
||||||
let (program, args) = match command {
|
let (program, args) = match command {
|
||||||
@@ -51,9 +71,8 @@ pub fn run_chroot(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build an explicit envp from setup_environment so the host environment
|
// Build the envp from the caller-composed pairs. execve takes this array
|
||||||
// is never inherited. execve takes this array directly; the host process
|
// directly; the host process environment is not touched at all.
|
||||||
// environment is not touched at all.
|
|
||||||
let env_cstrings = env
|
let env_cstrings = env
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| {
|
.map(|(k, v)| {
|
||||||
@@ -62,39 +81,21 @@ pub fn run_chroot(
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
// Change to first bind_rw directory if available, otherwise /root, otherwise /
|
std::env::set_current_dir(working_dir).with_context(|| {
|
||||||
// bind_rw paths are mounted at /mnt/<basename> (see mount.rs setup_bind_rw)
|
format!(
|
||||||
let working_dir = if let Some(first_bind_rw) = bind_rw_paths.first() {
|
"Failed to change to working directory {}",
|
||||||
let dest_dir = Path::new("/mnt").join(first_bind_rw.file_name().unwrap_or_default());
|
working_dir.display()
|
||||||
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")?;
|
|
||||||
|
|
||||||
// Print welcome message
|
// Print welcome message
|
||||||
veprintln!("Entering chroot at {}", rootfs.display());
|
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());
|
veprintln!("Working directory: {}", working_dir.display());
|
||||||
|
|
||||||
// Check if the program exists
|
// Check if the program exists
|
||||||
if !Path::new(&program).exists() {
|
if !Path::new(&program).exists() {
|
||||||
// Try to find it in PATH
|
// Try to find it in the caller-composed PATH
|
||||||
let found = env.get("PATH").and_then(|path| {
|
let found = env.iter().find(|(k, _)| k == "PATH").and_then(|(_, path)| {
|
||||||
path.split(':')
|
path.split(':')
|
||||||
.map(|p| std::path::PathBuf::from(p).join(&program))
|
.map(|p| std::path::PathBuf::from(p).join(&program))
|
||||||
.find(|p| p.exists())
|
.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.
|
// execve never returns on success.
|
||||||
let program_cstr = std::ffi::CString::new(program.as_str()).context("Invalid program name")?;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn env_get<'a>(env: &'a [(String, String)], key: &str) -> Option<&'a str> {
|
||||||
fn test_setup_environment_defaults() {
|
env.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
|
||||||
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())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_setup_environment_custom_shell() {
|
fn test_default_env_entries() {
|
||||||
let env = setup_environment("/usr/bin/zsh", "screen");
|
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);
|
assert_eq!(env.len(), 5);
|
||||||
|
assert_eq!(env_get(&env, "HOME"), Some("/root"));
|
||||||
// Should NOT have any host-specific variables
|
assert_eq!(env_get(&env, "USER"), Some("root"));
|
||||||
assert!(!env.contains_key("LANG"));
|
assert_eq!(env_get(&env, "SHELL"), Some("/bin/sh"));
|
||||||
assert!(!env.contains_key("DISPLAY"));
|
|
||||||
assert!(!env.contains_key("PWD"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_path_contains_standard_directories() {
|
fn test_default_env_shell_detection() {
|
||||||
let env = setup_environment("/bin/bash", "xterm");
|
// A rootfs with bash present must select /bin/bash
|
||||||
let path = env.get("PATH").expect("PATH should be set");
|
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
|
// Verify essential directories are in PATH
|
||||||
assert!(path.contains("/bin"));
|
assert!(path.contains("/bin"));
|
||||||
@@ -186,4 +160,18 @@ mod tests {
|
|||||||
assert!(path.contains("/sbin"));
|
assert!(path.contains("/sbin"));
|
||||||
assert!(path.contains("/usr/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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<BindTarget>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
|
||||||
|
/// argv to exec inside the rootfs. Empty runs the rootfs default shell
|
||||||
|
/// (bash if present, else sh).
|
||||||
|
pub command: Vec<String>,
|
||||||
|
|
||||||
|
/// Working directory inside the rootfs. Empty picks the first
|
||||||
|
/// read-write bind target that exists, then /root, then /.
|
||||||
|
pub working_dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<i32> {
|
||||||
|
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<PathBuf> {
|
||||||
|
if let Some(dir) = &opts.working_dir {
|
||||||
|
return Ok(dir.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates: Vec<PathBuf> = 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::<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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn opts(binds: Vec<BindTarget>, working_dir: Option<PathBuf>) -> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,16 @@
|
|||||||
//! the rootfs into a scratch directory, and runs commands inside
|
//! the rootfs into a scratch directory, and runs commands inside
|
||||||
//! unprivileged user/PID/mount/UTS namespaces — or boots it in a QEMU VM
|
//! unprivileged user/PID/mount/UTS namespaces — or boots it in a QEMU VM
|
||||||
//! (see the `qemu_vm` module).
|
//! (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 chroot;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod distro;
|
pub mod distro;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
|
pub mod exec;
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
pub mod kernel;
|
pub mod kernel;
|
||||||
pub mod mount;
|
pub mod mount;
|
||||||
@@ -28,3 +33,6 @@ macro_rules! veprintln {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub use exec::{exec, ExecOptions};
|
||||||
|
pub use mount::BindTarget;
|
||||||
|
|||||||
+82
-40
@@ -21,14 +21,41 @@ fn escape_overlay_path(path: &Path) -> Result<String> {
|
|||||||
Ok(s.replace('\\', "\\\\").replace(',', "\\,"))
|
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<std::path::PathBuf> {
|
||||||
|
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
|
/// Setup all required mounts inside the chroot
|
||||||
/// Returns a TempDir that must be kept alive for the duration of the chroot
|
/// Returns a TempDir that must be kept alive for the duration of the chroot
|
||||||
pub fn setup_mounts(
|
pub fn setup_mounts(rootfs: &Path, binds: &[BindTarget]) -> Result<Vec<TempDir>> {
|
||||||
rootfs: &Path,
|
|
||||||
bind_paths: &[std::path::PathBuf],
|
|
||||||
bind_rw_paths: &[std::path::PathBuf],
|
|
||||||
no_bind: bool,
|
|
||||||
) -> Result<Vec<TempDir>> {
|
|
||||||
// Keep all overlay temp dirs alive
|
// Keep all overlay temp dirs alive
|
||||||
let mut overlay_temps: Vec<TempDir> = Vec::new();
|
let mut overlay_temps: Vec<TempDir> = Vec::new();
|
||||||
|
|
||||||
@@ -57,21 +84,14 @@ pub fn setup_mounts(
|
|||||||
eprintln!("Warning: Could not mount /sys: {}", e);
|
eprintln!("Warning: Could not mount /sys: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup overlay mounts for bind paths (read-only via overlay)
|
for bind in binds {
|
||||||
if !no_bind {
|
let mount_point = in_rootfs(rootfs, &bind.target)?;
|
||||||
for bind_path in bind_paths {
|
if bind.read_only {
|
||||||
// Skip if this path is also in bind_rw (bind_rw takes precedence)
|
overlay_temps.push(setup_overlay(&bind.source, &mount_point)?);
|
||||||
if !bind_rw_paths.contains(bind_path) {
|
} else {
|
||||||
let temp = setup_overlay(rootfs, bind_path)?;
|
setup_bind_rw(&bind.source, &mount_point)?;
|
||||||
overlay_temps.push(temp);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
Ok(overlay_temps)
|
||||||
}
|
}
|
||||||
@@ -162,16 +182,10 @@ fn mount_devpts(rootfs: &Path) -> Result<()> {
|
|||||||
Ok(())
|
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
|
/// Returns a TempDir that must be kept alive for the overlay to work
|
||||||
fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
|
fn setup_overlay(source: &Path, mount_point: &Path) -> Result<TempDir> {
|
||||||
let basename = source
|
std::fs::create_dir_all(mount_point)?;
|
||||||
.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)?;
|
|
||||||
|
|
||||||
// Create temp directories for overlay
|
// Create temp directories for overlay
|
||||||
let temp_dir = tempfile::tempdir()?;
|
let temp_dir = tempfile::tempdir()?;
|
||||||
@@ -194,7 +208,7 @@ fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
|
|||||||
|
|
||||||
mount(
|
mount(
|
||||||
Some("overlay"),
|
Some("overlay"),
|
||||||
&mount_point,
|
mount_point,
|
||||||
Some("overlay"),
|
Some("overlay"),
|
||||||
MsFlags::empty(),
|
MsFlags::empty(),
|
||||||
Some(options.as_str()),
|
Some(options.as_str()),
|
||||||
@@ -206,20 +220,14 @@ fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Setup read-write bind mount
|
/// Setup read-write bind mount
|
||||||
fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> {
|
fn setup_bind_rw(source: &Path, mount_point: &Path) -> Result<()> {
|
||||||
let basename = source
|
std::fs::create_dir_all(mount_point)?;
|
||||||
.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)?;
|
|
||||||
|
|
||||||
let source = source.canonicalize()?;
|
let source = source.canonicalize()?;
|
||||||
|
|
||||||
mount(
|
mount(
|
||||||
Some(&source),
|
Some(&source),
|
||||||
&mount_point,
|
mount_point,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
MsFlags::MS_BIND | MsFlags::MS_REC,
|
MsFlags::MS_BIND | MsFlags::MS_REC,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
@@ -237,8 +245,42 @@ fn setup_bind_rw(rootfs: &Path, source: &Path) -> Result<()> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::escape_overlay_path;
|
use super::{escape_overlay_path, in_rootfs, BindTarget};
|
||||||
use std::path::Path;
|
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]
|
#[test]
|
||||||
fn plain_path_unchanged() {
|
fn plain_path_unchanged() {
|
||||||
|
|||||||
Reference in New Issue
Block a user