refactor: split ecr into library and cli crates
The root package becomes a virtual workspace: `crates/ecr` holds the library (package name `ecr`) and `crates/ecr-cli` the command line front-end, which keeps installing the `ecr` binary. No behavior change. The library must not depend on CLI types, so mount::setup_mounts now takes the `no_bind` flag instead of a `&Args`.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "ecr-cli"
|
||||
description = "Enter chroot environments with Linux namespaces"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# The installed binary keeps the `ecr` name even though the CLI package is
|
||||
# `ecr-cli`; the library package owns the `ecr` name for consumers.
|
||||
[[bin]]
|
||||
name = "ecr"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ecr = { path = "../ecr" }
|
||||
|
||||
# CLI parsing
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
# Interim: used by the CLI's inline cache/extraction orchestration until it
|
||||
# moves behind the library's rootfs API
|
||||
dirs = "6"
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,63 @@
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Enter chroot environments with Linux namespaces
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(author, version, about, long_about = None, override_usage = "ecr [OPTIONS] <DISTRO[:VERSION]> -- [COMMAND]...")]
|
||||
pub struct Args {
|
||||
/// Distribution name (e.g., ubuntu, debian, arch, alpine, fedora)
|
||||
#[arg(value_name = "DISTRO[:VERSION]")]
|
||||
pub distro: String,
|
||||
|
||||
/// Target architecture
|
||||
#[arg(short, long, value_name = "ARCH")]
|
||||
pub arch: Option<String>,
|
||||
|
||||
/// Directory to overlay-mount (can be specified multiple times, default: current directory)
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub bind: Vec<PathBuf>,
|
||||
|
||||
/// Directory to bind-mount read-write at /mnt/<basename> (overrides regular bind, can be specified multiple times)
|
||||
#[arg(long, value_name = "PATH")]
|
||||
pub bind_rw: Vec<PathBuf>,
|
||||
|
||||
/// Download fresh tarball, ignore cache
|
||||
#[arg(long)]
|
||||
pub no_cache: bool,
|
||||
|
||||
/// Skip mounting any directory
|
||||
#[arg(long)]
|
||||
pub no_bind: bool,
|
||||
|
||||
/// Print diagnostic messages (URLs, manifest info, extraction steps, etc.)
|
||||
#[arg(short = 'v', long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Boot with QEMU system emulation (optionally specify kernel path with =PATH, or omit to download default)
|
||||
///
|
||||
/// Examples:
|
||||
/// --kernel Download and use the default Alpine linux-virt kernel
|
||||
/// --kernel=./vmlinuz Use a specific kernel file
|
||||
///
|
||||
/// The kernel path must use `=` syntax: with `--kernel ./vmlinuz` the
|
||||
/// path would be parsed as the DISTRO argument.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "KERNEL_PATH",
|
||||
num_args = 0..=1,
|
||||
require_equals = true
|
||||
)]
|
||||
pub kernel: Option<Option<PathBuf>>,
|
||||
|
||||
/// Memory size for QEMU VM (only used with --kernel, e.g., 512M, 2G)
|
||||
#[arg(short = 'm', long, default_value = "2G", value_name = "SIZE")]
|
||||
pub memory: String,
|
||||
|
||||
/// Command to run inside the chroot (default: interactive shell)
|
||||
#[arg(
|
||||
trailing_var_arg = true,
|
||||
allow_hyphen_values = true,
|
||||
value_name = "COMMAND"
|
||||
)]
|
||||
pub command: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
mod cli;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use cli::Args;
|
||||
use ecr::chroot;
|
||||
use ecr::config::Config;
|
||||
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::extract::extract_tarball;
|
||||
use ecr::{kernel, mount, namespace, qemu, qemu_vm, utils, veprintln, verbose};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Initialise verbosity before anything else so all downstream code can use veprintln!.
|
||||
verbose::set(args.verbose);
|
||||
|
||||
// Load config file
|
||||
let config = Config::load()?;
|
||||
|
||||
// Get architecture
|
||||
let host_arch = get_host_arch();
|
||||
let arch = args.arch.clone().unwrap_or_else(|| host_arch.clone());
|
||||
|
||||
// Parse image reference
|
||||
let image_source = parse_image_ref(&args.distro, &arch)?;
|
||||
|
||||
// For DirectTarball, resolve floating aliases ("latest", "lts") to a concrete
|
||||
// version string *before* computing the cache key. This ensures we cache as
|
||||
// e.g. "ubuntu-noble-amd64" rather than "ubuntu-latest-amd64", so a future
|
||||
// release automatically gets its own cache entry.
|
||||
let image_source = match image_source {
|
||||
ImageSource::DirectTarball { distro, version } => {
|
||||
let resolved = resolve_distro_version(&distro, version.as_deref(), &arch)?;
|
||||
ImageSource::DirectTarball {
|
||||
distro,
|
||||
version: Some(resolved),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
|
||||
// Determine cache directory and filename
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.expect("Could not determine cache directory")
|
||||
.join("ecr");
|
||||
let cache_filename = generate_cache_filename(&image_source, &arch);
|
||||
let cache_path = cache_dir.join(&cache_filename);
|
||||
|
||||
// OCI images with a floating tag (":latest") need a freshness check:
|
||||
// fetch the current manifest digest from the registry and compare it
|
||||
// against the digest stored from the last download. Only re-pull when
|
||||
// the digest has actually changed. On a network error we fall back to
|
||||
// the cached image with a warning rather than hard-failing.
|
||||
let oci_digest_changed = if cache_path.exists() {
|
||||
if let ImageSource::OciImage {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
..
|
||||
} = &image_source
|
||||
{
|
||||
if tag == "latest" {
|
||||
match fetch_oci_digest(registry, repository, tag) {
|
||||
Ok(current) => {
|
||||
let stored = std::fs::read_to_string(digest_sidecar(&cache_path)).ok();
|
||||
stored.as_deref() != Some(current.trim())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Warning: could not check image freshness ({}); using cache",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false // pinned tags are assumed immutable
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false // cache absent — download triggered by !cache_path.exists() below
|
||||
};
|
||||
|
||||
// Download if not cached, --no-cache, or the remote digest has moved
|
||||
if args.no_cache || !cache_path.exists() || oci_digest_changed {
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
download_image(&image_source, &cache_path, &arch)?;
|
||||
} else {
|
||||
veprintln!("Using cached tarball: {}", cache_path.display());
|
||||
}
|
||||
|
||||
// Check QEMU if foreign architecture (for namespace mode)
|
||||
// For VM mode, we don't need binfmt_misc since we're using system emulation
|
||||
if args.kernel.is_none() && arch != host_arch {
|
||||
qemu::check_binfmt(&arch)?;
|
||||
}
|
||||
|
||||
// Create temp directory for extraction
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let rootfs = temp_dir.path().to_path_buf();
|
||||
|
||||
veprintln!("Extracting to: {}", rootfs.display());
|
||||
extract_tarball(&cache_path, &rootfs)?;
|
||||
|
||||
// Branch based on --kernel flag
|
||||
// Option<Option<PathBuf>> (require_equals: the value must use =PATH syntax
|
||||
// so it can never swallow the DISTRO positional):
|
||||
// None -> --kernel not specified, use namespace mode
|
||||
// Some(None) -> --kernel without path, download default kernel
|
||||
// Some(Some(path)) -> --kernel=/path/to/vmlinuz, use provided kernel
|
||||
if let Some(kernel_opt) = &args.kernel {
|
||||
// VM mode boots an initramfs: host bind mounts are never applied
|
||||
if !args.bind.is_empty() || !args.bind_rw.is_empty() {
|
||||
eprintln!(
|
||||
"Warning: --bind/--bind-rw are ignored with --kernel \
|
||||
(the VM boots from an initramfs, no host directories are mounted)"
|
||||
);
|
||||
}
|
||||
|
||||
// QEMU system mode
|
||||
let kernel_path = match kernel_opt {
|
||||
Some(path) => {
|
||||
veprintln!("QEMU mode: using provided kernel {}", path.display());
|
||||
path.clone()
|
||||
}
|
||||
None => {
|
||||
veprintln!("QEMU mode: downloading default kernel...");
|
||||
kernel::get_default_kernel(&cache_dir, &arch)?
|
||||
}
|
||||
};
|
||||
|
||||
let command = if args.command.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(args.command.clone())
|
||||
};
|
||||
|
||||
let result = qemu_vm::launch_qemu(qemu_vm::QemuConfig {
|
||||
kernel_path,
|
||||
rootfs_path: rootfs,
|
||||
memory: args.memory.clone(),
|
||||
arch: arch.clone(),
|
||||
command,
|
||||
});
|
||||
|
||||
// Cleanup happens automatically via tempfile
|
||||
if result.is_ok() {
|
||||
veprintln!("Cleanup complete.");
|
||||
}
|
||||
|
||||
result
|
||||
} else {
|
||||
// Namespace/chroot mode
|
||||
let exit_code = namespace_mode(args, rootfs, config)?;
|
||||
// Propagate the command's exit code, cleaning up the extracted rootfs
|
||||
// first: process::exit does not run destructors.
|
||||
drop(temp_dir);
|
||||
if exit_code != 0 {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
|
||||
// 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()]
|
||||
} 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.");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Generate a cache filename based on the image source
|
||||
fn generate_cache_filename(source: &ImageSource, arch: &str) -> String {
|
||||
match source {
|
||||
ImageSource::DirectTarball { distro, version } => {
|
||||
let distro_name = match distro {
|
||||
Distro::Ubuntu => "ubuntu",
|
||||
Distro::Alpine => "alpine",
|
||||
};
|
||||
let distro_arch = map_arch(*distro, arch);
|
||||
// Get extension from URL
|
||||
let url = resolve_distro_url(distro, version.as_deref(), arch).unwrap_or_default();
|
||||
let ext = get_tarball_extension(&url);
|
||||
format!(
|
||||
"{}-{}-{}.{}",
|
||||
distro_name,
|
||||
version.as_deref().unwrap_or("latest"),
|
||||
distro_arch,
|
||||
ext
|
||||
)
|
||||
}
|
||||
ImageSource::OciImage {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
architecture,
|
||||
} => {
|
||||
// Sanitize for filename
|
||||
let safe_registry = registry.replace(['.', ':'], "_");
|
||||
let safe_repo = repository.replace(['/', ':'], "_");
|
||||
format!(
|
||||
"oci-{}-{}-{}-{}.tar.gz",
|
||||
safe_registry, safe_repo, tag, architecture
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tarball_extension(url: &str) -> &str {
|
||||
// Extract extension from URL (e.g., .tar.gz, .tar.xz, .tar.zst)
|
||||
if url.ends_with(".tar.zst") {
|
||||
"tar.zst"
|
||||
} else if url.ends_with(".tar.xz") {
|
||||
"tar.xz"
|
||||
} else if url.ends_with(".tar.gz") {
|
||||
"tar.gz"
|
||||
} else if url.ends_with(".tar.bz2") {
|
||||
"tar.bz2"
|
||||
} else {
|
||||
"tar.gz" // default
|
||||
}
|
||||
}
|
||||
|
||||
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