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(())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "ecr"
|
||||
description = "Ephemeral chroot environments with Linux namespaces"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Config parsing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# HTTP downloads
|
||||
reqwest = { version = "0.13", features = ["blocking", "stream"] }
|
||||
|
||||
# Tarball extraction
|
||||
tar = "0.4"
|
||||
flate2 = "1"
|
||||
xz2 = "0.1"
|
||||
zstd = "0.13"
|
||||
|
||||
# Unix syscall bindings
|
||||
nix = { version = "0.31", features = ["fs", "mount", "sched", "signal", "user", "process", "hostname"] }
|
||||
|
||||
# Temp directories
|
||||
tempfile = "3"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
# Utilities
|
||||
dirs = "6"
|
||||
which = "7"
|
||||
cpio = "0.4"
|
||||
base64 = "0.22"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
|
||||
futures-util = "0.3"
|
||||
indicatif = "0.18"
|
||||
serde_json = "1"
|
||||
libc = "0.2"
|
||||
users = "0.11"
|
||||
@@ -0,0 +1,189 @@
|
||||
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
|
||||
pub fn run_chroot(
|
||||
rootfs: &Path,
|
||||
command: Option<Vec<String>>,
|
||||
bind_rw_paths: &[std::path::PathBuf],
|
||||
) -> 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);
|
||||
}
|
||||
|
||||
// Detect shell before chroot (we're still outside)
|
||||
let shell = crate::utils::detect_shell(rootfs);
|
||||
|
||||
// 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);
|
||||
|
||||
// Determine the command to run
|
||||
let (program, args) = match command {
|
||||
Some(cmd) if !cmd.is_empty() => {
|
||||
let program = cmd[0].clone();
|
||||
let args = cmd
|
||||
.iter()
|
||||
.map(|s| {
|
||||
std::ffi::CString::new(s.as_str())
|
||||
.with_context(|| format!("Argument contains a null byte: {:?}", s))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
(program, args)
|
||||
}
|
||||
_ => {
|
||||
// Run shell (already determined above based on chroot filesystem)
|
||||
let program = shell.to_string();
|
||||
let args =
|
||||
vec![std::ffi::CString::new(shell).context("Shell path contains a null byte")?];
|
||||
(program, args)
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
let env_cstrings = env
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
std::ffi::CString::new(format!("{}={}", k, v))
|
||||
.with_context(|| format!("Environment variable contains a null byte: {}={}", k, v))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
// Change to first bind_rw directory if available, otherwise /root, otherwise /
|
||||
// bind_rw paths are mounted at /mnt/<basename> (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")?;
|
||||
|
||||
// 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| {
|
||||
path.split(':')
|
||||
.map(|p| std::path::PathBuf::from(p).join(&program))
|
||||
.find(|p| p.exists())
|
||||
});
|
||||
|
||||
if found.is_none() {
|
||||
return Err(anyhow!("Program not found: {}", program));
|
||||
}
|
||||
}
|
||||
|
||||
// Exec the program directly with an explicit, isolated environment.
|
||||
// execve never returns on success.
|
||||
let program_cstr = std::ffi::CString::new(program.as_str()).context("Invalid program name")?;
|
||||
|
||||
let result = execve(&program_cstr, &args, &env_cstrings);
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()), // Never reached
|
||||
Err(e) => Err(anyhow!("Failed to exec {}: {}", program, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_setup_environment_custom_shell() {
|
||||
let env = setup_environment("/usr/bin/zsh", "screen");
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_contains_standard_directories() {
|
||||
let env = setup_environment("/bin/bash", "xterm");
|
||||
let path = env.get("PATH").expect("PATH should be set");
|
||||
|
||||
// Verify essential directories are in PATH
|
||||
assert!(path.contains("/bin"));
|
||||
assert!(path.contains("/usr/bin"));
|
||||
assert!(path.contains("/sbin"));
|
||||
assert!(path.contains("/usr/sbin"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub dns: Vec<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = dirs::config_dir().map(|p| p.join("ecr.yaml"));
|
||||
|
||||
match config_path {
|
||||
Some(path) if path.exists() => {
|
||||
let content =
|
||||
std::fs::read_to_string(&path).context("Failed to read config file")?;
|
||||
|
||||
let config: Config =
|
||||
serde_yaml::from_str(&content).context("Failed to parse config file")?;
|
||||
|
||||
// Set defaults
|
||||
let config = Config {
|
||||
dns: if config.dns.is_empty() {
|
||||
vec!["1.1.1.1".to_string()]
|
||||
} else {
|
||||
config.dns
|
||||
},
|
||||
};
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
_ => {
|
||||
// No config file, use defaults
|
||||
Ok(Config {
|
||||
dns: vec!["1.1.1.1".to_string()],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
/// Known distributions with optimized direct tarball downloads
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Distro {
|
||||
Ubuntu,
|
||||
Alpine,
|
||||
}
|
||||
|
||||
/// Represents the source of the container image
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ImageSource {
|
||||
/// Direct tarball download from known distro
|
||||
DirectTarball {
|
||||
distro: Distro,
|
||||
version: Option<String>,
|
||||
},
|
||||
/// OCI/Docker registry image
|
||||
OciImage {
|
||||
registry: String,
|
||||
repository: String,
|
||||
tag: String,
|
||||
architecture: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Distro {
|
||||
pub fn from_name(name: &str) -> Result<Self> {
|
||||
match name.to_lowercase().as_str() {
|
||||
"ubuntu" => Ok(Distro::Ubuntu),
|
||||
"alpine" => Ok(Distro::Alpine),
|
||||
_ => Err(anyhow!("Unknown distribution: {}", name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse image reference and return the appropriate source
|
||||
/// Supports:
|
||||
/// - Simple distro names: ubuntu, debian, alpine
|
||||
/// - Distro with version: ubuntu:noble, alpine:3.19
|
||||
/// - OCI image references: docker://ubuntu:latest, quay.io/centos/centos:stream9
|
||||
/// - Docker Hub shorthand: ubuntu:latest (without registry prefix)
|
||||
pub fn parse_image_ref(input: &str, arch: &str) -> Result<ImageSource> {
|
||||
let input = input.trim();
|
||||
|
||||
// Check for explicit docker:// or oci:// prefix
|
||||
if let Some(rest) = input
|
||||
.strip_prefix("docker://")
|
||||
.or_else(|| input.strip_prefix("oci://"))
|
||||
{
|
||||
return parse_oci_ref(rest, arch);
|
||||
}
|
||||
|
||||
// Check for registry prefix (contains /)
|
||||
if input.contains('/') {
|
||||
return parse_oci_ref(input, arch);
|
||||
}
|
||||
|
||||
// Try to parse as known distro
|
||||
let (name, version) = match input.split_once(':') {
|
||||
Some((n, v)) => (n, Some(v.to_string())),
|
||||
None => (input, None),
|
||||
};
|
||||
|
||||
// Check if it's a known distro with optimized path
|
||||
match name.to_lowercase().as_str() {
|
||||
"ubuntu" | "alpine" => {
|
||||
let distro = Distro::from_name(name)?;
|
||||
Ok(ImageSource::DirectTarball { distro, version })
|
||||
}
|
||||
// Arch: use Docker image (has mirrors configured, unlike bootstrap tarball)
|
||||
"arch" => {
|
||||
let oci_arch = map_oci_arch(arch);
|
||||
Ok(ImageSource::OciImage {
|
||||
registry: "docker.io".to_string(),
|
||||
repository: "library/archlinux".to_string(),
|
||||
tag: version.unwrap_or_else(|| "latest".to_string()),
|
||||
architecture: oci_arch,
|
||||
})
|
||||
}
|
||||
// Special case: gentoo maps to gentoo/stage3 on Docker Hub (full rootfs)
|
||||
"gentoo" => {
|
||||
let oci_arch = map_oci_arch(arch);
|
||||
Ok(ImageSource::OciImage {
|
||||
registry: "docker.io".to_string(),
|
||||
repository: "gentoo/stage3".to_string(),
|
||||
tag: version.unwrap_or_else(|| "latest".to_string()),
|
||||
architecture: oci_arch,
|
||||
})
|
||||
}
|
||||
// Default to Docker Hub for unknown distros (debian, fedora, etc.)
|
||||
_ => parse_oci_ref(input, arch),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OCI image reference (registry/repo:tag or repo:tag)
|
||||
fn parse_oci_ref(input: &str, arch: &str) -> Result<ImageSource> {
|
||||
// Split off the tag. A ':' is a tag separator only when it appears after
|
||||
// the last '/'; any ':' before a '/' is a port number
|
||||
// (e.g. localhost:5000/image). With no '/' the single ':' is the tag.
|
||||
let (without_tag, tag) = if let Some(slash_pos) = input.rfind('/') {
|
||||
let after_slash = &input[slash_pos + 1..];
|
||||
if let Some(colon_pos) = after_slash.find(':') {
|
||||
(
|
||||
&input[..slash_pos + 1 + colon_pos],
|
||||
input[slash_pos + 1 + colon_pos + 1..].to_string(),
|
||||
)
|
||||
} else {
|
||||
(input, "latest".to_string())
|
||||
}
|
||||
} else {
|
||||
// No slash: bare "ubuntu" or "ubuntu:latest"
|
||||
match input.split_once(':') {
|
||||
Some((name, t)) => (name, t.to_string()),
|
||||
None => (input, "latest".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
// Split without_tag into registry + repository.
|
||||
// The first path component is the registry when it contains '.' or ':'
|
||||
// (hostname / host:port) or is the literal "localhost".
|
||||
let (registry, repository) = if let Some((first, rest)) = without_tag.split_once('/') {
|
||||
if first.contains('.') || first.contains(':') || first == "localhost" {
|
||||
(first.to_string(), rest.to_string())
|
||||
} else {
|
||||
// Org-qualified Docker Hub shorthand: "myorg/myimage"
|
||||
("docker.io".to_string(), without_tag.to_string())
|
||||
}
|
||||
} else {
|
||||
// Bare image name → Docker Hub library image
|
||||
("docker.io".to_string(), format!("library/{}", without_tag))
|
||||
};
|
||||
|
||||
// Map architecture to OCI standard
|
||||
let oci_arch = map_oci_arch(arch);
|
||||
|
||||
Ok(ImageSource::OciImage {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
architecture: oci_arch,
|
||||
})
|
||||
}
|
||||
|
||||
/// Map architecture to OCI standard names
|
||||
pub fn map_oci_arch(arch: &str) -> String {
|
||||
crate::utils::map_oci_arch(arch)
|
||||
}
|
||||
|
||||
/// Map ecr architecture names to distro-specific names
|
||||
pub fn map_arch(distro: Distro, arch: &str) -> String {
|
||||
let distro_name = match distro {
|
||||
Distro::Ubuntu => "ubuntu",
|
||||
Distro::Alpine => "alpine",
|
||||
};
|
||||
crate::utils::map_arch_for_distro(distro_name, arch)
|
||||
}
|
||||
|
||||
/// Resolve the download URL for a known distro (optimized path)
|
||||
pub fn resolve_distro_url(distro: &Distro, version: Option<&str>, arch: &str) -> Result<String> {
|
||||
match distro {
|
||||
Distro::Ubuntu => resolve_ubuntu_url(version, arch),
|
||||
Distro::Alpine => resolve_alpine_url(version, arch),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the latest Ubuntu codename from a changelogs.ubuntu.com meta-release file.
|
||||
/// Pass the LTS-only URL to get the latest LTS, or the full URL for the latest release.
|
||||
fn fetch_ubuntu_codename(meta_release_url: &str) -> Result<String> {
|
||||
let text = reqwest::blocking::get(meta_release_url)
|
||||
.with_context(|| format!("Failed to fetch {}", meta_release_url))?
|
||||
.text()
|
||||
.with_context(|| format!("Failed to read {}", meta_release_url))?;
|
||||
|
||||
let mut current_dist: Option<String> = None;
|
||||
let mut latest: Option<String> = None;
|
||||
|
||||
for line in text.lines() {
|
||||
if let Some(dist) = line.strip_prefix("Dist: ") {
|
||||
current_dist = Some(dist.trim().to_string());
|
||||
} else if line.trim_start().starts_with("Supported: 1") {
|
||||
if let Some(dist) = current_dist.take() {
|
||||
latest = Some(dist);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
latest.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Could not determine Ubuntu codename from {}",
|
||||
meta_release_url
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch the current Alpine minirootfs version from latest-releases.yaml on the CDN.
|
||||
/// The `latest-stable/` directory is a server-side symlink; the YAML file it contains
|
||||
/// tells us the exact version number needed for the tarball filename.
|
||||
/// Fetch the latest minirootfs version for a given Alpine CDN branch.
|
||||
/// `branch` is the directory name on the CDN, e.g. `"latest-stable"` or `"v3.23"`.
|
||||
fn fetch_alpine_version_from_branch(branch: &str, arch: &str) -> Result<String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AlpineRelease {
|
||||
file: Option<String>,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"https://dl-cdn.alpinelinux.org/alpine/{}/releases/{}/latest-releases.yaml",
|
||||
branch, arch
|
||||
);
|
||||
let text = reqwest::blocking::get(&url)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to fetch Alpine latest-releases.yaml for branch {}",
|
||||
branch
|
||||
)
|
||||
})?
|
||||
.text()
|
||||
.context("Failed to read Alpine latest-releases.yaml")?;
|
||||
|
||||
let releases: Vec<AlpineRelease> =
|
||||
serde_yaml::from_str(&text).context("Failed to parse Alpine latest-releases.yaml")?;
|
||||
|
||||
for release in releases {
|
||||
let is_minirootfs = release
|
||||
.file
|
||||
.as_deref()
|
||||
.map(|f| f.contains("minirootfs"))
|
||||
.unwrap_or(false);
|
||||
if is_minirootfs {
|
||||
// Prefer explicit `version:` field; fall back to parsing the filename.
|
||||
// Filename format: alpine-minirootfs-3.23.0-x86_64.tar.gz
|
||||
if let Some(v) = release.version {
|
||||
return Ok(v);
|
||||
}
|
||||
if let Some(v) = release.file.as_deref().and_then(|f| f.split('-').nth(2)) {
|
||||
return Ok(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Could not find minirootfs entry in Alpine latest-releases.yaml for branch {}",
|
||||
branch
|
||||
))
|
||||
}
|
||||
|
||||
fn fetch_alpine_latest_version(arch: &str) -> Result<String> {
|
||||
fetch_alpine_version_from_branch("latest-stable", arch)
|
||||
}
|
||||
|
||||
/// Resolve a `major.minor` Alpine series (e.g. `"3.24"`) to its current
|
||||
/// patch release by querying the CDN branch `v{minor}`.
|
||||
fn fetch_alpine_minor_version(minor: &str, arch: &str) -> Result<String> {
|
||||
fetch_alpine_version_from_branch(&format!("v{}", minor), arch)
|
||||
}
|
||||
|
||||
/// Resolve the canonical version string for a distro, performing a network lookup
|
||||
/// only when the requested version is a floating alias (e.g. "latest", "lts").
|
||||
/// The returned string is suitable for use as a stable cache key.
|
||||
pub fn resolve_distro_version(
|
||||
distro: &Distro,
|
||||
version: Option<&str>,
|
||||
arch: &str,
|
||||
) -> Result<String> {
|
||||
match distro {
|
||||
Distro::Ubuntu => resolve_ubuntu_version(version),
|
||||
Distro::Alpine => resolve_alpine_version(version, arch),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a map of YY.MM version strings to codenames by parsing the Ubuntu
|
||||
/// meta-release file (e.g. "24.04" → "noble", "22.04" → "jammy").
|
||||
/// Uses the full meta-release (not -lts) so non-LTS versions are also covered.
|
||||
fn fetch_ubuntu_version_map() -> Result<std::collections::HashMap<String, String>> {
|
||||
let text = reqwest::blocking::get("https://changelogs.ubuntu.com/meta-release")
|
||||
.context("Failed to fetch Ubuntu meta-release")?
|
||||
.text()
|
||||
.context("Failed to read Ubuntu meta-release")?;
|
||||
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let mut current_dist: Option<String> = None;
|
||||
|
||||
for line in text.lines() {
|
||||
if let Some(dist) = line.strip_prefix("Dist: ") {
|
||||
current_dist = Some(dist.trim().to_string());
|
||||
} else if let Some(version_str) = line.strip_prefix("Version: ") {
|
||||
// Version field may be "22.04", "22.04 LTS", or "24.04.1 LTS".
|
||||
// Normalise to YY.MM by taking the first two dot-separated components.
|
||||
let raw = version_str.split_whitespace().next().unwrap_or("");
|
||||
let normalised: String = {
|
||||
let mut parts = raw.splitn(3, '.');
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(a), Some(b)) => format!("{}.{}", a, b),
|
||||
_ => raw.to_string(),
|
||||
}
|
||||
};
|
||||
if let Some(dist) = ¤t_dist {
|
||||
map.insert(normalised, dist.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn resolve_ubuntu_version(version: Option<&str>) -> Result<String> {
|
||||
let version = version.unwrap_or("latest");
|
||||
match version {
|
||||
"latest" => fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release"),
|
||||
"lts" | "latest-lts" => {
|
||||
fetch_ubuntu_codename("https://changelogs.ubuntu.com/meta-release-lts")
|
||||
}
|
||||
other => {
|
||||
// If it looks like a YY.MM version number, resolve it to a codename
|
||||
// via the meta-release file so the mapping never goes stale.
|
||||
if is_ubuntu_version_number(other) {
|
||||
let map = fetch_ubuntu_version_map()?;
|
||||
map.get(other).cloned().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Unknown Ubuntu version '{}'. \
|
||||
Use the codename directly (e.g. noble, jammy) or check \
|
||||
https://changelogs.ubuntu.com/meta-release",
|
||||
other
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// Treat as a codename and pass through (e.g. "noble", "jammy")
|
||||
Ok(other.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true for strings of the form "YY.MM" (two numeric dot-separated components).
|
||||
fn is_ubuntu_version_number(s: &str) -> bool {
|
||||
let mut parts = s.splitn(3, '.');
|
||||
matches!(
|
||||
(parts.next(), parts.next(), parts.next()),
|
||||
(Some(a), Some(b), None)
|
||||
if !a.is_empty() && !b.is_empty()
|
||||
&& a.chars().all(|c| c.is_ascii_digit())
|
||||
&& b.chars().all(|c| c.is_ascii_digit())
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_alpine_version(version: Option<&str>, arch: &str) -> Result<String> {
|
||||
let alpine_arch = map_arch(Distro::Alpine, arch);
|
||||
Ok(match version.unwrap_or("latest") {
|
||||
"latest" | "stable" => fetch_alpine_latest_version(&alpine_arch)?,
|
||||
// edge is a rolling branch; query the CDN to get the current
|
||||
// date-stamped version (e.g. "20250401") so the URL and cache key
|
||||
// are correct. resolve_alpine_url maps this date string back to the
|
||||
// "edge" CDN directory via the all-digits guard in its release match.
|
||||
"edge" => fetch_alpine_version_from_branch("edge", &alpine_arch)?,
|
||||
v => {
|
||||
// "3.23" — one dot: major.minor series → fetch current patch from CDN
|
||||
// "3.23.0" — two dots: full version already → pass through as-is
|
||||
let dots = v.chars().filter(|&c| c == '.').count();
|
||||
if dots == 1 {
|
||||
fetch_alpine_minor_version(v, &alpine_arch)?
|
||||
} else {
|
||||
v.to_string()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_ubuntu_url(version: Option<&str>, arch: &str) -> Result<String> {
|
||||
let codename = resolve_ubuntu_version(version)?;
|
||||
let arch = map_arch(Distro::Ubuntu, arch);
|
||||
Ok(format!(
|
||||
"https://cdimage.ubuntu.com/ubuntu-base/{}/daily/current/{}-base-{}.tar.gz",
|
||||
codename, codename, arch
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_alpine_url(version: Option<&str>, arch: &str) -> Result<String> {
|
||||
let version_str = version.unwrap_or("latest");
|
||||
let alpine_arch = map_arch(Distro::Alpine, arch);
|
||||
let version_num = resolve_alpine_version(Some(version_str), arch)?;
|
||||
|
||||
// Derive the CDN release directory from the version string.
|
||||
// After resolve_distro_version runs, version_str may already be the
|
||||
// *resolved* value rather than the original alias:
|
||||
// "latest"/"stable" → e.g. "3.23.1" (dots present → v3.23 below)
|
||||
// "edge" → e.g. "20250401" (all digits, no dots)
|
||||
// "3.23" → e.g. "3.23.1" (dots present → v3.23 below)
|
||||
// The all-digit check catches resolved edge dates and maps them back to
|
||||
// the "edge" CDN directory.
|
||||
let release = match version_str {
|
||||
"latest" | "stable" => "latest-stable".to_string(),
|
||||
"edge" => "edge".to_string(),
|
||||
v if v.chars().all(|c| c.is_ascii_digit()) => "edge".to_string(),
|
||||
other => {
|
||||
// "3.23" or "3.23.1" → "v3.23"
|
||||
let mut parts = other.splitn(3, '.');
|
||||
let major = parts.next().unwrap_or("0");
|
||||
let minor = parts.next().unwrap_or("0");
|
||||
format!("v{}.{}", major, minor)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(format!(
|
||||
"https://dl-cdn.alpinelinux.org/alpine/{}/releases/{}/alpine-minirootfs-{}-{}.tar.gz",
|
||||
release, alpine_arch, version_num, alpine_arch
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn oci(registry: &str, repository: &str, tag: &str) -> ImageSource {
|
||||
ImageSource::OciImage {
|
||||
registry: registry.to_string(),
|
||||
repository: repository.to_string(),
|
||||
tag: tag.to_string(),
|
||||
architecture: "amd64".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(input: &str) -> ImageSource {
|
||||
parse_oci_ref(input, "amd64").expect("parse failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bare_name() {
|
||||
// "ubuntu" → Docker Hub library, tag=latest
|
||||
let src = parse("ubuntu");
|
||||
assert_eq!(src, oci("docker.io", "library/ubuntu", "latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_name_with_tag() {
|
||||
let src = parse("ubuntu:noble");
|
||||
assert_eq!(src, oci("docker.io", "library/ubuntu", "noble"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_org_repo() {
|
||||
let src = parse("myorg/myimage:v2");
|
||||
assert_eq!(src, oci("docker.io", "myorg/myimage", "v2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_with_port_no_tag() {
|
||||
// The colon in "localhost:5000" must NOT be treated as a tag separator
|
||||
let src = parse("localhost:5000/myimage");
|
||||
assert_eq!(src, oci("localhost:5000", "myimage", "latest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_with_port_and_tag() {
|
||||
let src = parse("localhost:5000/myimage:v1");
|
||||
assert_eq!(src, oci("localhost:5000", "myimage", "v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_with_port_and_org() {
|
||||
let src = parse("localhost:5000/org/myimage:v1");
|
||||
assert_eq!(src, oci("localhost:5000", "org/myimage", "v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_named_registry() {
|
||||
let src = parse("quay.io/centos/centos:stream9");
|
||||
assert_eq!(src, oci("quay.io", "centos/centos", "stream9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_hub_fqdn() {
|
||||
let src = parse("registry-1.docker.io/library/ubuntu:noble");
|
||||
assert_eq!(src, oci("registry-1.docker.io", "library/ubuntu", "noble"));
|
||||
}
|
||||
|
||||
/// After resolve_distro_version, alpine:edge carries a date string like
|
||||
/// "20250401". resolve_alpine_url must still produce a URL under the
|
||||
/// "edge" CDN directory, not a bogus "v20250401.0" directory.
|
||||
#[test]
|
||||
fn test_alpine_edge_resolved_date_uses_edge_directory() {
|
||||
// Simulate the already-resolved version that main.rs stores after calling
|
||||
// resolve_distro_version("edge", …).
|
||||
let url = resolve_alpine_url(Some("20250401"), "amd64").unwrap();
|
||||
assert!(
|
||||
url.contains("/alpine/edge/"),
|
||||
"expected URL to contain '/alpine/edge/' but got: {}",
|
||||
url
|
||||
);
|
||||
assert!(
|
||||
url.contains("minirootfs-20250401-"),
|
||||
"expected URL to contain 'minirootfs-20250401-' but got: {}",
|
||||
url
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
use crate::veprintln;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use futures_util::StreamExt;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::distro::ImageSource;
|
||||
|
||||
/// Path of the digest sidecar file for a cached OCI image.
|
||||
/// e.g. `foo.tar.gz` → `foo.tar.gz.digest`
|
||||
pub fn digest_sidecar(cache_path: &Path) -> PathBuf {
|
||||
let mut s = cache_path.as_os_str().to_owned();
|
||||
s.push(".digest");
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Fetch the current manifest digest for an OCI tag without downloading any
|
||||
/// layers. The registry returns the content-addressable digest in the
|
||||
/// `Docker-Content-Digest` response header of any manifest GET.
|
||||
pub fn fetch_oci_digest(registry: &str, repository: &str, tag: &str) -> Result<String> {
|
||||
tokio::runtime::Runtime::new()
|
||||
.context("Failed to create Tokio runtime")?
|
||||
.block_on(fetch_oci_digest_async(registry, repository, tag))
|
||||
}
|
||||
|
||||
async fn fetch_oci_digest_async(registry: &str, repository: &str, tag: &str) -> Result<String> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let token = get_auth_token(&client, registry, repository).await?;
|
||||
|
||||
let manifest_url = if registry == "docker.io" {
|
||||
format!(
|
||||
"https://registry-1.docker.io/v2/{}/manifests/{}",
|
||||
repository, tag
|
||||
)
|
||||
} else {
|
||||
format!("https://{}/v2/{}/manifests/{}", registry, repository, tag)
|
||||
};
|
||||
|
||||
let response = client
|
||||
.get(&manifest_url)
|
||||
.header(
|
||||
"Accept",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json, \
|
||||
application/vnd.docker.distribution.manifest.v2+json, \
|
||||
application/vnd.oci.image.index.v1+json, \
|
||||
application/vnd.oci.image.manifest.v1+json",
|
||||
)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch manifest for digest check")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Manifest fetch returned HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
response
|
||||
.headers()
|
||||
.get("Docker-Content-Digest")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow!("Registry did not return a Docker-Content-Digest header"))
|
||||
}
|
||||
|
||||
/// Download a container image (either direct tarball or OCI image)
|
||||
pub fn download_image(source: &ImageSource, dest: &Path, arch: &str) -> Result<()> {
|
||||
// Create a single Tokio runtime for all async I/O in this download.
|
||||
// Previously each of the two sync wrappers (download_file_sync and
|
||||
// download_oci_image) created their own runtime; this consolidates them.
|
||||
let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
|
||||
|
||||
match source {
|
||||
ImageSource::DirectTarball { distro, version } => {
|
||||
let url = crate::distro::resolve_distro_url(distro, version.as_deref(), arch)?;
|
||||
veprintln!("Resolved URL: {}", url);
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return Err(anyhow!("Unsupported URL scheme: {}", url));
|
||||
}
|
||||
rt.block_on(download_file_async(&url, dest))
|
||||
}
|
||||
ImageSource::OciImage {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
architecture,
|
||||
} => {
|
||||
veprintln!("Pulling OCI image: {}/{}:{}", registry, repository, tag);
|
||||
rt.block_on(download_oci_image_async(
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
architecture,
|
||||
dest,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_file_async(url: &str, dest: &Path) -> Result<()> {
|
||||
veprintln!("Downloading: {}", url);
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to start download")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Download failed: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
|
||||
// Create parent directories
|
||||
if let Some(parent) = dest.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("Failed to create cache directory")?;
|
||||
}
|
||||
|
||||
// Setup progress bar
|
||||
let pb = ProgressBar::new(total_size);
|
||||
pb.set_style(ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"));
|
||||
|
||||
let temp_dest = dest.with_extension("partial");
|
||||
|
||||
// Download into the temp file, then atomically rename to the final path.
|
||||
// On any failure after the temp file is created we remove it so stale
|
||||
// .partial files don't accumulate in the cache directory.
|
||||
let result: Result<()> = async {
|
||||
let mut file = tokio::fs::File::create(&temp_dest)
|
||||
.await
|
||||
.context("Failed to create temporary file")?;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.context("Download interrupted")?;
|
||||
tokio::io::copy(&mut &chunk[..], &mut file).await?;
|
||||
pb.inc(chunk.len() as u64);
|
||||
}
|
||||
|
||||
pb.finish_and_clear();
|
||||
|
||||
tokio::fs::rename(&temp_dest, dest)
|
||||
.await
|
||||
.context("Failed to move partial download to final destination")?;
|
||||
veprintln!("Download complete: {}", dest.display());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
// Best-effort cleanup; ignore errors (file may not exist if creation failed).
|
||||
let _ = tokio::fs::remove_file(&temp_dest).await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn download_oci_image_async(
|
||||
registry: &str,
|
||||
repository: &str,
|
||||
tag: &str,
|
||||
arch: &str,
|
||||
dest: &Path,
|
||||
) -> Result<()> {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let digest = try_download_oci_image(&client, registry, repository, tag, arch, dest).await?;
|
||||
|
||||
// Persist the manifest digest so the next run can skip the download when
|
||||
// the tag hasn't changed (see fetch_oci_digest / digest_sidecar).
|
||||
if !digest.is_empty() {
|
||||
let _ = std::fs::write(digest_sidecar(dest), &digest);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_download_oci_image(
|
||||
client: &Client,
|
||||
registry: &str,
|
||||
repository: &str,
|
||||
tag: &str,
|
||||
arch: &str,
|
||||
dest: &Path,
|
||||
) -> Result<String> {
|
||||
// Get authentication token for the registry
|
||||
let token = get_auth_token(client, registry, repository).await?;
|
||||
|
||||
// Construct manifest URL based on registry
|
||||
let manifest_url = if registry == "docker.io" {
|
||||
// Docker Hub uses registry-1.docker.io for API
|
||||
format!(
|
||||
"https://registry-1.docker.io/v2/{}/manifests/{}",
|
||||
repository, tag
|
||||
)
|
||||
} else {
|
||||
format!("https://{}/v2/{}/manifests/{}", registry, repository, tag)
|
||||
};
|
||||
|
||||
veprintln!("Fetching manifest from: {}", manifest_url);
|
||||
|
||||
// Request both manifest list and single manifest types (including OCI formats)
|
||||
let response = client
|
||||
.get(&manifest_url)
|
||||
.header("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json")
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get image manifest")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to get manifest: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
// Capture the tag-level digest before consuming the response body.
|
||||
// This is the content-addressable identity of the manifest (or manifest
|
||||
// list) at this tag — used to detect whether the tag has moved since the
|
||||
// last download.
|
||||
let tag_digest = response
|
||||
.headers()
|
||||
.get("Docker-Content-Digest")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let body = response.text().await?;
|
||||
let manifest: Value = serde_json::from_str(&body).context("Failed to parse manifest JSON")?;
|
||||
|
||||
// Check if this is a manifest list (multi-arch)
|
||||
let layers = if let Some(manifests) = manifest["manifests"].as_array() {
|
||||
// This is a manifest list - find the right architecture
|
||||
veprintln!("Got manifest list with {} manifests", manifests.len());
|
||||
|
||||
let manifest_entry = manifests
|
||||
.iter()
|
||||
.find(|m| {
|
||||
let m_arch = m["platform"]["architecture"].as_str().unwrap_or("");
|
||||
let m_os = m["platform"]["os"].as_str().unwrap_or("");
|
||||
m_arch == arch && m_os == "linux"
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
// List available architectures in the error message
|
||||
let available: Vec<&str> = manifests
|
||||
.iter()
|
||||
.filter_map(|m| m["platform"]["architecture"].as_str())
|
||||
.collect();
|
||||
anyhow!(
|
||||
"No manifest found for architecture '{}'. Available: {}",
|
||||
arch,
|
||||
available.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest_digest = manifest_entry["digest"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("No digest in manifest entry"))?;
|
||||
|
||||
veprintln!("Found manifest digest: {}", manifest_digest);
|
||||
|
||||
// Now get the actual manifest for this architecture
|
||||
let arch_manifest_url = if registry == "docker.io" {
|
||||
format!(
|
||||
"https://registry-1.docker.io/v2/{}/manifests/{}",
|
||||
repository, manifest_digest
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"https://{}/v2/{}/manifests/{}",
|
||||
registry, repository, manifest_digest
|
||||
)
|
||||
};
|
||||
|
||||
let arch_response = client
|
||||
.get(&arch_manifest_url)
|
||||
.header("Accept", "application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json")
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to get architecture manifest")?;
|
||||
|
||||
if !arch_response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to get arch manifest: HTTP {}",
|
||||
arch_response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let arch_body = arch_response.text().await?;
|
||||
let arch_manifest: Value = serde_json::from_str(&arch_body)
|
||||
.context("Failed to parse architecture manifest JSON")?;
|
||||
|
||||
// Get all layers from architecture manifest (in order)
|
||||
arch_manifest["layers"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("No layers in architecture manifest"))?
|
||||
.clone()
|
||||
} else {
|
||||
// Single architecture manifest - get all layers in order
|
||||
manifest["layers"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!("No layers in manifest"))?
|
||||
.clone()
|
||||
};
|
||||
|
||||
veprintln!("Found {} layers to download", layers.len());
|
||||
|
||||
// Download all layer blobs into a temp directory
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
|
||||
// layer_names[i] is the filename used both for the downloaded blob and in
|
||||
// layers.manifest. The extension is derived from the layer's mediaType so
|
||||
// extract_oci_layer can dispatch on the filename without relying on
|
||||
// magic-byte detection (which would misfire on a zstd blob named .tar.gz).
|
||||
let mut layer_names: Vec<String> = Vec::with_capacity(layers.len());
|
||||
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
let layer_digest = layer["digest"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("No digest in layer"))?;
|
||||
|
||||
let media_type = layer["mediaType"].as_str().unwrap_or("");
|
||||
let ext = media_type_to_extension(media_type);
|
||||
let layer_name = format!("layer_{}.{}", i, ext);
|
||||
|
||||
veprintln!(
|
||||
"Fetching layer {}/{}: {}",
|
||||
i + 1,
|
||||
layers.len(),
|
||||
layer_digest
|
||||
);
|
||||
|
||||
let blob_url = if registry == "docker.io" {
|
||||
format!(
|
||||
"https://registry-1.docker.io/v2/{}/blobs/{}",
|
||||
repository, layer_digest
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"https://{}/v2/{}/blobs/{}",
|
||||
registry, repository, layer_digest
|
||||
)
|
||||
};
|
||||
|
||||
let layer_path = temp_dir.path().join(&layer_name);
|
||||
let label = format!("Layer {}/{} ({})", i + 1, layers.len(), layer_name);
|
||||
download_blob_with_auth(client, &blob_url, &layer_path, &token, &label).await?;
|
||||
layer_names.push(layer_name);
|
||||
}
|
||||
|
||||
// Write the layer index so extract.rs knows the order
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut manifest_file = std::fs::File::create(temp_dir.path().join("layers.manifest"))?;
|
||||
for name in &layer_names {
|
||||
writeln!(manifest_file, "{}", name)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Bundle layers.manifest + all layer blobs into a single .tar.gz cache file
|
||||
// using the tar + flate2 crates — no external `tar` binary required.
|
||||
let temp_dest = dest.with_extension("tar.partial");
|
||||
|
||||
let bundle_result: Result<()> = (|| {
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
|
||||
let out_file =
|
||||
std::fs::File::create(&temp_dest).context("Failed to create temporary bundle file")?;
|
||||
let mut builder = tar::Builder::new(GzEncoder::new(out_file, Compression::default()));
|
||||
|
||||
builder
|
||||
.append_path_with_name(temp_dir.path().join("layers.manifest"), "layers.manifest")
|
||||
.context("Failed to add layers.manifest to bundle")?;
|
||||
|
||||
for name in &layer_names {
|
||||
builder
|
||||
.append_path_with_name(temp_dir.path().join(name), name)
|
||||
.with_context(|| format!("Failed to add {} to bundle", name))?;
|
||||
}
|
||||
|
||||
// into_inner finalises the tar end-of-archive marker and returns the
|
||||
// GzEncoder; finish() flushes and closes the gzip stream.
|
||||
builder
|
||||
.into_inner()
|
||||
.context("Failed to finalise tar archive")?
|
||||
.finish()
|
||||
.context("Failed to finalise gzip stream")?;
|
||||
|
||||
std::fs::rename(&temp_dest, dest).context("Failed to move bundle to cache destination")
|
||||
})();
|
||||
|
||||
if bundle_result.is_err() {
|
||||
// Best-effort cleanup; ignore errors (file may not exist if creation failed).
|
||||
let _ = std::fs::remove_file(&temp_dest);
|
||||
}
|
||||
bundle_result?;
|
||||
|
||||
Ok(tag_digest)
|
||||
}
|
||||
|
||||
async fn get_auth_token(client: &Client, registry: &str, repository: &str) -> Result<String> {
|
||||
// Docker Hub has a well-known, stable auth endpoint.
|
||||
if registry == "docker.io" {
|
||||
let url = format!(
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{}:pull",
|
||||
repository
|
||||
);
|
||||
veprintln!("Getting auth token from: {}", url);
|
||||
return fetch_bearer_token(client, &url).await;
|
||||
}
|
||||
|
||||
// For every other registry follow the OCI Distribution Spec §4.2:
|
||||
// 1. Probe GET /v2/ unauthenticated.
|
||||
// 2. Read the WWW-Authenticate challenge from the 401 response.
|
||||
// 3. Build the token URL from the advertised realm + service.
|
||||
let probe_url = format!("https://{}/v2/", registry);
|
||||
let probe = client
|
||||
.get(&probe_url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to probe registry at {}", probe_url))?;
|
||||
|
||||
match probe.status().as_u16() {
|
||||
200 => {
|
||||
// Registry requires no authentication (e.g. local insecure registry).
|
||||
return Ok(String::new());
|
||||
}
|
||||
401 => {}
|
||||
other => {
|
||||
// Unexpected status; proceed without a token and let the manifest
|
||||
// request fail with a more descriptive error.
|
||||
eprintln!(
|
||||
"Warning: registry probe returned HTTP {}; trying without auth",
|
||||
other
|
||||
);
|
||||
return Ok(String::new());
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the WWW-Authenticate challenge.
|
||||
let www_auth = probe
|
||||
.headers()
|
||||
.get("WWW-Authenticate")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Registry {} returned 401 but no WWW-Authenticate header",
|
||||
registry
|
||||
)
|
||||
})?;
|
||||
|
||||
let (realm, service) = parse_www_authenticate(www_auth)
|
||||
.ok_or_else(|| anyhow!("Could not parse WWW-Authenticate header: {}", www_auth))?;
|
||||
|
||||
// The probe scope is generic; override it with the per-repository pull scope.
|
||||
let token_url = format!(
|
||||
"{}?service={}&scope=repository:{}:pull",
|
||||
realm, service, repository
|
||||
);
|
||||
veprintln!("Getting auth token from: {}", token_url);
|
||||
fetch_bearer_token(client, &token_url).await
|
||||
}
|
||||
|
||||
/// Parse a `Bearer realm="...",service="..."[,scope="..."]` header value.
|
||||
/// Returns `(realm, service)` on success.
|
||||
fn parse_www_authenticate(header: &str) -> Option<(String, String)> {
|
||||
let params = header.strip_prefix("Bearer ")?;
|
||||
|
||||
let mut realm: Option<String> = None;
|
||||
let mut service: Option<String> = None;
|
||||
|
||||
// Split on commas that lie outside quoted strings.
|
||||
let mut start = 0;
|
||||
let mut in_quotes = false;
|
||||
let bytes = params.as_bytes();
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for i in 0..bytes.len() {
|
||||
match bytes[i] {
|
||||
b'"' => in_quotes = !in_quotes,
|
||||
b',' if !in_quotes => {
|
||||
parts.push(params[start..i].trim());
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts.push(params[start..].trim());
|
||||
|
||||
for part in parts {
|
||||
if let Some(val) = part.strip_prefix("realm=") {
|
||||
realm = Some(val.trim_matches('"').to_string());
|
||||
} else if let Some(val) = part.strip_prefix("service=") {
|
||||
service = Some(val.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Some((realm?, service?))
|
||||
}
|
||||
|
||||
/// Fetch a bearer token from a fully-constructed token URL.
|
||||
async fn fetch_bearer_token(client: &Client, token_url: &str) -> Result<String> {
|
||||
let response = client
|
||||
.get(token_url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to request auth token")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// Registry may allow anonymous pulls; proceed with an empty token.
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
let json: Value = serde_json::from_str(&body).context("Failed to parse auth token response")?;
|
||||
|
||||
// OCI spec uses "token"; Docker also emits "access_token".
|
||||
json["token"]
|
||||
.as_str()
|
||||
.or_else(|| json["access_token"].as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| anyhow!("No token field in auth response: {}", body))
|
||||
}
|
||||
|
||||
async fn download_blob_with_auth(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
token: &str,
|
||||
label: &str,
|
||||
) -> Result<()> {
|
||||
let mut request = client.get(url);
|
||||
if !token.is_empty() {
|
||||
request = request.header("Authorization", format!("Bearer {}", token));
|
||||
}
|
||||
|
||||
let response = request.send().await.context("Failed to start download")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to download blob: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
|
||||
let pb = ProgressBar::new(total_size);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
pb.set_message(label.to_string());
|
||||
|
||||
let mut file = tokio::fs::File::create(dest)
|
||||
.await
|
||||
.context("Failed to create destination file")?;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.context("Failed to read chunk")?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.context("Failed to write chunk")?;
|
||||
pb.inc(chunk.len() as u64);
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
pb.finish_and_clear();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map an OCI layer mediaType to the appropriate file extension.
|
||||
/// The extension is stored in layers.manifest and used by extract_oci_layer
|
||||
/// for compression dispatch, so it must accurately reflect the blob encoding.
|
||||
fn media_type_to_extension(media_type: &str) -> &'static str {
|
||||
match media_type {
|
||||
// Docker V2 schema 2
|
||||
"application/vnd.docker.image.rootfs.diff.tar.gzip" => "tar.gz",
|
||||
// OCI image spec
|
||||
"application/vnd.oci.image.layer.v1.tar+gzip" => "tar.gz",
|
||||
"application/vnd.oci.image.layer.v1.tar+zstd" => "tar.zst",
|
||||
"application/vnd.oci.image.layer.v1.tar" => "tar",
|
||||
// Non-distributable variants (same encoding, different semantics)
|
||||
"application/vnd.oci.image.layer.nondistributable.v1.tar+gzip" => "tar.gz",
|
||||
"application/vnd.oci.image.layer.nondistributable.v1.tar+zstd" => "tar.zst",
|
||||
"application/vnd.oci.image.layer.nondistributable.v1.tar" => "tar",
|
||||
// Unknown: fall back to gzip (historically the most common format)
|
||||
// and let magic-byte detection in extract_oci_layer handle it.
|
||||
_ => "tar.gz",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_www_authenticate;
|
||||
|
||||
#[test]
|
||||
fn test_docker_hub_challenge() {
|
||||
let hdr = r#"Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/ubuntu:pull""#;
|
||||
let (realm, service) = parse_www_authenticate(hdr).unwrap();
|
||||
assert_eq!(realm, "https://auth.docker.io/token");
|
||||
assert_eq!(service, "registry.docker.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quay_challenge() {
|
||||
let hdr = r#"Bearer realm="https://quay.io/v2/auth",service="quay.io",scope="repository:centos/centos:pull""#;
|
||||
let (realm, service) = parse_www_authenticate(hdr).unwrap();
|
||||
assert_eq!(realm, "https://quay.io/v2/auth");
|
||||
assert_eq!(service, "quay.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ghcr_challenge() {
|
||||
let hdr = r#"Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:owner/repo:pull""#;
|
||||
let (realm, service) = parse_www_authenticate(hdr).unwrap();
|
||||
assert_eq!(realm, "https://ghcr.io/token");
|
||||
assert_eq!(service, "ghcr.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gcr_challenge() {
|
||||
let hdr = r#"Bearer realm="https://gcr.io/v2/token",service="gcr.io""#;
|
||||
let (realm, service) = parse_www_authenticate(hdr).unwrap();
|
||||
assert_eq!(realm, "https://gcr.io/v2/token");
|
||||
assert_eq!(service, "gcr.io");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_value_with_comma_in_scope() {
|
||||
// scope field itself contains no comma but realm/service values may
|
||||
// contain other special chars — ensure the quoted-comma splitter works
|
||||
let hdr = r#"Bearer realm="https://example.com/auth",service="example.com",scope="repository:a/b:pull""#;
|
||||
let (realm, service) = parse_www_authenticate(hdr).unwrap();
|
||||
assert_eq!(realm, "https://example.com/auth");
|
||||
assert_eq!(service, "example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_not_bearer_returns_none() {
|
||||
assert!(parse_www_authenticate("Basic realm=\"registry\"").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_type_to_extension_known_types() {
|
||||
use super::media_type_to_extension;
|
||||
assert_eq!(
|
||||
media_type_to_extension("application/vnd.docker.image.rootfs.diff.tar.gzip"),
|
||||
"tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
media_type_to_extension("application/vnd.oci.image.layer.v1.tar+gzip"),
|
||||
"tar.gz"
|
||||
);
|
||||
assert_eq!(
|
||||
media_type_to_extension("application/vnd.oci.image.layer.v1.tar+zstd"),
|
||||
"tar.zst"
|
||||
);
|
||||
assert_eq!(
|
||||
media_type_to_extension("application/vnd.oci.image.layer.v1.tar"),
|
||||
"tar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_type_to_extension_unknown_falls_back_to_gz() {
|
||||
use super::media_type_to_extension;
|
||||
assert_eq!(media_type_to_extension(""), "tar.gz");
|
||||
assert_eq!(media_type_to_extension("text/plain"), "tar.gz");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
use crate::veprintln;
|
||||
use anyhow::{Context, Result};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::Path;
|
||||
|
||||
/// Extract a tarball to the specified directory
|
||||
pub fn extract_tarball(tarball: &Path, dest: &Path) -> Result<()> {
|
||||
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
|
||||
// OCI bundle files are always named "oci-…" by generate_cache_filename.
|
||||
// Use the filename prefix as a zero-I/O dispatch signal instead of
|
||||
// scanning the archive for a layers.manifest entry (which required
|
||||
// reading the entire compressed archive twice for large OCI images).
|
||||
if filename.starts_with("oci-") {
|
||||
veprintln!("Detected multi-layer OCI image, extracting layers...");
|
||||
return extract_multi_layer_oci(tarball, dest);
|
||||
}
|
||||
|
||||
let file = File::open(tarball)
|
||||
.with_context(|| format!("Failed to open tarball: {}", tarball.display()))?;
|
||||
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
// Detect compression format from filename
|
||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
extract_gz(reader, dest)?;
|
||||
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
||||
extract_xz(reader, dest)?;
|
||||
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") {
|
||||
extract_zst(reader, dest)?;
|
||||
} else if filename.ends_with(".tar") {
|
||||
extract_tar(reader, dest)?;
|
||||
} else {
|
||||
// Try to detect from magic bytes
|
||||
let mut magic = [0u8; 6];
|
||||
let mut peek_reader = BufReader::new(File::open(tarball)?);
|
||||
peek_reader.read_exact(&mut magic)?;
|
||||
|
||||
match magic {
|
||||
[0x1f, 0x8b, ..] => {
|
||||
// gzip magic
|
||||
drop(peek_reader);
|
||||
let file = File::open(tarball)?;
|
||||
extract_gz(BufReader::new(file), dest)?;
|
||||
}
|
||||
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => {
|
||||
// xz magic
|
||||
drop(peek_reader);
|
||||
let file = File::open(tarball)?;
|
||||
extract_xz(BufReader::new(file), dest)?;
|
||||
}
|
||||
[0x28, 0xb5, 0x2f, 0xfd, ..] => {
|
||||
// zstd magic
|
||||
drop(peek_reader);
|
||||
let file = File::open(tarball)?;
|
||||
extract_zst(BufReader::new(file), dest)?;
|
||||
}
|
||||
_ => {
|
||||
// Assume uncompressed tar
|
||||
drop(peek_reader);
|
||||
let file = File::open(tarball)?;
|
||||
extract_tar(BufReader::new(file), dest)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract a multi-layer OCI image
|
||||
fn extract_multi_layer_oci(tarball: &Path, dest: &Path) -> Result<()> {
|
||||
let filename = tarball.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
|
||||
// Extract the outer OCI bundle (layers.manifest + layer_N.tar.gz files)
|
||||
// into a temp directory. This is our own format, not an OCI layer, so
|
||||
// plain unpack is fine here.
|
||||
let temp_dir = tempfile::tempdir().context("Failed to create temp directory for OCI layers")?;
|
||||
|
||||
let file = File::open(tarball)
|
||||
.with_context(|| format!("Failed to open OCI tarball: {}", tarball.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
tar::Archive::new(flate2::read::GzDecoder::new(reader))
|
||||
.unpack(temp_dir.path())
|
||||
.context("Failed to unpack OCI bundle")?;
|
||||
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
||||
tar::Archive::new(xz2::read::XzDecoder::new(reader))
|
||||
.unpack(temp_dir.path())
|
||||
.context("Failed to unpack OCI bundle")?;
|
||||
} else if filename.ends_with(".tar.zst") || filename.ends_with(".tar.zstd") {
|
||||
tar::Archive::new(zstd::stream::read::Decoder::new(reader)?)
|
||||
.unpack(temp_dir.path())
|
||||
.context("Failed to unpack OCI bundle")?;
|
||||
} else {
|
||||
tar::Archive::new(reader)
|
||||
.unpack(temp_dir.path())
|
||||
.context("Failed to unpack OCI bundle")?;
|
||||
}
|
||||
|
||||
// Read the layers manifest
|
||||
let manifest = std::fs::read_to_string(temp_dir.path().join("layers.manifest"))
|
||||
.context("Failed to read layers.manifest")?;
|
||||
|
||||
// Apply each layer in order with full whiteout handling.
|
||||
for layer_name in manifest.lines() {
|
||||
let layer_path = temp_dir.path().join(layer_name);
|
||||
if !layer_path.exists() {
|
||||
continue;
|
||||
}
|
||||
veprintln!("Extracting layer: {}", layer_name);
|
||||
extract_oci_layer(&layer_path, dest)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decompress and extract one OCI layer tarball into `dest`, honouring
|
||||
/// whiteout markers. Compression is inferred from the filename then from
|
||||
/// magic bytes.
|
||||
fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
|
||||
use std::io::Read;
|
||||
let layer_name = layer_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let file = File::open(layer_path)
|
||||
.with_context(|| format!("Failed to open layer: {}", layer_path.display()))?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") {
|
||||
extract_with_progress(
|
||||
tar::Archive::new(flate2::read::GzDecoder::new(reader)),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
)
|
||||
} else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") {
|
||||
extract_with_progress(
|
||||
tar::Archive::new(xz2::read::XzDecoder::new(reader)),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
)
|
||||
} else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") {
|
||||
extract_with_progress(
|
||||
tar::Archive::new(zstd::stream::read::Decoder::new(reader)?),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
)
|
||||
} else {
|
||||
// Fall back to magic-byte detection
|
||||
let mut magic = [0u8; 6];
|
||||
let mut peek = BufReader::new(File::open(layer_path)?);
|
||||
let _ = peek.read_exact(&mut magic); // short reads are fine for detection
|
||||
drop(peek);
|
||||
match magic {
|
||||
[0x1f, 0x8b, ..] => extract_with_progress(
|
||||
tar::Archive::new(flate2::read::GzDecoder::new(BufReader::new(File::open(
|
||||
layer_path,
|
||||
)?))),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
),
|
||||
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_with_progress(
|
||||
tar::Archive::new(xz2::read::XzDecoder::new(BufReader::new(File::open(
|
||||
layer_path,
|
||||
)?))),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
),
|
||||
[0x28, 0xb5, 0x2f, 0xfd, ..] => extract_with_progress(
|
||||
tar::Archive::new(zstd::stream::read::Decoder::new(BufReader::new(
|
||||
File::open(layer_path)?,
|
||||
))?),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
),
|
||||
_ => extract_with_progress(
|
||||
tar::Archive::new(BufReader::new(File::open(layer_path)?)),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_gz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
let gz_decoder = flate2::read::GzDecoder::new(reader);
|
||||
let archive = tar::Archive::new(gz_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting gzip archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_xz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
let xz_decoder = xz2::read::XzDecoder::new(reader);
|
||||
let archive = tar::Archive::new(xz_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting xz archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_zst<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
let zst_decoder = zstd::Decoder::new(reader)?;
|
||||
let archive = tar::Archive::new(zst_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting zstd archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
let archive = tar::Archive::new(reader);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting tar archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract tar archive with progress bar, handling whiteout files for OCI layers
|
||||
fn extract_with_progress<R: std::io::Read>(
|
||||
mut archive: tar::Archive<R>,
|
||||
dest: &Path,
|
||||
msg: &str,
|
||||
) -> Result<()> {
|
||||
let pb = ProgressBar::new_spinner();
|
||||
pb.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.green} {msg} ({pos} files)")
|
||||
.unwrap(),
|
||||
);
|
||||
pb.set_message(msg.to_string());
|
||||
|
||||
archive.set_preserve_permissions(true);
|
||||
archive.set_preserve_ownerships(false);
|
||||
archive.set_unpack_xattrs(false);
|
||||
|
||||
let entries = archive
|
||||
.entries()
|
||||
.context("Failed to read archive entries")?;
|
||||
|
||||
for entry in entries {
|
||||
let mut entry = entry.context("Failed to read archive entry")?;
|
||||
|
||||
// Clone the path before any mutable borrow of entry
|
||||
let path = entry.path().context("Invalid tar entry path")?.into_owned();
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Handle whiteout files (OCI layer markers for deletions)
|
||||
if filename == ".wh..wh..opq" {
|
||||
// Opaque whiteout: clear all previously-extracted content in the parent directory
|
||||
let parent = path.parent().unwrap_or(Path::new(""));
|
||||
let dest_dir = dest.join(parent);
|
||||
if dest_dir.symlink_metadata().is_ok() {
|
||||
for child in std::fs::read_dir(&dest_dir)
|
||||
.with_context(|| format!("Failed to read {}", dest_dir.display()))?
|
||||
{
|
||||
let child = child?;
|
||||
let child_path = child.path();
|
||||
remove_path(&child_path).with_context(|| {
|
||||
format!("Opaque whiteout: failed to remove {}", child_path.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
// Do not extract the .wh..wh..opq marker itself
|
||||
} else if let Some(real_name) = filename.strip_prefix(".wh.") {
|
||||
// Regular whiteout: delete the named path from lower layers
|
||||
let parent = path.parent().unwrap_or(Path::new(""));
|
||||
let target = dest.join(parent).join(real_name);
|
||||
if target.symlink_metadata().is_ok() {
|
||||
remove_path(&target)
|
||||
.with_context(|| format!("Whiteout: failed to remove {}", target.display()))?;
|
||||
}
|
||||
// Do not extract the .wh.* marker itself
|
||||
} else {
|
||||
entry
|
||||
.unpack_in(dest)
|
||||
.with_context(|| format!("Failed to extract {}", path.display()))?;
|
||||
}
|
||||
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
pb.finish_and_clear();
|
||||
veprintln!("Extracted {} files", pb.position());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a path: uses remove_dir_all for real directories, remove_file for
|
||||
/// everything else (regular files, symlinks — including symlinks-to-dirs).
|
||||
fn remove_path(path: &Path) -> std::io::Result<()> {
|
||||
// symlink_metadata does not follow symlinks, so a symlink-to-dir correctly
|
||||
// reports file_type().is_symlink() rather than is_dir().
|
||||
let meta = std::fs::symlink_metadata(path)?;
|
||||
if meta.is_dir() {
|
||||
std::fs::remove_dir_all(path)
|
||||
} else {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Default kernel download for QEMU VM mode.
|
||||
//!
|
||||
//! When `--kernel` is specified without a path, we download a default kernel
|
||||
//! suitable for VM booting. We use Alpine's `linux-virt` package because:
|
||||
//!
|
||||
//! - Small size (~10-15MB compressed)
|
||||
//! - VM-optimized configuration
|
||||
//! - Multi-architecture support
|
||||
//! - Simple direct download URLs
|
||||
//!
|
||||
//! The kernel is cached in the same cache directory as rootfs images.
|
||||
|
||||
use crate::veprintln;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Alpine architecture mapping for kernel packages
|
||||
fn alpine_kernel_arch(arch: &str) -> &'static str {
|
||||
match arch {
|
||||
"amd64" | "x86_64" => "x86_64",
|
||||
"arm64" | "aarch64" => "aarch64",
|
||||
"armhf" | "armv7l" | "arm" => "armv7",
|
||||
"riscv64" => "riscv64",
|
||||
"ppc64le" => "ppc64le",
|
||||
"s390x" => "s390x",
|
||||
"x86" | "i386" | "i686" => "x86",
|
||||
_ => "x86_64",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the Alpine version branch for kernel downloads
|
||||
/// We use the latest stable branch
|
||||
fn get_alpine_branch() -> Result<String> {
|
||||
// Fetch the latest-stable branch from Alpine CDN
|
||||
// The URL redirects to the current stable version
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let response = client
|
||||
.head("https://dl-cdn.alpinelinux.org/alpine/latest-stable/main/")
|
||||
.send()
|
||||
.context("Failed to check Alpine latest-stable")?;
|
||||
|
||||
// The final URL after redirect contains the version, e.g.:
|
||||
// https://dl-cdn.alpinelinux.org/alpine/v3.23/main/
|
||||
if let Some(final_url) = response
|
||||
.url()
|
||||
.as_str()
|
||||
.strip_prefix("https://dl-cdn.alpinelinux.org/alpine/")
|
||||
{
|
||||
if let Some(branch) = final_url.split('/').next() {
|
||||
return Ok(branch.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: parse from the releases YAML
|
||||
fetch_alpine_branch_from_yaml()
|
||||
}
|
||||
|
||||
fn fetch_alpine_branch_from_yaml() -> Result<String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AlpineRelease {
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
let url =
|
||||
"https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/x86_64/latest-releases.yaml";
|
||||
let text = reqwest::blocking::get(url)
|
||||
.context("Failed to fetch Alpine latest-releases.yaml")?
|
||||
.text()
|
||||
.context("Failed to read Alpine latest-releases.yaml")?;
|
||||
|
||||
let releases: Vec<AlpineRelease> =
|
||||
serde_yaml::from_str(&text).context("Failed to parse Alpine latest-releases.yaml")?;
|
||||
|
||||
if let Some(release) = releases.first() {
|
||||
if let Some(version) = &release.version {
|
||||
// version is like "3.23.0", we want "v3.23"
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
if parts.len() >= 2 {
|
||||
return Ok(format!("v{}.{}", parts[0], parts[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Could not determine Alpine version from releases"))
|
||||
}
|
||||
|
||||
/// Fetch the latest linux-virt package version from Alpine's package index
|
||||
fn get_linux_virt_version(branch: &str, arch: &str) -> Result<String> {
|
||||
// Alpine package index URL
|
||||
let url = format!(
|
||||
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/APKINDEX.tar.gz",
|
||||
branch, arch
|
||||
);
|
||||
|
||||
veprintln!("Fetching package index: {}", url);
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.context("Failed to fetch Alpine APKINDEX")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to fetch APKINDEX: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response.bytes().context("Failed to read APKINDEX")?;
|
||||
|
||||
// Extract APKINDEX from the tar.gz
|
||||
// We need to own the bytes to avoid lifetime issues
|
||||
let bytes_owned = bytes.to_vec();
|
||||
veprintln!(" Downloaded {} bytes", bytes_owned.len());
|
||||
|
||||
// First decompress gzip to memory, then parse tar
|
||||
// Note: Alpine's APKINDEX.tar.gz uses concatenated gzip members (multi-member gzip)
|
||||
// flate2::read::GzDecoder only reads the first member, so we use MultiGzDecoder
|
||||
let cursor = std::io::Cursor::new(&bytes_owned);
|
||||
let mut gz_decoder = flate2::read::MultiGzDecoder::new(cursor);
|
||||
let mut decompressed = Vec::new();
|
||||
gz_decoder
|
||||
.read_to_end(&mut decompressed)
|
||||
.context("Failed to decompress gzip")?;
|
||||
|
||||
veprintln!(" Decompressed {} bytes", decompressed.len());
|
||||
|
||||
let tar_cursor = std::io::Cursor::new(decompressed);
|
||||
let mut archive = tar::Archive::new(tar_cursor);
|
||||
|
||||
// Iterate through entries directly
|
||||
let entries_iter = archive
|
||||
.entries()
|
||||
.context("Failed to read APKINDEX tar entries")?;
|
||||
let mut entry_count = 0;
|
||||
|
||||
for entry_result in entries_iter {
|
||||
entry_count += 1;
|
||||
let mut entry = match entry_result {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
veprintln!(
|
||||
" Warning: failed to read tar entry #{}: {}",
|
||||
entry_count,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let path = entry.path().context("Failed to get entry path")?;
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
veprintln!(" Entry #{}: {}", entry_count, path_str);
|
||||
|
||||
if path_str == "APKINDEX" {
|
||||
let mut contents = String::new();
|
||||
entry
|
||||
.read_to_string(&mut contents)
|
||||
.context("Failed to read APKINDEX contents")?;
|
||||
|
||||
veprintln!(" APKINDEX size: {} bytes", contents.len());
|
||||
|
||||
// Parse the APKINDEX to find linux-virt
|
||||
// Format:
|
||||
// P:linux-virt
|
||||
// V:6.12.8-r0
|
||||
// ...
|
||||
let mut pkg_name: Option<String> = None;
|
||||
|
||||
for line in contents.lines() {
|
||||
if let Some(name) = line.strip_prefix("P:") {
|
||||
pkg_name = Some(name.trim().to_string());
|
||||
} else if let Some(version) = line.strip_prefix("V:") {
|
||||
if pkg_name.as_deref() == Some("linux-virt") {
|
||||
return Ok(version.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we got here, we found APKINDEX but not linux-virt
|
||||
return Err(anyhow!("linux-virt package not found in APKINDEX. Available packages may vary by architecture."));
|
||||
}
|
||||
}
|
||||
|
||||
veprintln!(" Total entries processed: {}", entry_count);
|
||||
|
||||
Err(anyhow!(
|
||||
"APKINDEX file not found in tar.gz archive (processed {} entries)",
|
||||
entry_count
|
||||
))
|
||||
}
|
||||
|
||||
/// Download and extract the linux-virt kernel from Alpine's package repository
|
||||
fn download_alpine_kernel(branch: &str, arch: &str, dest: &Path) -> Result<()> {
|
||||
let version = get_linux_virt_version(branch, arch)?;
|
||||
veprintln!("Found linux-virt version: {}", version);
|
||||
|
||||
// Construct the download URL for the linux-virt .apk
|
||||
// Format: https://dl-cdn.alpinelinux.org/alpine/v3.23/main/x86_64/linux-virt-6.12.8-r0.apk
|
||||
let url = format!(
|
||||
"https://dl-cdn.alpinelinux.org/alpine/{}/main/{}/linux-virt-{}.apk",
|
||||
branch, arch, version
|
||||
);
|
||||
|
||||
veprintln!("Downloading kernel: {}", url);
|
||||
|
||||
// Use async download via the existing download module pattern
|
||||
let rt = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
|
||||
rt.block_on(download_kernel_async(&url, dest))?;
|
||||
|
||||
// Extract vmlinuz-virt from the APK
|
||||
// APK files are gzip-compressed tar archives
|
||||
let temp_apk = dest.with_extension("apk");
|
||||
// Remove the ~45MB APK whatever the extraction outcome — don't leave
|
||||
// it behind in the cache directory on failure.
|
||||
let result = extract_kernel_from_apk(&temp_apk, dest);
|
||||
std::fs::remove_file(&temp_apk).ok();
|
||||
result
|
||||
}
|
||||
|
||||
async fn download_kernel_async(url: &str, dest: &Path) -> Result<()> {
|
||||
use futures_util::StreamExt;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to start kernel download")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Kernel download failed: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
|
||||
// Setup progress bar
|
||||
let pb = ProgressBar::new(total_size);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
let temp_apk = dest.with_extension("apk.partial");
|
||||
let mut file = tokio::fs::File::create(&temp_apk)
|
||||
.await
|
||||
.context("Failed to create temp APK file")?;
|
||||
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.context("Failed to read chunk")?;
|
||||
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
|
||||
.await
|
||||
.context("Failed to write chunk")?;
|
||||
downloaded += chunk.len() as u64;
|
||||
pb.set_position(downloaded);
|
||||
}
|
||||
|
||||
tokio::io::AsyncWriteExt::flush(&mut file)
|
||||
.await
|
||||
.context("Failed to flush file")?;
|
||||
|
||||
pb.finish_with_message("Download complete");
|
||||
|
||||
// Rename to final name
|
||||
let final_apk = dest.with_extension("apk");
|
||||
tokio::fs::rename(&temp_apk, &final_apk)
|
||||
.await
|
||||
.context("Failed to rename temp file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract vmlinuz-virt from an Alpine APK file
|
||||
fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> {
|
||||
veprintln!("Extracting kernel from APK...");
|
||||
|
||||
let file = std::fs::File::open(apk_path).context("Failed to open APK file")?;
|
||||
// Use MultiGzDecoder because Alpine APKs have concatenated gzip members
|
||||
let gz_decoder = flate2::read::MultiGzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(gz_decoder);
|
||||
|
||||
for entry in archive.entries().context("Failed to read APK entries")? {
|
||||
let mut entry = entry.context("Failed to read tar entry")?;
|
||||
let path = entry.path().context("Failed to get entry path")?;
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
veprintln!(" APK entry: {}", path_str);
|
||||
|
||||
// Look for the kernel file: boot/vmlinuz-virt
|
||||
if path_str == "boot/vmlinuz-virt" || path_str == "./boot/vmlinuz-virt" {
|
||||
// Extract to destination
|
||||
entry.unpack(dest).context("Failed to extract kernel")?;
|
||||
veprintln!(" Extracted kernel to: {}", dest.display());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("vmlinuz-virt not found in APK package"))
|
||||
}
|
||||
|
||||
/// Get the path to the cached default kernel for the given architecture.
|
||||
/// Downloads and caches it if not present.
|
||||
pub fn get_default_kernel(cache_dir: &Path, arch: &str) -> Result<PathBuf> {
|
||||
let alpine_arch = alpine_kernel_arch(arch);
|
||||
|
||||
// Cache filename includes architecture
|
||||
let kernel_filename = format!("ecr-default-kernel-{}.vmlinuz", alpine_arch);
|
||||
let kernel_path = cache_dir.join(&kernel_filename);
|
||||
|
||||
// Check if already cached
|
||||
if kernel_path.exists() {
|
||||
veprintln!("Using cached default kernel: {}", kernel_path.display());
|
||||
return Ok(kernel_path);
|
||||
}
|
||||
|
||||
// Create cache directory if needed
|
||||
std::fs::create_dir_all(cache_dir).context("Failed to create cache directory")?;
|
||||
|
||||
// Determine Alpine branch
|
||||
let branch = get_alpine_branch()?;
|
||||
veprintln!("Using Alpine branch: {}", branch);
|
||||
|
||||
// Download and extract the kernel
|
||||
download_alpine_kernel(&branch, alpine_arch, &kernel_path)?;
|
||||
|
||||
Ok(kernel_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_alpine_kernel_arch() {
|
||||
assert_eq!(alpine_kernel_arch("amd64"), "x86_64");
|
||||
assert_eq!(alpine_kernel_arch("x86_64"), "x86_64");
|
||||
assert_eq!(alpine_kernel_arch("arm64"), "aarch64");
|
||||
assert_eq!(alpine_kernel_arch("aarch64"), "aarch64");
|
||||
assert_eq!(alpine_kernel_arch("armhf"), "armv7");
|
||||
assert_eq!(alpine_kernel_arch("riscv64"), "riscv64");
|
||||
assert_eq!(alpine_kernel_arch("ppc64le"), "ppc64le");
|
||||
assert_eq!(alpine_kernel_arch("s390x"), "s390x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! ecr — ephemeral chroot environments with Linux namespaces.
|
||||
//!
|
||||
//! This crate is the library behind the `ecr` CLI. It resolves distro and
|
||||
//! OCI image references, downloads them through a content cache, extracts
|
||||
//! 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).
|
||||
|
||||
pub mod chroot;
|
||||
pub mod config;
|
||||
pub mod distro;
|
||||
pub mod download;
|
||||
pub mod extract;
|
||||
pub mod kernel;
|
||||
pub mod mount;
|
||||
pub mod namespace;
|
||||
pub mod qemu;
|
||||
pub mod qemu_vm;
|
||||
pub mod utils;
|
||||
pub mod verbose;
|
||||
|
||||
/// Print to stderr only when verbose mode is active (see [`verbose::set`]).
|
||||
#[macro_export]
|
||||
macro_rules! veprintln {
|
||||
($($arg:tt)*) => {
|
||||
if $crate::verbose::is_verbose() {
|
||||
eprintln!($($arg)*);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use nix::mount::{mount, MsFlags};
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Escape a path for use as an overlayfs mount option value.
|
||||
///
|
||||
/// The overlayfs kernel driver uses `,` as its option delimiter and `\` as
|
||||
/// the escape character (Linux ≥ 5.1, commit 6b2d09a). A bare comma in a
|
||||
/// path would silently split the option string at the wrong boundary and
|
||||
/// produce a cryptic kernel error; a bare backslash would be mis-interpreted
|
||||
/// as starting an escape sequence.
|
||||
fn escape_overlay_path(path: &Path) -> Result<String> {
|
||||
let s = path.to_str().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Overlay path '{}' contains non-UTF-8 characters",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
// Backslashes must be escaped before commas to avoid double-escaping.
|
||||
Ok(s.replace('\\', "\\\\").replace(',', "\\,"))
|
||||
}
|
||||
|
||||
/// 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<Vec<TempDir>> {
|
||||
// Keep all overlay temp dirs alive
|
||||
let mut overlay_temps: Vec<TempDir> = Vec::new();
|
||||
|
||||
// Make all mounts private to avoid propagation to host
|
||||
if let Err(e) = mount(
|
||||
None::<&str>,
|
||||
"/",
|
||||
None::<&str>,
|
||||
MsFlags::MS_PRIVATE | MsFlags::MS_REC,
|
||||
None::<&str>,
|
||||
) {
|
||||
eprintln!("Warning: Failed to make mounts private: {}", e);
|
||||
}
|
||||
|
||||
// Mount /proc
|
||||
mount_proc(rootfs)?;
|
||||
|
||||
// Setup /dev by bind mounting from host
|
||||
mount_dev(rootfs)?;
|
||||
|
||||
// Mount /dev/pts
|
||||
mount_devpts(rootfs)?;
|
||||
|
||||
// Try to mount /sys (may fail in some environments)
|
||||
if let Err(e) = mount_sys(rootfs) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
fn mount_proc(rootfs: &Path) -> Result<()> {
|
||||
let proc_path = rootfs.join("proc");
|
||||
std::fs::create_dir_all(&proc_path)?;
|
||||
|
||||
let flags = MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV;
|
||||
|
||||
mount(Some("proc"), &proc_path, Some("proc"), flags, None::<&str>)
|
||||
.with_context(|| format!("Failed to mount proc at {}", proc_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount_sys(rootfs: &Path) -> Result<()> {
|
||||
let sys_path = rootfs.join("sys");
|
||||
std::fs::create_dir_all(&sys_path)?;
|
||||
|
||||
// Bind mount /sys from host as read-only
|
||||
mount(
|
||||
Some("/sys"),
|
||||
&sys_path,
|
||||
None::<&str>,
|
||||
MsFlags::MS_BIND | MsFlags::MS_REC,
|
||||
None::<&str>,
|
||||
)
|
||||
.with_context(|| format!("Failed to bind mount sys at {}", sys_path.display()))?;
|
||||
|
||||
// Remount as read-only with full security flags.
|
||||
// MS_BIND | MS_REMOUNT does NOT inherit the original mount's flags; every
|
||||
// desired flag must be listed explicitly. /proc uses the same set.
|
||||
mount(
|
||||
Some(&sys_path),
|
||||
&sys_path,
|
||||
None::<&str>,
|
||||
MsFlags::MS_BIND
|
||||
| MsFlags::MS_REMOUNT
|
||||
| MsFlags::MS_RDONLY
|
||||
| MsFlags::MS_NOSUID
|
||||
| MsFlags::MS_NODEV
|
||||
| MsFlags::MS_NOEXEC,
|
||||
None::<&str>,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to remount sys as read-only at {}",
|
||||
sys_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount_dev(rootfs: &Path) -> Result<()> {
|
||||
let dev_path = rootfs.join("dev");
|
||||
std::fs::create_dir_all(&dev_path)?;
|
||||
|
||||
// Bind mount /dev from host
|
||||
mount(
|
||||
Some("/dev"),
|
||||
&dev_path,
|
||||
None::<&str>,
|
||||
MsFlags::MS_BIND | MsFlags::MS_REC,
|
||||
None::<&str>,
|
||||
)
|
||||
.with_context(|| format!("Failed to bind mount dev at {}", dev_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount_devpts(rootfs: &Path) -> Result<()> {
|
||||
let devpts_path = rootfs.join("dev/pts");
|
||||
std::fs::create_dir_all(&devpts_path)?;
|
||||
|
||||
let flags = MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC;
|
||||
|
||||
mount(
|
||||
Some("devpts"),
|
||||
&devpts_path,
|
||||
Some("devpts"),
|
||||
flags,
|
||||
None::<&str>,
|
||||
)
|
||||
.with_context(|| format!("Failed to mount devpts at {}", devpts_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setup overlay mount for workspace directory
|
||||
/// Returns a TempDir that must be kept alive for the overlay to work
|
||||
fn setup_overlay(rootfs: &Path, source: &Path) -> Result<TempDir> {
|
||||
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)?;
|
||||
|
||||
// Create temp directories for overlay
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let upper_dir = temp_dir.path().join("upper");
|
||||
let work_dir = temp_dir.path().join("work");
|
||||
std::fs::create_dir_all(&upper_dir)?;
|
||||
std::fs::create_dir_all(&work_dir)?;
|
||||
|
||||
// Create overlay mount options
|
||||
let lowerdir = source.canonicalize()?;
|
||||
let upperdir = upper_dir.canonicalize()?;
|
||||
let workdir = work_dir.canonicalize()?;
|
||||
|
||||
let options = format!(
|
||||
"lowerdir={},upperdir={},workdir={}",
|
||||
escape_overlay_path(&lowerdir)?,
|
||||
escape_overlay_path(&upperdir)?,
|
||||
escape_overlay_path(&workdir)?,
|
||||
);
|
||||
|
||||
mount(
|
||||
Some("overlay"),
|
||||
&mount_point,
|
||||
Some("overlay"),
|
||||
MsFlags::empty(),
|
||||
Some(options.as_str()),
|
||||
)
|
||||
.with_context(|| format!("Failed to mount overlay at {}", mount_point.display()))?;
|
||||
|
||||
// Return temp_dir so caller can keep it alive
|
||||
Ok(temp_dir)
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
let source = source.canonicalize()?;
|
||||
|
||||
mount(
|
||||
Some(&source),
|
||||
&mount_point,
|
||||
None::<&str>,
|
||||
MsFlags::MS_BIND | MsFlags::MS_REC,
|
||||
None::<&str>,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to bind mount {} at {}",
|
||||
source.display(),
|
||||
mount_point.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::escape_overlay_path;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn plain_path_unchanged() {
|
||||
assert_eq!(
|
||||
escape_overlay_path(Path::new("/home/user/project")).unwrap(),
|
||||
"/home/user/project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comma_in_path_escaped() {
|
||||
assert_eq!(
|
||||
escape_overlay_path(Path::new("/home/user/my,project")).unwrap(),
|
||||
"/home/user/my\\,project"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_escaped_before_comma() {
|
||||
// Backslash must be doubled first so a path like "a\,b" becomes
|
||||
// "a\\\,b" and not "a\,b" (which would look like an escaped comma).
|
||||
assert_eq!(
|
||||
escape_overlay_path(Path::new("/a\\,b")).unwrap(),
|
||||
"/a\\\\\\,b"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_commas_all_escaped() {
|
||||
assert_eq!(
|
||||
escape_overlay_path(Path::new("/a,b,c")).unwrap(),
|
||||
"/a\\,b\\,c"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use nix::sched::{clone, CloneFlags};
|
||||
use nix::sys::signal::Signal;
|
||||
use nix::unistd::{getgid, getuid, Pid};
|
||||
|
||||
/// RAII wrapper that closes a raw file descriptor on drop.
|
||||
/// Guarantees all pipe fds are closed on every return path, including
|
||||
/// clone() and setup_user_namespace() failures.
|
||||
struct AutoCloseFd(i32);
|
||||
|
||||
impl AutoCloseFd {
|
||||
fn raw(&self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AutoCloseFd {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
libc::close(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone flags for namespace creation
|
||||
const CLONE_FLAGS: CloneFlags = CloneFlags::CLONE_NEWUSER
|
||||
.union(CloneFlags::CLONE_NEWPID)
|
||||
.union(CloneFlags::CLONE_NEWNS)
|
||||
.union(CloneFlags::CLONE_NEWUTS);
|
||||
|
||||
/// Check if user namespaces are available
|
||||
pub fn check_user_namespace() -> Result<()> {
|
||||
// Check kernel.unprivileged_userns_clone on systems that have it
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone") {
|
||||
if content.trim() == "0" {
|
||||
return Err(anyhow!(
|
||||
"User namespaces not available\n\n\
|
||||
Enable with:\n\
|
||||
sysctl -w kernel.unprivileged_userns_clone=1\n\n\
|
||||
Or check AppArmor profile restrictions."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check max_user_namespaces
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/sys/user/max_user_namespaces") {
|
||||
if let Ok(max) = content.trim().parse::<u32>() {
|
||||
if max == 0 {
|
||||
return Err(anyhow!(
|
||||
"User namespaces not available\n\n\
|
||||
Enable with:\n\
|
||||
sysctl -w user.max_user_namespaces=10000"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setup namespaces and run the provided function inside them.
|
||||
/// Returns the child's exit code (0 on success, 128+signal when killed by a
|
||||
/// signal). Setup failures are returned as Err.
|
||||
pub fn setup_namespaces<F>(f: F) -> Result<i32>
|
||||
where
|
||||
F: FnOnce() -> Result<()> + Send + 'static,
|
||||
{
|
||||
// sync pipe: parent signals child to proceed after UID/GID mapping
|
||||
// done pipe: child signals parent it has finished (or exec'd the shell)
|
||||
// error pipe: child writes the anyhow error chain to the parent on failure.
|
||||
// The write end is O_CLOEXEC so it is automatically closed when execvp
|
||||
// succeeds — the parent then reads EOF and knows there was no error.
|
||||
//
|
||||
// All six fds are wrapped in AutoCloseFd so they are closed on every return
|
||||
// path, including clone() and setup_user_namespace() failures.
|
||||
let (parent_read, parent_write, child_read, child_write, error_read, error_write);
|
||||
|
||||
unsafe {
|
||||
let mut fds: [i32; 2] = [-1, -1];
|
||||
|
||||
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
||||
return Err(anyhow!("Failed to create sync pipe"));
|
||||
}
|
||||
parent_read = AutoCloseFd(fds[0]);
|
||||
parent_write = AutoCloseFd(fds[1]);
|
||||
// parent_read/write auto-closed if subsequent pipes fail ↑
|
||||
|
||||
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
||||
return Err(anyhow!("Failed to create done pipe"));
|
||||
}
|
||||
child_read = AutoCloseFd(fds[0]);
|
||||
child_write = AutoCloseFd(fds[1]);
|
||||
// O_CLOEXEC on the write end: if execve succeeds the kernel closes cw
|
||||
// atomically, the parent's read(child_read) gets EOF immediately, and
|
||||
// waitpid becomes the real wait. The done-pipe is then only used on
|
||||
// the error path (f() returned Err before execve was reached).
|
||||
libc::fcntl(child_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC);
|
||||
|
||||
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
||||
return Err(anyhow!("Failed to create error pipe"));
|
||||
}
|
||||
error_read = AutoCloseFd(fds[0]);
|
||||
error_write = AutoCloseFd(fds[1]);
|
||||
// Same treatment for error_write: auto-closed on exec (no error),
|
||||
// written explicitly on the error path before the child exits.
|
||||
libc::fcntl(error_write.raw(), libc::F_SETFD, libc::FD_CLOEXEC);
|
||||
}
|
||||
|
||||
// Stack for the child process
|
||||
let stack_size = crate::utils::CHILD_STACK_SIZE;
|
||||
let mut stack = vec![0u8; stack_size];
|
||||
|
||||
// Wrap f in Option to allow taking it once inside the child closure
|
||||
let mut f = Some(f);
|
||||
|
||||
// Extract raw fds for the child closure. The child is a clone of the
|
||||
// parent process and gets its own copies of all open fds; the parent's
|
||||
// AutoCloseFd wrappers independently manage the parent's copies.
|
||||
let pr = parent_read.raw();
|
||||
let pw = parent_write.raw();
|
||||
let cr = child_read.raw();
|
||||
let cw = child_write.raw();
|
||||
let er = error_read.raw();
|
||||
let ew = error_write.raw();
|
||||
|
||||
// Clone with new namespaces
|
||||
let pid = unsafe {
|
||||
clone(
|
||||
Box::new(move || {
|
||||
// Close unused pipe ends in the child
|
||||
libc::close(pw);
|
||||
libc::close(cr);
|
||||
libc::close(er);
|
||||
|
||||
// Wait for parent to set up UID/GID mappings
|
||||
let mut buf = [0u8; 1];
|
||||
libc::read(pr, buf.as_mut_ptr() as *mut libc::c_void, 1);
|
||||
libc::close(pr);
|
||||
|
||||
// Run the function
|
||||
let result = if let Some(func) = f.take() {
|
||||
func()
|
||||
} else {
|
||||
Err(anyhow!("Function already called"))
|
||||
};
|
||||
|
||||
// On failure, write the full error chain to the error pipe
|
||||
// before signalling done, so the parent can reconstruct it.
|
||||
if let Err(ref e) = result {
|
||||
let msg = format!("{:#}", e);
|
||||
let bytes = msg.as_bytes();
|
||||
libc::write(ew, bytes.as_ptr() as *const libc::c_void, bytes.len());
|
||||
}
|
||||
libc::close(ew);
|
||||
|
||||
// Signal completion
|
||||
libc::write(cw, c"done".as_ptr() as *const libc::c_void, 4);
|
||||
libc::close(cw);
|
||||
|
||||
if result.is_ok() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}),
|
||||
&mut stack,
|
||||
CLONE_FLAGS,
|
||||
Some(Signal::SIGCHLD as i32),
|
||||
)
|
||||
}
|
||||
.context("Failed to clone with new namespaces")?;
|
||||
// clone() failure: all six AutoCloseFds drop here, closing every fd. ✓
|
||||
|
||||
// Parent: drop the child-side ends now that clone has succeeded.
|
||||
// The child process has its own copies; dropping here closes the parent's.
|
||||
drop(parent_read);
|
||||
drop(child_write);
|
||||
drop(error_write);
|
||||
|
||||
// Set up UID/GID mappings for the child
|
||||
// setup_user_namespace failure: parent_write, child_read, error_read
|
||||
// auto-closed by AutoCloseFd drop. ✓
|
||||
setup_user_namespace(pid)?;
|
||||
|
||||
// Signal child to proceed
|
||||
unsafe {
|
||||
libc::write(parent_write.raw(), c"go".as_ptr() as *const libc::c_void, 2);
|
||||
}
|
||||
drop(parent_write);
|
||||
|
||||
// Wait for child to complete (or the exec'd shell to exit)
|
||||
let mut buf = [0u8; 4];
|
||||
unsafe {
|
||||
libc::read(child_read.raw(), buf.as_mut_ptr() as *mut libc::c_void, 4);
|
||||
}
|
||||
drop(child_read);
|
||||
|
||||
// Read the error message written by the child, if any.
|
||||
// error_write was either closed explicitly (on error) or auto-closed via
|
||||
// O_CLOEXEC (on successful exec), so this read always terminates.
|
||||
let child_error: Option<String> = unsafe {
|
||||
let mut error_bytes = Vec::new();
|
||||
let mut tmp = [0u8; crate::utils::ERROR_BUFFER_SIZE];
|
||||
loop {
|
||||
let n = libc::read(
|
||||
error_read.raw(),
|
||||
tmp.as_mut_ptr() as *mut libc::c_void,
|
||||
tmp.len(),
|
||||
);
|
||||
if n <= 0 {
|
||||
break;
|
||||
}
|
||||
error_bytes.extend_from_slice(&tmp[..n as usize]);
|
||||
}
|
||||
if error_bytes.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&error_bytes).into_owned())
|
||||
}
|
||||
};
|
||||
drop(error_read);
|
||||
|
||||
// Wait for child process
|
||||
let status = nix::sys::wait::waitpid(pid, None)?;
|
||||
|
||||
match status {
|
||||
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0),
|
||||
nix::sys::wait::WaitStatus::Exited(_, code) => {
|
||||
// If the child reported an error (e.g., setup failure), return it.
|
||||
// Otherwise, just forward the exit code without an error message.
|
||||
if let Some(msg) = child_error {
|
||||
Err(anyhow!("{}", msg))
|
||||
} else {
|
||||
Ok(code)
|
||||
}
|
||||
}
|
||||
nix::sys::wait::WaitStatus::Signaled(_, sig, _) => Ok(128 + sig as i32),
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up UID/GID mappings for user namespace
|
||||
fn setup_user_namespace(pid: Pid) -> Result<()> {
|
||||
let uid = getuid();
|
||||
let gid = getgid();
|
||||
|
||||
// Get the subordinate UID/GID ranges from /etc/subuid and /etc/subgid
|
||||
// For unprivileged users, we need to use these ranges
|
||||
let (sub_uid_start, sub_uid_count) = get_subuid_range(uid)?;
|
||||
let (sub_gid_start, sub_gid_count) = get_subgid_range(gid)?;
|
||||
|
||||
// We map UID 0 (root inside the namespace) to the host user, then map
|
||||
// IDs 1..sub_uid_count-1 to the subordinate range. A count of 0 or 1
|
||||
// leaves no subordinate IDs to map and indicates a malformed /etc/subuid.
|
||||
if sub_uid_count < 2 {
|
||||
return Err(anyhow!(
|
||||
"subuid count {} for uid {} is too small (need at least 2); \
|
||||
check /etc/subuid",
|
||||
sub_uid_count,
|
||||
uid
|
||||
));
|
||||
}
|
||||
if sub_gid_count < 2 {
|
||||
return Err(anyhow!(
|
||||
"subgid count {} for gid {} is too small (need at least 2); \
|
||||
check /etc/subgid",
|
||||
sub_gid_count,
|
||||
gid
|
||||
));
|
||||
}
|
||||
|
||||
// Allow setgroups so apt and other tools can drop privileges
|
||||
let setgroups_path = format!("/proc/{}/setgroups", pid);
|
||||
std::fs::write(&setgroups_path, "allow\n")
|
||||
.with_context(|| format!("Failed to write {}", setgroups_path))?;
|
||||
|
||||
// Use newuidmap and newgidmap for setting up mappings
|
||||
// These are setuid binaries that allow unprivileged users to map subuid/subgid ranges
|
||||
let pid_str = pid.to_string();
|
||||
|
||||
// newuidmap format: newuidmap pid ns_start host_start count ...
|
||||
// Map current user to root (0), then subordinate UIDs starting from 1
|
||||
let uid_result = std::process::Command::new("newuidmap")
|
||||
.arg(&pid_str)
|
||||
.arg("0")
|
||||
.arg(uid.to_string())
|
||||
.arg("1")
|
||||
.arg("1")
|
||||
.arg(sub_uid_start.to_string())
|
||||
.arg((sub_uid_count - 1).to_string())
|
||||
.status()
|
||||
.context("Failed to execute newuidmap")?;
|
||||
|
||||
if !uid_result.success() {
|
||||
return Err(anyhow!(
|
||||
"newuidmap failed - ensure subuid entry exists in /etc/subuid"
|
||||
));
|
||||
}
|
||||
|
||||
// newgidmap format: newgidmap pid ns_start host_start count ...
|
||||
// Map current group to root (0), then subordinate GIDs starting from 1
|
||||
let gid_result = std::process::Command::new("newgidmap")
|
||||
.arg(&pid_str)
|
||||
.arg("0")
|
||||
.arg(gid.to_string())
|
||||
.arg("1")
|
||||
.arg("1")
|
||||
.arg(sub_gid_start.to_string())
|
||||
.arg((sub_gid_count - 1).to_string())
|
||||
.status()
|
||||
.context("Failed to execute newgidmap")?;
|
||||
|
||||
if !gid_result.success() {
|
||||
return Err(anyhow!(
|
||||
"newgidmap failed - ensure subgid entry exists in /etc/subgid"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get subordinate UID range for a user from /etc/subuid
|
||||
fn get_subuid_range(uid: nix::unistd::Uid) -> Result<(u32, u32)> {
|
||||
let content = std::fs::read_to_string("/etc/subuid").context("Failed to read /etc/subuid")?;
|
||||
|
||||
let username =
|
||||
users::get_user_by_uid(uid.as_raw()).map(|u| u.name().to_string_lossy().to_string());
|
||||
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() >= 3 {
|
||||
// Check if this line matches our user (by name or UID)
|
||||
let matches = parts[0] == username.as_deref().unwrap_or("")
|
||||
|| parts[0].parse::<u32>().ok() == Some(uid.as_raw());
|
||||
|
||||
if matches {
|
||||
let start: u32 = parts[1].parse().context("Invalid subuid start")?;
|
||||
let count: u32 = parts[2].parse().context("Invalid subuid count")?;
|
||||
return Ok((start, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No subuid entry found for user {} (uid {}). \
|
||||
Add one to /etc/subuid, e.g.:\n {}:100000:65536",
|
||||
username.as_deref().unwrap_or("<unknown>"),
|
||||
uid,
|
||||
username.as_deref().unwrap_or(&uid.to_string()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get subordinate GID range for a group from /etc/subgid
|
||||
fn get_subgid_range(gid: nix::unistd::Gid) -> Result<(u32, u32)> {
|
||||
let content = std::fs::read_to_string("/etc/subgid").context("Failed to read /etc/subgid")?;
|
||||
|
||||
let groupname =
|
||||
users::get_group_by_gid(gid.as_raw()).map(|g| g.name().to_string_lossy().to_string());
|
||||
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() >= 3 {
|
||||
// Check if this line matches our group (by name or GID)
|
||||
let matches = parts[0] == groupname.as_deref().unwrap_or("")
|
||||
|| parts[0].parse::<u32>().ok() == Some(gid.as_raw());
|
||||
|
||||
if matches {
|
||||
let start: u32 = parts[1].parse().context("Invalid subgid start")?;
|
||||
let count: u32 = parts[2].parse().context("Invalid subgid count")?;
|
||||
return Ok((start, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No subgid entry found for group {} (gid {}). \
|
||||
Add one to /etc/subgid, e.g.:\n {}:100000:65536",
|
||||
groupname.as_deref().unwrap_or("<unknown>"),
|
||||
gid,
|
||||
groupname.as_deref().unwrap_or(&gid.to_string()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Set hostname in UTS namespace
|
||||
pub fn set_hostname(distro: &str) -> Result<()> {
|
||||
use nix::unistd::sethostname;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
// Seed from both the current time and the PID so each invocation gets a
|
||||
// distinct suffix even when called in rapid succession. Time alone is
|
||||
// not sufficient: truncating nanoseconds to u8 in a tight loop produces
|
||||
// the same byte every iteration.
|
||||
let mut hasher = DefaultHasher::new();
|
||||
std::time::SystemTime::now().hash(&mut hasher);
|
||||
std::process::id().hash(&mut hasher);
|
||||
let mut state = hasher.finish();
|
||||
|
||||
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
// Use HOSTNAME_SUFFIX_BITS for entropy (6 hex chars = 24 bits)
|
||||
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
|
||||
let random_suffix: String = (0..suffix_len)
|
||||
.map(|_| {
|
||||
// Knuth multiplicative LCG — each step advances the full 64-bit state.
|
||||
state = state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
chars[(state >> 33) as usize % chars.len()] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let hostname = format!("ecr-{}-{}", distro, random_suffix);
|
||||
|
||||
sethostname(&hostname).with_context(|| format!("Failed to set hostname to {}", hostname))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
#[test]
|
||||
fn test_check_user_namespace_returns_ok() {
|
||||
// This test verifies the function runs without panicking
|
||||
// On most modern Linux systems with user namespaces enabled, this should pass
|
||||
let result = check_user_namespace();
|
||||
// We can't assert success because it depends on system configuration
|
||||
// But we can verify it doesn't panic and returns a Result
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hostname_format() {
|
||||
// Test that hostname generation produces valid format
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut hostnames = HashSet::new();
|
||||
for _ in 0..100 {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
std::time::SystemTime::now().hash(&mut hasher);
|
||||
std::process::id().hash(&mut hasher);
|
||||
let mut state = hasher.finish();
|
||||
|
||||
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
|
||||
let random_suffix: String = (0..suffix_len)
|
||||
.map(|_| {
|
||||
state = state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
chars[(state >> 33) as usize % chars.len()] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let hostname = format!("ecr-test-{}", random_suffix);
|
||||
|
||||
// Verify hostname format
|
||||
assert!(hostname.starts_with("ecr-test-"));
|
||||
assert!(hostname.len() > 9); // "ecr-test-" + at least 1 char
|
||||
assert!(hostname
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
|
||||
|
||||
hostnames.insert(hostname);
|
||||
}
|
||||
|
||||
// With 100 iterations and good entropy, we should get many unique hostnames
|
||||
assert!(
|
||||
hostnames.len() > 50,
|
||||
"Expected many unique hostnames, got {}",
|
||||
hostnames.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_hostname_uniqueness() {
|
||||
// Verify that rapid consecutive calls produce different hostnames
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut hostnames = Vec::new();
|
||||
for _ in 0..10 {
|
||||
// Simulate the hostname generation logic
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
std::time::SystemTime::now().hash(&mut hasher);
|
||||
std::process::id().hash(&mut hasher);
|
||||
let mut state = hasher.finish();
|
||||
|
||||
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let suffix_len = (crate::utils::HOSTNAME_SUFFIX_BITS as f64).log2() as usize / 4;
|
||||
let random_suffix: String = (0..suffix_len)
|
||||
.map(|_| {
|
||||
state = state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
chars[(state >> 33) as usize % chars.len()] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
hostnames.push(format!("ecr-test-{}", random_suffix));
|
||||
}
|
||||
|
||||
let unique: HashSet<_> = hostnames.iter().collect();
|
||||
// Most hostnames should be unique (high entropy)
|
||||
assert!(unique.len() >= 8, "Expected mostly unique hostnames");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::veprintln;
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::path::Path;
|
||||
|
||||
/// Check if binfmt_misc is registered for the target architecture
|
||||
pub fn check_binfmt(arch: &str) -> Result<()> {
|
||||
let qemu_arch = crate::utils::Arch::from_str(arch).qemu_binfmt_name();
|
||||
|
||||
let binfmt_path = format!("/proc/sys/fs/binfmt_misc/qemu-{}", qemu_arch);
|
||||
|
||||
if !Path::new(&binfmt_path).exists() {
|
||||
return Err(anyhow!(
|
||||
"binfmt_misc not registered for {}\n\n\
|
||||
Install QEMU user emulation:\n\
|
||||
Ubuntu/Debian: sudo apt install qemu-user-static\n\
|
||||
Arch: sudo pacman -S qemu-user-static-binfmt\n\
|
||||
Alpine: sudo apk add qemu-user-static",
|
||||
arch
|
||||
));
|
||||
}
|
||||
|
||||
veprintln!("QEMU binfmt_misc registered for {}", arch);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
use crate::veprintln;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use cpio::{newc, NewcBuilder};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// QEMU system emulation configuration
|
||||
pub struct QemuConfig {
|
||||
/// Path to the kernel image (vmlinuz)
|
||||
pub kernel_path: PathBuf,
|
||||
/// Path to the rootfs directory
|
||||
pub rootfs_path: PathBuf,
|
||||
/// Memory size for VM (e.g., "2G", "512M")
|
||||
pub memory: String,
|
||||
/// Target architecture
|
||||
pub arch: String,
|
||||
/// Optional command to run instead of default init
|
||||
pub command: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Launch QEMU with the given configuration
|
||||
pub fn launch_qemu(config: QemuConfig) -> Result<()> {
|
||||
// Check that kernel exists
|
||||
if !config.kernel_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"Kernel not found: {}",
|
||||
config.kernel_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Check that rootfs exists
|
||||
if !config.rootfs_path.exists() {
|
||||
return Err(anyhow!(
|
||||
"Rootfs not found: {}",
|
||||
config.rootfs_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Validate memory string format
|
||||
crate::utils::validate_memory_string(&config.memory)
|
||||
.with_context(|| format!("Invalid memory size: {}", config.memory))?;
|
||||
|
||||
// Create an uncompressed cpio initramfs from the rootfs
|
||||
let initramfs = create_initramfs(&config.rootfs_path)?;
|
||||
|
||||
// Get QEMU binary for architecture
|
||||
let qemu_bin = qemu_binary_for_arch(&config.arch);
|
||||
|
||||
// Check QEMU exists
|
||||
which::which(&qemu_bin).context(format!(
|
||||
"QEMU system emulator '{}' not found. Install it with:\n\
|
||||
Ubuntu/Debian: sudo apt install qemu-system-{}\n\
|
||||
Arch: sudo pacman -S qemu-system-{}\n\
|
||||
Alpine: sudo apk add qemu-system-{}",
|
||||
qemu_bin,
|
||||
get_arch_package_suffix(&config.arch),
|
||||
get_arch_package_suffix(&config.arch),
|
||||
get_arch_package_suffix(&config.arch)
|
||||
))?;
|
||||
|
||||
// Check if we can use KVM acceleration
|
||||
let use_kvm = can_use_kvm(&config.arch);
|
||||
if use_kvm {
|
||||
veprintln!(" KVM: enabled (native acceleration)");
|
||||
} else {
|
||||
veprintln!(" KVM: disabled (using software emulation)");
|
||||
}
|
||||
|
||||
// Detect the best available shell in the rootfs
|
||||
let shell = crate::utils::detect_shell(&config.rootfs_path);
|
||||
|
||||
// Generate a unique hostname like "ecr-vm-a1b2c3"
|
||||
// Use VM_HOSTNAME_SUFFIX_BITS constant for entropy
|
||||
let hostname_suffix = format!(
|
||||
"{:x}",
|
||||
(std::process::id() as u64).wrapping_mul(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64
|
||||
) % crate::utils::VM_HOSTNAME_SUFFIX_BITS
|
||||
);
|
||||
let hostname = format!("ecr-vm-{}", hostname_suffix);
|
||||
|
||||
// Build kernel command line
|
||||
// For initramfs boot, use rdinit= instead of init=
|
||||
// No root= needed as initramfs becomes the rootfs
|
||||
// 'quiet' suppresses kernel log messages for a cleaner console (removed with -v)
|
||||
// The init script (added to initramfs) handles hostname, shell, and poweroff
|
||||
let quiet_flag = if crate::verbose::is_verbose() {
|
||||
""
|
||||
} else {
|
||||
" quiet"
|
||||
};
|
||||
|
||||
let kernel_append = if let Some(ref cmd) = config.command {
|
||||
// The argv travels through the kernel cmdline, where quotes and
|
||||
// spaces would be mangled — pass each element base64-encoded,
|
||||
// comma-separated (the base64 alphabet contains neither). The init
|
||||
// script decodes it back and execs the argv verbatim.
|
||||
use base64::Engine as _;
|
||||
let argv_b64 = cmd
|
||||
.iter()
|
||||
.map(|arg| base64::engine::general_purpose::STANDARD.encode(arg.as_bytes()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(
|
||||
"console=ttyS0{} ECR_SHELL={} ECR_ARGV={} ECR_HOSTNAME={}",
|
||||
quiet_flag, shell, argv_b64, hostname
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"console=ttyS0{} ECR_SHELL={} ECR_HOSTNAME={}",
|
||||
quiet_flag, shell, hostname
|
||||
)
|
||||
};
|
||||
|
||||
veprintln!("Launching QEMU: {}", qemu_bin);
|
||||
veprintln!(" Kernel: {}", config.kernel_path.display());
|
||||
veprintln!(" Initramfs: {}", initramfs.display());
|
||||
veprintln!(" Memory: {}", config.memory);
|
||||
veprintln!(" Kernel append: {}", kernel_append);
|
||||
|
||||
// Build QEMU arguments
|
||||
// -display none suppresses VGA/BIOS output
|
||||
// -serial mon:stdio connects serial console to terminal with QEMU monitor muxed
|
||||
// -no-reboot makes QEMU exit when the guest requests poweroff/reboot
|
||||
let mut args = vec![
|
||||
"-kernel".to_string(),
|
||||
config.kernel_path.to_string_lossy().to_string(),
|
||||
"-initrd".to_string(),
|
||||
initramfs.to_string_lossy().to_string(),
|
||||
"-append".to_string(),
|
||||
kernel_append,
|
||||
"-m".to_string(),
|
||||
config.memory.clone(),
|
||||
"-display".to_string(),
|
||||
"none".to_string(),
|
||||
"-serial".to_string(),
|
||||
"mon:stdio".to_string(),
|
||||
"-no-reboot".to_string(),
|
||||
"-netdev".to_string(),
|
||||
"user,id=net0".to_string(),
|
||||
"-device".to_string(),
|
||||
"virtio-net-pci,netdev=net0".to_string(),
|
||||
];
|
||||
|
||||
// Add KVM acceleration if available
|
||||
if use_kvm {
|
||||
args.push("-enable-kvm".to_string());
|
||||
args.push("-cpu".to_string());
|
||||
args.push("host".to_string());
|
||||
}
|
||||
|
||||
// Execute QEMU
|
||||
let status = Command::new(&qemu_bin)
|
||||
.args(&args)
|
||||
.status()
|
||||
.context("Failed to execute QEMU")?;
|
||||
|
||||
// Cleanup initramfs
|
||||
if let Err(e) = std::fs::remove_file(&initramfs) {
|
||||
veprintln!("Warning: failed to cleanup initramfs: {}", e);
|
||||
}
|
||||
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"QEMU exited with non-zero status: {}",
|
||||
status.code().unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get QEMU system binary name for architecture
|
||||
fn qemu_binary_for_arch(arch: &str) -> String {
|
||||
let arch_enum = crate::utils::Arch::from_str(arch);
|
||||
format!("qemu-system-{}", arch_enum.qemu_system_name())
|
||||
}
|
||||
|
||||
/// Get architecture suffix for package names
|
||||
fn get_arch_package_suffix(arch: &str) -> &'static str {
|
||||
crate::utils::Arch::from_str(arch).qemu_package_suffix()
|
||||
}
|
||||
|
||||
/// Check if KVM acceleration can be used for the target architecture
|
||||
fn can_use_kvm(target_arch: &str) -> bool {
|
||||
use crate::utils::Arch;
|
||||
|
||||
// Normalize both to canonical form (uname -m style) and compare
|
||||
let host_arch = crate::utils::get_host_arch();
|
||||
let target_enum = Arch::from_str(target_arch);
|
||||
|
||||
if host_arch != target_enum {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if /dev/kvm exists and is accessible (read+write required for VM execution)
|
||||
match std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/kvm")
|
||||
{
|
||||
Ok(_) => {
|
||||
// Additional check: verify KVM actually works by checking capabilities
|
||||
// This catches cases where /dev/kvm exists but KVM is not functional
|
||||
check_kvm_capabilities()
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if KVM capabilities are actually functional
|
||||
fn check_kvm_capabilities() -> bool {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
// Try to open /dev/kvm and check KVM_GET_API_VERSION
|
||||
match std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/kvm")
|
||||
{
|
||||
Ok(file) => {
|
||||
let fd = file.as_raw_fd();
|
||||
// KVM_GET_API_VERSION ioctl = 0xAE00
|
||||
// Expected return value is 12 (KVM_API_VERSION)
|
||||
let ret = unsafe { libc::ioctl(fd, 0xAE00) };
|
||||
ret == 12
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an uncompressed cpio initramfs from a directory.
|
||||
/// Entries are streamed straight to disk so large rootfs images never have
|
||||
/// to fit in memory as a whole archive.
|
||||
fn create_initramfs(rootfs: &Path) -> Result<PathBuf> {
|
||||
// Create a temporary file for the initramfs (uncompressed cpio)
|
||||
// Use a temp file in the same directory as rootfs, or fall back to /tmp
|
||||
let initramfs_path = rootfs
|
||||
.parent()
|
||||
.map(|p| p.join("initramfs.cpio"))
|
||||
.unwrap_or_else(|| std::env::temp_dir().join("initramfs.cpio"));
|
||||
|
||||
// Create progress bar
|
||||
let pb = ProgressBar::new_spinner();
|
||||
pb.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.green} {msg} ({pos} files)")
|
||||
.unwrap(),
|
||||
);
|
||||
pb.set_message("Creating initramfs...");
|
||||
|
||||
let file = std::fs::File::create(&initramfs_path)
|
||||
.with_context(|| format!("Failed to create {}", initramfs_path.display()))?;
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
|
||||
let result = write_cpio_archive(rootfs, &mut writer, &pb);
|
||||
let flushed = writer.flush().context("Failed to flush initramfs");
|
||||
|
||||
// On any error, don't leave a partial archive behind
|
||||
if let Err(e) = result.or(flushed) {
|
||||
std::fs::remove_file(&initramfs_path).ok();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Finish progress bar
|
||||
let file_count = pb.position();
|
||||
pb.finish_and_clear();
|
||||
|
||||
let total_bytes = std::fs::metadata(&initramfs_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
veprintln!(
|
||||
"Initramfs created: {} bytes, {} files",
|
||||
total_bytes,
|
||||
file_count
|
||||
);
|
||||
|
||||
Ok(initramfs_path)
|
||||
}
|
||||
|
||||
/// Write the newc-format cpio archive for a directory tree, streaming to `writer`
|
||||
fn write_cpio_archive<W: Write>(rootfs: &Path, writer: &mut W, pb: &ProgressBar) -> Result<()> {
|
||||
// Track hard links by (device, inode): the value is the synthetic cpio
|
||||
// inode assigned to the first occurrence. The Linux initramfs loader
|
||||
// turns later zero-size entries sharing that inode into hard links.
|
||||
let mut seen_inodes: std::collections::HashMap<(u64, u64), u32> =
|
||||
std::collections::HashMap::new();
|
||||
let mut next_ino: u32 = 1;
|
||||
// Names of entries already written, so we can skip duplicate device nodes
|
||||
let mut written_names: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut total_data: u64 = 0;
|
||||
|
||||
write_dir_entries(
|
||||
rootfs,
|
||||
rootfs,
|
||||
writer,
|
||||
pb,
|
||||
&mut seen_inodes,
|
||||
&mut next_ino,
|
||||
&mut written_names,
|
||||
&mut total_data,
|
||||
)?;
|
||||
|
||||
veprintln!(
|
||||
"Collected {} entries, {} bytes total data",
|
||||
written_names.len(),
|
||||
total_data
|
||||
);
|
||||
|
||||
// Add essential device nodes for serial console
|
||||
// These are character devices (mode 0o020xxx)
|
||||
let device_nodes = [
|
||||
// /dev/ttyS0 - serial console (major 4, minor 64)
|
||||
("dev/ttyS0", 0o020644, 4, 64),
|
||||
// /dev/null (major 1, minor 3)
|
||||
("dev/null", 0o020644, 1, 3),
|
||||
// /dev/tty - controlling terminal (major 5, minor 0)
|
||||
("dev/tty", 0o020666, 5, 0),
|
||||
];
|
||||
|
||||
for (name, mode, major, minor) in device_nodes {
|
||||
// Check if this device node already exists in the archive
|
||||
if written_names.contains(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let builder = NewcBuilder::new(name)
|
||||
.mode(mode)
|
||||
.uid(0)
|
||||
.gid(0)
|
||||
.nlink(1)
|
||||
.mtime(0)
|
||||
.rdev_major(major)
|
||||
.rdev_minor(minor);
|
||||
|
||||
// Device nodes have zero size
|
||||
let entry_writer = builder.write(&mut *writer, 0);
|
||||
entry_writer
|
||||
.finish()
|
||||
.context("Failed to finish device node entry")?;
|
||||
}
|
||||
|
||||
// Add the /init script that will be run as PID 1
|
||||
// This script handles hostname setup, shell execution, and poweroff on exit
|
||||
// Uses /proc/sysrq-trigger for poweroff since poweroff command may not be available
|
||||
let init_script = r#"#!/bin/sh
|
||||
# ECR init script - runs as PID 1
|
||||
|
||||
# Mount essential filesystems
|
||||
mount -t proc proc /proc
|
||||
mount -t sysfs sysfs /sys
|
||||
mount -t devtmpfs devtmpfs /dev 2>/dev/null || true
|
||||
|
||||
# Parse our parameters straight from the kernel cmdline. More robust than
|
||||
# relying on the kernel forwarding unknown key=value params to init's env.
|
||||
ECR_SHELL="/bin/sh"
|
||||
ECR_ARGV=""
|
||||
for param in $(cat /proc/cmdline); do
|
||||
case "$param" in
|
||||
ECR_SHELL=*) ECR_SHELL="${param#ECR_SHELL=}" ;;
|
||||
ECR_ARGV=*) ECR_ARGV="${param#ECR_ARGV=}" ;;
|
||||
ECR_HOSTNAME=*) ECR_HOSTNAME="${param#ECR_HOSTNAME=}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Set hostname from kernel cmdline (via procfs — no hostname binary needed)
|
||||
if [ -n "$ECR_HOSTNAME" ]; then
|
||||
echo "$ECR_HOSTNAME" > /etc/hostname
|
||||
echo "$ECR_HOSTNAME" > /proc/sys/kernel/hostname
|
||||
fi
|
||||
|
||||
# Create console device if missing
|
||||
mknod -m 600 /dev/console c 5 1 2>/dev/null || true
|
||||
mknod -m 666 /dev/ttyS0 c 4 64 2>/dev/null || true
|
||||
|
||||
# Function to poweroff - use sysrq-trigger which works without external binaries
|
||||
do_poweroff() {
|
||||
# Give the serial console a moment to drain pending output, otherwise
|
||||
# the last command output can be dropped when the VM powers off
|
||||
sleep 1
|
||||
# Silence kernel printk to suppress shutdown messages
|
||||
echo 0 > /proc/sys/kernel/printk
|
||||
# 'o' means power off, see Documentation/admin-guide/sysrq.rst
|
||||
echo o > /proc/sysrq-trigger
|
||||
# Fallback: infinite loop to prevent kernel panic
|
||||
while true; do sleep 1; done
|
||||
}
|
||||
|
||||
# Trap exit to ensure poweroff runs
|
||||
trap do_poweroff EXIT
|
||||
|
||||
# Rebuild the argv: each element is base64-encoded, elements are separated
|
||||
# by commas. Decoding into "$@" avoids any shell re-parsing of the command.
|
||||
set --
|
||||
if [ -n "$ECR_ARGV" ]; then
|
||||
for enc in $(printf '%s' "$ECR_ARGV" | tr ',' ' '); do
|
||||
dec=$(printf '%s' "$enc" | base64 -d 2>/dev/null)
|
||||
set -- "$@" "$dec"
|
||||
done
|
||||
fi
|
||||
|
||||
# Run the requested command verbatim, or an interactive shell in its own
|
||||
# session (setsid enables job control on the serial console)
|
||||
if [ "$#" -gt 0 ]; then
|
||||
"$@"
|
||||
else
|
||||
setsid sh -c "exec $ECR_SHELL </dev/ttyS0 >/dev/ttyS0 2>&1"
|
||||
fi
|
||||
"#;
|
||||
|
||||
write_cpio_entry(writer, "init", 0o100755, 0, 1, 0, init_script.as_bytes())?;
|
||||
|
||||
// Write the trailer
|
||||
newc::trailer(&mut *writer).context("Failed to write cpio trailer")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a single newc-format cpio entry
|
||||
fn write_cpio_entry<W: Write>(
|
||||
writer: &mut W,
|
||||
name: &str,
|
||||
mode: u32,
|
||||
mtime: u32,
|
||||
nlink: u32,
|
||||
ino: u32,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
let builder = NewcBuilder::new(name)
|
||||
.mode(mode)
|
||||
.uid(0)
|
||||
.gid(0)
|
||||
.ino(ino)
|
||||
.nlink(nlink)
|
||||
.mtime(mtime);
|
||||
|
||||
let mut entry_writer = builder.write(&mut *writer, data.len() as u32);
|
||||
entry_writer
|
||||
.write_all(data)
|
||||
.with_context(|| format!("Failed to write {} to cpio archive", name))?;
|
||||
entry_writer
|
||||
.finish()
|
||||
.with_context(|| format!("Failed to finish cpio entry {}", name))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk a directory tree, writing every entry to the cpio archive as it goes
|
||||
/// Hard links are handled by assigning a synthetic inode to the first
|
||||
/// occurrence of a (device, inode) pair; subsequent occurrences are written
|
||||
/// as zero-size entries reusing that inode.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_dir_entries(
|
||||
base: &Path,
|
||||
current: &Path,
|
||||
writer: &mut impl Write,
|
||||
pb: &ProgressBar,
|
||||
seen_inodes: &mut std::collections::HashMap<(u64, u64), u32>,
|
||||
next_ino: &mut u32,
|
||||
written_names: &mut std::collections::HashSet<String>,
|
||||
total_data: &mut u64,
|
||||
) -> Result<()> {
|
||||
// Read directory entries
|
||||
let dir_entries: Vec<_> = match std::fs::read_dir(current) {
|
||||
Ok(entries) => entries.collect::<std::result::Result<_, _>>()?,
|
||||
Err(e) => {
|
||||
veprintln!(
|
||||
"Warning: cannot read directory {}: {}",
|
||||
current.display(),
|
||||
e
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
for entry in dir_entries {
|
||||
let path = entry.path();
|
||||
|
||||
// Get metadata
|
||||
let metadata = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
veprintln!(
|
||||
"Warning: skipping {} due to metadata error: {}",
|
||||
path.display(),
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Increment progress counter
|
||||
pb.inc(1);
|
||||
|
||||
let file_type = metadata.file_type();
|
||||
|
||||
// Determine mode (file type + permissions from filesystem)
|
||||
let mode = if file_type.is_dir() {
|
||||
// Directory: preserve permissions, ensure at least rwx for owner
|
||||
0o040000 | (metadata.mode() & 0o7777)
|
||||
} else if file_type.is_symlink() {
|
||||
0o120777 // symlink with rwxrwxrwx (permissions don't matter for symlinks)
|
||||
} else if file_type.is_file() {
|
||||
// Regular file: preserve permissions from filesystem
|
||||
0o100000 | (metadata.mode() & 0o7777)
|
||||
} else {
|
||||
continue; // Skip other types (sockets, fifos, etc.)
|
||||
};
|
||||
|
||||
// Build the entry name (relative path from base)
|
||||
let relative = path.strip_prefix(base).unwrap();
|
||||
let entry_name = relative.to_string_lossy().into_owned();
|
||||
|
||||
// Determine (data, nlink, cpio inode).
|
||||
// The kernel's initramfs loader records the first entry of a hard-link
|
||||
// group (the one carrying the data) and turns later zero-size entries
|
||||
// with the same inode into sys_link calls.
|
||||
let (data, nlink, cpio_ino) = if file_type.is_file() && metadata.nlink() > 1 {
|
||||
let inode_key = (metadata.dev(), metadata.ino());
|
||||
match seen_inodes.get(&inode_key) {
|
||||
Some(seen_cpio_ino) => {
|
||||
// Subsequent occurrence: zero-size hard-link entry
|
||||
(Vec::new(), metadata.nlink() as u32, *seen_cpio_ino)
|
||||
}
|
||||
None => {
|
||||
// First occurrence: read the data, assign a synthetic inode
|
||||
let cpio_ino = *next_ino;
|
||||
*next_ino = next_ino.wrapping_add(1);
|
||||
seen_inodes.insert(inode_key, cpio_ino);
|
||||
let data = match std::fs::read(&path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
veprintln!("Warning: cannot read file {}: {}", path.display(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
(data, metadata.nlink() as u32, cpio_ino)
|
||||
}
|
||||
}
|
||||
} else if file_type.is_file() {
|
||||
// Regular file with nlink=1
|
||||
let data = match std::fs::read(&path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
veprintln!("Warning: cannot read file {}: {}", path.display(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
(data, 1, 0)
|
||||
} else if file_type.is_symlink() {
|
||||
match std::fs::read_link(&path) {
|
||||
Ok(target) => (target.to_string_lossy().into_owned().into_bytes(), 1, 0),
|
||||
Err(e) => {
|
||||
veprintln!("Warning: cannot read symlink {}: {}", path.display(), e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Directory
|
||||
(Vec::new(), 2, 0)
|
||||
};
|
||||
|
||||
*total_data += data.len() as u64;
|
||||
|
||||
write_cpio_entry(
|
||||
writer,
|
||||
&entry_name,
|
||||
mode,
|
||||
metadata.mtime() as u32,
|
||||
nlink,
|
||||
cpio_ino,
|
||||
&data,
|
||||
)?;
|
||||
written_names.insert(entry_name);
|
||||
|
||||
// Recurse into directories
|
||||
if file_type.is_dir() {
|
||||
write_dir_entries(
|
||||
base,
|
||||
&path,
|
||||
writer,
|
||||
pb,
|
||||
seen_inodes,
|
||||
next_ino,
|
||||
written_names,
|
||||
total_data,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read as _;
|
||||
|
||||
/// Parse a newc cpio archive into (name, ino, mode, nlink, file_size, data) tuples
|
||||
fn parse_cpio(archive: Vec<u8>) -> Vec<(String, u32, u32, u32, u32, Vec<u8>)> {
|
||||
let mut cursor = std::io::Cursor::new(archive);
|
||||
let mut entries = Vec::new();
|
||||
loop {
|
||||
let mut reader = newc::Reader::new(&mut cursor).expect("valid cpio entry");
|
||||
let name = reader.entry().name().to_string();
|
||||
let ino = reader.entry().ino();
|
||||
let mode = reader.entry().mode();
|
||||
let nlink = reader.entry().nlink();
|
||||
let file_size = reader.entry().file_size();
|
||||
let mut data = Vec::new();
|
||||
reader.read_to_end(&mut data).expect("read entry data");
|
||||
let is_trailer = reader.entry().is_trailer();
|
||||
// Skip the padding after the entry data before parsing the next one
|
||||
reader.finish().expect("skip entry padding");
|
||||
entries.push((name, ino, mode, nlink, file_size, data));
|
||||
if is_trailer {
|
||||
break;
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hard_links_share_inode() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Two distinct hard-link groups, each with two names
|
||||
std::fs::write(dir.path().join("group1.txt"), b"hello").unwrap();
|
||||
std::fs::hard_link(
|
||||
dir.path().join("group1.txt"),
|
||||
dir.path().join("group1b.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("group2.txt"), b"world!").unwrap();
|
||||
std::fs::hard_link(
|
||||
dir.path().join("group2.txt"),
|
||||
dir.path().join("group2b.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pb = ProgressBar::hidden();
|
||||
let mut archive = Vec::new();
|
||||
write_cpio_archive(dir.path(), &mut archive, &pb).unwrap();
|
||||
|
||||
let entries = parse_cpio(archive)
|
||||
.into_iter()
|
||||
.map(|(name, ino, _, nlink, size, data)| (name, (ino, nlink, size, data)))
|
||||
.collect::<std::collections::HashMap<String, (u32, u32, u32, Vec<u8>)>>();
|
||||
let get = |name: &str| entries[name].clone();
|
||||
|
||||
// Both names of a hard link share one synthetic (nonzero) inode.
|
||||
// readdir order decides which occurrence is walked first, so exactly
|
||||
// one of the two entries carries the data and the other is zero-size.
|
||||
let (ino1, nlink1, size1, data1) = get("group1.txt");
|
||||
let (ino1b, nlink1b, size1b, data1b) = get("group1b.txt");
|
||||
assert_eq!(nlink1, 2);
|
||||
assert_eq!(nlink1b, 2);
|
||||
assert_eq!(ino1, ino1b, "both names of a hard link must share an inode");
|
||||
assert_ne!(ino1, 0, "hard-link group must get a synthetic inode");
|
||||
assert_eq!(size1 + size1b, 5);
|
||||
assert_eq!(
|
||||
data1
|
||||
.iter()
|
||||
.chain(data1b.iter())
|
||||
.cloned()
|
||||
.collect::<Vec<u8>>(),
|
||||
b"hello".to_vec()
|
||||
);
|
||||
|
||||
// The other group gets a different inode — this is what keeps the
|
||||
// kernel from linking group2 names to group1's file
|
||||
let (ino2, _, size2, data2) = get("group2.txt");
|
||||
let (ino2b, _, size2b, data2b) = get("group2b.txt");
|
||||
assert_eq!(ino2, ino2b);
|
||||
assert_ne!(
|
||||
ino2, ino1,
|
||||
"distinct hard-link groups must not share an inode"
|
||||
);
|
||||
assert_eq!(size2 + size2b, 6);
|
||||
assert_eq!(
|
||||
data2
|
||||
.iter()
|
||||
.chain(data2b.iter())
|
||||
.cloned()
|
||||
.collect::<Vec<u8>>(),
|
||||
b"world!".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regular_files_have_zero_inode_and_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("sub")).unwrap();
|
||||
std::fs::write(dir.path().join("sub/plain.txt"), b"plain").unwrap();
|
||||
|
||||
let pb = ProgressBar::hidden();
|
||||
let mut archive = Vec::new();
|
||||
write_cpio_archive(dir.path(), &mut archive, &pb).unwrap();
|
||||
|
||||
let entries = parse_cpio(archive);
|
||||
let find = |name: &str| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|(n, ..)| n == name)
|
||||
.map(|(_, ino, mode, nlink, size, data)| (*ino, *mode, *nlink, *size, data.clone()))
|
||||
.expect("entry missing")
|
||||
};
|
||||
|
||||
// Regular files with nlink=1 keep inode 0 and must keep their data
|
||||
let (ino, _, nlink, size, data) = find("sub/plain.txt");
|
||||
assert_eq!(ino, 0);
|
||||
assert_eq!((nlink, size), (1, 5));
|
||||
assert_eq!(data, b"plain");
|
||||
|
||||
// The init script is always appended, executable, and non-empty
|
||||
let (_, init_mode, _, init_size, init_data) = find("init");
|
||||
assert_eq!(init_mode, 0o100755);
|
||||
assert!(init_size > 0);
|
||||
assert!(init_data.starts_with(b"#!/bin/sh"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::path::Path;
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/// Stack size for child processes in namespace cloning (1 MiB)
|
||||
pub const CHILD_STACK_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Buffer size for reading error messages from pipes (4 KiB, typical page size)
|
||||
pub const ERROR_BUFFER_SIZE: usize = 4096;
|
||||
|
||||
/// Maximum entropy bits for hostname suffix (24 bits = 6 hex chars)
|
||||
pub const HOSTNAME_SUFFIX_BITS: u64 = 0x1000000;
|
||||
|
||||
/// Maximum entropy bits for VM hostname suffix (24 bits = 6 hex chars)
|
||||
pub const VM_HOSTNAME_SUFFIX_BITS: u64 = 0x1000000;
|
||||
|
||||
// ============================================================================
|
||||
// Architecture handling
|
||||
// ============================================================================
|
||||
|
||||
/// Architecture representation in different naming conventions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Arch {
|
||||
/// x86-64 (AMD64, x86_64)
|
||||
Amd64,
|
||||
/// ARM 64-bit (AArch64)
|
||||
Arm64,
|
||||
/// ARM 32-bit hard-float
|
||||
Armhf,
|
||||
/// RISC-V 64-bit
|
||||
Riscv64,
|
||||
/// PowerPC 64-bit little-endian
|
||||
Ppc64el,
|
||||
/// IBM s390x
|
||||
S390x,
|
||||
/// Unknown architecture
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Arch {
|
||||
/// Get the architecture from a string (any common naming convention)
|
||||
/// Not a FromStr impl: unrecognized names map to Arch::Unknown rather
|
||||
/// than an error, by design.
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"amd64" | "x86_64" | "x64" => Arch::Amd64,
|
||||
"arm64" | "aarch64" | "arm64v8" => Arch::Arm64,
|
||||
"armhf" | "armv7" | "armv7l" | "arm" => Arch::Armhf,
|
||||
"riscv64" => Arch::Riscv64,
|
||||
"ppc64el" | "ppc64le" => Arch::Ppc64el,
|
||||
"s390x" => Arch::S390x,
|
||||
_ => Arch::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the Debian/Ubuntu style name
|
||||
pub fn debian_name(&self) -> &'static str {
|
||||
match self {
|
||||
Arch::Amd64 => "amd64",
|
||||
Arch::Arm64 => "arm64",
|
||||
Arch::Armhf => "armhf",
|
||||
Arch::Riscv64 => "riscv64",
|
||||
Arch::Ppc64el => "ppc64el",
|
||||
Arch::S390x => "s390x",
|
||||
Arch::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the OCI/Docker registry style name
|
||||
pub fn oci_name(&self) -> &'static str {
|
||||
match self {
|
||||
Arch::Amd64 => "amd64",
|
||||
Arch::Arm64 => "arm64",
|
||||
// OCI uses "arm" for 32-bit ARM with variant field
|
||||
Arch::Armhf => "arm",
|
||||
Arch::Riscv64 => "riscv64",
|
||||
Arch::Ppc64el => "ppc64le",
|
||||
Arch::S390x => "s390x",
|
||||
Arch::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the Alpine style name
|
||||
pub fn alpine_name(&self) -> &'static str {
|
||||
match self {
|
||||
Arch::Amd64 => "x86_64",
|
||||
Arch::Arm64 => "aarch64",
|
||||
Arch::Armhf => "armv7",
|
||||
Arch::Riscv64 => "riscv64",
|
||||
Arch::Ppc64el => "ppc64le",
|
||||
Arch::S390x => "s390x",
|
||||
Arch::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the QEMU binary suffix (e.g., "qemu-system-x86_64")
|
||||
pub fn qemu_system_name(&self) -> &'static str {
|
||||
match self {
|
||||
Arch::Amd64 => "x86_64",
|
||||
Arch::Arm64 => "aarch64",
|
||||
Arch::Armhf => "arm",
|
||||
Arch::Riscv64 => "riscv64",
|
||||
Arch::Ppc64el => "ppc64",
|
||||
Arch::S390x => "s390x",
|
||||
Arch::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the QEMU binfmt_misc name
|
||||
pub fn qemu_binfmt_name(&self) -> &'static str {
|
||||
match self {
|
||||
Arch::Amd64 => "x86_64",
|
||||
Arch::Arm64 => "aarch64",
|
||||
Arch::Armhf => "arm",
|
||||
Arch::Riscv64 => "riscv64",
|
||||
Arch::Ppc64el => "ppc64le",
|
||||
Arch::S390x => "s390x",
|
||||
Arch::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the package suffix for QEMU system emulator
|
||||
pub fn qemu_package_suffix(&self) -> &'static str {
|
||||
match self {
|
||||
Arch::Amd64 => "x86",
|
||||
Arch::Arm64 => "aarch64",
|
||||
Arch::Armhf => "arm",
|
||||
Arch::Riscv64 => "riscv64",
|
||||
Arch::Ppc64el => "ppc",
|
||||
Arch::S390x => "s390x",
|
||||
Arch::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the host system architecture using uname(2) syscall
|
||||
/// This returns the runtime machine string, which is correct even when
|
||||
/// the binary itself is running under emulation.
|
||||
pub fn get_host_arch() -> Arch {
|
||||
let utsname = nix::sys::utsname::uname()
|
||||
.expect("uname(2) syscall failed — cannot determine host architecture");
|
||||
let machine = utsname.machine().to_string_lossy();
|
||||
Arch::from_str(machine.as_ref())
|
||||
}
|
||||
|
||||
/// Map ecr architecture names to distro-specific names
|
||||
pub fn map_arch_for_distro(distro: &str, arch: &str) -> String {
|
||||
let arch_enum = Arch::from_str(arch);
|
||||
match distro.to_lowercase().as_str() {
|
||||
"ubuntu" => arch_enum.debian_name().to_string(),
|
||||
"alpine" => arch_enum.alpine_name().to_string(),
|
||||
_ => arch_enum.oci_name().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map architecture to OCI registry standard names
|
||||
pub fn map_oci_arch(arch: &str) -> String {
|
||||
Arch::from_str(arch).oci_name().to_string()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shell detection
|
||||
// ============================================================================
|
||||
|
||||
/// Detect the best available shell in a rootfs
|
||||
/// Checks for bash first, falls back to sh
|
||||
/// Returns the path relative to the rootfs (e.g., "/bin/bash")
|
||||
pub fn detect_shell(rootfs: &Path) -> &'static str {
|
||||
// Check for bash first (preferred)
|
||||
if rootfs.join("bin/bash").exists() {
|
||||
"/bin/bash"
|
||||
} else if rootfs.join("bin/sh").exists() {
|
||||
"/bin/sh"
|
||||
} else if rootfs.join("usr/bin/bash").exists() {
|
||||
"/usr/bin/bash"
|
||||
} else if rootfs.join("usr/bin/sh").exists() {
|
||||
"/usr/bin/sh"
|
||||
} else {
|
||||
"/bin/sh" // Will fail with clear error if not present
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory string validation
|
||||
// ============================================================================
|
||||
|
||||
/// Validate a QEMU memory size string (e.g., "512M", "2G")
|
||||
/// Returns an error if the format is invalid
|
||||
pub fn validate_memory_string(s: &str) -> Result<()> {
|
||||
if s.is_empty() {
|
||||
return Err(anyhow!("Memory size cannot be empty"));
|
||||
}
|
||||
|
||||
// Must end with a valid suffix or be a plain number
|
||||
let suffix = s.chars().last().unwrap();
|
||||
let has_suffix = suffix.is_ascii_alphabetic();
|
||||
|
||||
let numeric_part = if has_suffix { &s[..s.len() - 1] } else { s };
|
||||
|
||||
// Check for negative numbers
|
||||
if numeric_part.starts_with('-') {
|
||||
return Err(anyhow!("Memory size cannot be negative: {}", s));
|
||||
}
|
||||
|
||||
// Must be a valid positive number
|
||||
if numeric_part.is_empty() {
|
||||
return Err(anyhow!("Memory size must have a numeric value: {}", s));
|
||||
}
|
||||
|
||||
// Check if it's a valid integer
|
||||
if !numeric_part.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(anyhow!(
|
||||
"Memory size must be a positive integer with optional suffix: {}",
|
||||
s
|
||||
));
|
||||
}
|
||||
|
||||
// Check suffix is valid
|
||||
if has_suffix {
|
||||
let valid_suffixes = ['K', 'M', 'G', 'T'];
|
||||
let suffix_upper = suffix.to_ascii_uppercase();
|
||||
if !valid_suffixes.contains(&suffix_upper) {
|
||||
return Err(anyhow!(
|
||||
"Invalid memory suffix '{}'. Valid suffixes: K, M, G, T",
|
||||
suffix
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_arch_from_str() {
|
||||
assert_eq!(Arch::from_str("amd64"), Arch::Amd64);
|
||||
assert_eq!(Arch::from_str("x86_64"), Arch::Amd64);
|
||||
assert_eq!(Arch::from_str("arm64"), Arch::Arm64);
|
||||
assert_eq!(Arch::from_str("aarch64"), Arch::Arm64);
|
||||
assert_eq!(Arch::from_str("armhf"), Arch::Armhf);
|
||||
assert_eq!(Arch::from_str("armv7"), Arch::Armhf);
|
||||
assert_eq!(Arch::from_str("riscv64"), Arch::Riscv64);
|
||||
assert_eq!(Arch::from_str("ppc64el"), Arch::Ppc64el);
|
||||
assert_eq!(Arch::from_str("ppc64le"), Arch::Ppc64el);
|
||||
assert_eq!(Arch::from_str("s390x"), Arch::S390x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arch_alpine_name() {
|
||||
assert_eq!(Arch::Amd64.alpine_name(), "x86_64");
|
||||
assert_eq!(Arch::Arm64.alpine_name(), "aarch64");
|
||||
assert_eq!(Arch::Armhf.alpine_name(), "armv7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arch_oci_name() {
|
||||
assert_eq!(Arch::Amd64.oci_name(), "amd64");
|
||||
assert_eq!(Arch::Arm64.oci_name(), "arm64");
|
||||
assert_eq!(Arch::Armhf.oci_name(), "arm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_memory_string_valid() {
|
||||
assert!(validate_memory_string("512M").is_ok());
|
||||
assert!(validate_memory_string("2G").is_ok());
|
||||
assert!(validate_memory_string("1024").is_ok());
|
||||
assert!(validate_memory_string("1T").is_ok());
|
||||
assert!(validate_memory_string("256K").is_ok());
|
||||
assert!(validate_memory_string("2g").is_ok()); // lowercase
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_memory_string_invalid() {
|
||||
assert!(validate_memory_string("").is_err());
|
||||
assert!(validate_memory_string("-1G").is_err());
|
||||
assert!(validate_memory_string("2X").is_err());
|
||||
assert!(validate_memory_string("abc").is_err());
|
||||
assert!(validate_memory_string("G").is_err());
|
||||
assert!(validate_memory_string("1.5G").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static VERBOSE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn set(v: bool) {
|
||||
VERBOSE.store(v, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_verbose() -> bool {
|
||||
VERBOSE.load(Ordering::Relaxed)
|
||||
}
|
||||
Reference in New Issue
Block a user