Compare commits

...
22 Commits
Author SHA1 Message Date
vhaudiquet 2c47a5c662 docs: update QEMU mode docs for =PATH syntax and uncompressed initramfs
CI / Check (push) Successful in 1m7s
CI / Format (push) Successful in 14s
CI / Clippy (push) Successful in 1m8s
CI / Test (push) Successful in 1m24s
2026-09-20 22:37:33 +02:00
vhaudiquet 240a66c532 fix: initramfs hard links, streaming archive, verbatim argv transport
- hard links in the rootfs were written as zero-size entries with no
  inode set (all zero), so the kernel could link them to the wrong file;
  assign a synthetic inode per (device, inode) group.
- build the cpio archive by streaming entries to disk instead of holding
  the whole archive in memory.
- the VM command was joined into a single string and shell-expanded in
  the guest, mangling quotes and nested commands; pass the argv
  base64-encoded (ECR_ARGV) and exec it verbatim.
- parse ecr parameters from /proc/cmdline in the init script, set the
  hostname via procfs (arch has no hostname binary), and give the serial
  console a moment to drain before poweroff.
2026-09-20 22:37:33 +02:00
vhaudiquet d64da0671b fix: --kernel =PATH syntax, exit-code cleanup, bind warning in VM mode
- --kernel took an optional value greedily, swallowing the DISTRO
  positional: 'ecr --kernel alpine' failed with a missing-argument error
  and 'ecr --kernel alpine -- cmd' pulled a garbage OCI ref. require
  =PATH syntax for the kernel path instead.
- setup_namespaces returns the child's exit code (or 128+signal) instead
  of calling process::exit, so the extracted rootfs tempdir is cleaned
  up before exiting instead of leaking into /tmp.
- warn that --bind/--bind-rw are ignored when booting with --kernel.
2026-09-20 22:37:11 +02:00
vhaudiquet ef00776414 fix: return Ok after extracting kernel from APK
extract_kernel_from_apk fell through to an unconditional error after a
successful extraction, so every first --kernel run failed after
downloading the 45MB package; the second run worked from cache. Also
remove the downloaded APK even when extraction fails.
2026-09-20 22:36:49 +02:00
vhaudiquet e1d69eaed6 fix: make kernel verbose with --kernel -v 2026-06-19 16:51:24 +02:00
vhaudiquet 3188566b6e feat: download alpine linux-virt kernel for single --kernel 2026-06-19 16:46:23 +02:00
vhaudiquet 1d2031b3ca fix: fmt 2026-06-17 17:42:56 +02:00
vhaudiquet 49343e5811 fix: clippy 2026-06-17 17:42:41 +02:00
vhaudiquet f3aec10618 test: add unit tests for namespace and chroot modules
- Add tests for hostname format and uniqueness
- Add tests for chroot environment setup
- Add tests for environment isolation and PATH configuration
- Verify all 34 tests pass
2026-06-17 17:28:50 +02:00
vhaudiquet 5834630d60 refactor: multiple fixes
- Create unified Arch enum in utils.rs with methods for all naming conventions
- Replace 5 duplicate architecture mapping functions with single source of truth
- Add memory string validation for QEMU
- Add KVM capability verification via ioctl
- Fix TOCTOU race in resolv.conf writing using O_EXCL
- Extract magic numbers to named constants
- Use constants for stack size, buffer sizes, hostname entropy
2026-06-17 17:24:41 +02:00
vhaudiquet 6bd6f2cf77 feat: enable kvm with --kernel if relevant 2026-06-17 16:42:34 +02:00
vhaudiquet 09661ec9e0 feat: use uncompressed initramfs 2026-06-17 16:35:28 +02:00
vhaudiquet 503578d648 feat: terminate when init process exits (--kernel)
Add an init script to the initramfs that traps shell exit and triggers
poweroff via /proc/sysrq-trigger. This ensures QEMU terminates cleanly
when the user exits the shell, rather than hanging indefinitely.

Changes:
- Add /init script to initramfs that runs as PID 1
- Use -no-reboot QEMU flag to exit on guest poweroff
- Simplify kernel command line using environment variables
- Init script handles hostname, device creation, and poweroff on exit
2026-06-17 14:03:04 +02:00
vhaudiquet 7a37f99030 feat: detect shell for --kernel
- Move detect_shell() to new src/utils.rs for reuse
- Use detected shell in QEMU VM mode (bash when available)
- Update chroot.rs and qemu_vm.rs to use shared function
2026-06-17 11:13:05 +02:00
vhaudiquet d52310c0f4 feat: set unique hostname in QEMU VM mode
Generate hostname like "ecr-vm-<hex>" at boot time via kernel command
line, leaving initramfs cacheable. Set via echo to /etc/hostname and
hostname command before spawning shell.
2026-06-17 10:52:00 +02:00
vhaudiquet b3ffa89faa fix: handle hard links in initramfs creation
- Track inodes to avoid duplicating data for hard-linked files
- Use fast compression level for faster initramfs creation
2026-06-17 10:40:10 +02:00
vhaudiquet 3e3af3dab8 feat: add progressbar for initramfs compression 2026-06-17 10:11:04 +02:00
vhaudiquet 4475dff141 feat: add progressbar for extraction and initramfs creation 2026-06-17 00:11:12 +02:00
vhaudiquet a81e699619 fix: enable job control for shell with --kernel 2026-06-16 23:38:09 +02:00
vhaudiquet 8875bcc92a feat: do not display kernel logs with --kernel 2026-06-16 22:07:28 +02:00
vhaudiquet 931a6dcfd5 feat: use cpio crate for initramfs creation
Add the `cpio` crate as dependency, removing e2fsprogs external dependency.
2026-06-16 20:31:25 +02:00
vhaudiquet 4f44af4449 feat: add --kernel flag for QEMU system emulation mode
Add --kernel <PATH> option to boot extracted rootfs in a QEMU virtual
machine instead of namespace/chroot mode. The rootfs is converted to an
ext4 disk image using mke2fs and booted with the provided kernel.
2026-06-16 19:16:34 +02:00
14 changed files with 1894 additions and 184 deletions
Generated
+39
View File
@@ -243,6 +243,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpio"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "938e716cb1ade5d6c8f959c13a7248b889c07491fc7e41167c3afe20f8f0de1e"
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -295,7 +301,9 @@ name = "ecr"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"clap", "clap",
"cpio",
"dirs", "dirs",
"flate2", "flate2",
"futures-util", "futures-util",
@@ -310,10 +318,17 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"users", "users",
"which",
"xz2", "xz2",
"zstd", "zstd",
] ]
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "encode_unicode" name = "encode_unicode"
version = "1.0.0" version = "1.0.0"
@@ -329,6 +344,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -1997,6 +2018,18 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "which"
version = "7.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762"
dependencies = [
"either",
"env_home",
"rustix",
"winsafe",
]
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -2197,6 +2230,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
+3
View File
@@ -35,6 +35,9 @@ anyhow = "1"
# Utilities # Utilities
dirs = "6" dirs = "6"
which = "7"
cpio = "0.4"
base64 = "0.22"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
futures-util = "0.3" futures-util = "0.3"
indicatif = "0.18" indicatif = "0.18"
+36
View File
@@ -38,6 +38,8 @@ ecr [OPTIONS] <DISTRO[:VERSION]> [-- COMMAND...]
| `--no-cache` | Force a fresh download, bypassing the cache | | `--no-cache` | Force a fresh download, bypassing the cache |
| `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) | | `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) |
| `-a, --arch <ARCH>` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) | | `-a, --arch <ARCH>` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) |
| `--kernel[=PATH]` | Boot with QEMU system emulation. Downloads Alpine's `linux-virt` kernel if no `=PATH` given |
| `-m, --memory <SIZE>` | Memory for QEMU VM (default: 2G, only with `--kernel`) |
## Examples ## Examples
@@ -56,8 +58,42 @@ ecr --arch arm64 alpine -- uname -m
# Always pull a fresh image # Always pull a fresh image
ecr --no-cache fedora ecr --no-cache fedora
# Boot with QEMU system emulation (auto-downloads default kernel)
ecr --kernel alpine
# Boot with your own kernel (note the `=` — a space would parse the path as the distro)
ecr --kernel=/boot/vmlinuz ubuntu
# Boot with custom memory
ecr --kernel --memory 4G alpine
``` ```
## QEMU System Mode
When `--kernel` is specified, ecr boots the rootfs in a full QEMU virtual machine instead of using namespaces:
```sh
# Auto-download Alpine's linux-virt kernel (recommended)
ecr --kernel alpine
# Use your own kernel
ecr --kernel=/boot/vmlinuz ubuntu
```
This mode:
- Creates an uncompressed CPIO initramfs from the rootfs (streamed to disk)
- Boots QEMU with your kernel (or auto-downloads Alpine's `linux-virt` kernel)
- Provides full VM isolation
- Works for any architecture (no binfmt_misc needed)
- Caches the default kernel in `~/.cache/ecr/`
Host bind mounts (`--bind`, `--bind-rw`) are not applied in this mode.
Requirements:
- `qemu-system-<arch>` installed
- For custom kernels: kernel must have serial console support
## Supported distributions ## Supported distributions
| Name | Source | Version examples | | Name | Source | Version examples |
+65
View File
@@ -22,6 +22,9 @@ ecr [OPTIONS] <DISTRO[:VERSION]> -- [COMMAND]...
| `--bind-rw <path>` | none | Read-write bind mount at `/mnt/<basename>` (can be specified multiple times, overrides `--bind` for same path) | | `--bind-rw <path>` | none | Read-write bind mount at `/mnt/<basename>` (can be specified multiple times, overrides `--bind` for same path) |
| `--no-cache` | false | Download fresh tarball, ignore cache | | `--no-cache` | false | Download fresh tarball, ignore cache |
| `--no-bind` | false | Skip mounting any directory | | `--no-bind` | false | Skip mounting any directory |
| `--kernel[=PATH]` | none | Boot with QEMU system emulation; downloads the default Alpine `linux-virt` kernel when no `=PATH` is given |
| `-m, --memory <size>` | 2G | Memory size for QEMU VM (only used with `--kernel`) |
| `-v, --verbose` | false | Print diagnostic messages |
| `-h, --help` | - | Show help | | `-h, --help` | - | Show help |
| `-V, --version` | - | Show version | | `-V, --version` | - | Show version |
@@ -182,6 +185,68 @@ Install QEMU user emulation:
No action required. Modern qemu-user-static packages register binfmt_misc with the `F` (fix binary) flag, loading the interpreter into kernel memory. The kernel handles foreign binary execution transparently. No action required. Modern qemu-user-static packages register binfmt_misc with the `F` (fix binary) flag, loading the interpreter into kernel memory. The kernel handles foreign binary execution transparently.
## QEMU System Emulation Mode
When `--kernel` is specified, ecr switches from namespace/chroot mode to QEMU system emulation. The extracted rootfs is converted to an uncompressed CPIO initramfs and booted with the provided kernel.
### Usage
The kernel path uses `=` syntax (`--kernel=PATH`); `--kernel` without a value downloads the default kernel. Without `=`, a following path would be parsed as the DISTRO argument.
```sh
ecr --kernel ubuntu:noble
ecr --kernel=/boot/vmlinuz ubuntu:noble
ecr --kernel=/boot/vmlinuz --memory 4G alpine
ecr --kernel=/boot/vmlinuz debian -- /bin/sh -c "echo hello"
```
### Execution Flow
1. Download/cache rootfs tarball (same as namespace mode)
2. Extract tarball to temporary directory
3. Create uncompressed CPIO initramfs from rootfs (streamed to disk), including essential device nodes (/dev/ttyS0, /dev/null, /dev/tty) and an `/init` script that mounts proc/sys/dev, sets the hostname, execs the requested command argv verbatim (each argv element base64-encoded in the cmdline as `ECR_ARGV`), and powers off on exit
4. Launch QEMU with:
- `-kernel <path>` - provided (or downloaded) kernel
- `-initrd initramfs.cpio` - rootfs as initramfs
- `-append "console=ttyS0 [quiet] ECR_SHELL=... [ECR_ARGV=...] ECR_HOSTNAME=..."` - kernel command line (`quiet` unless `-v`)
- `-m <memory>` - memory size (default 2G)
- `-display none -serial mon:stdio` - console on stdio
- `-netdev user,id=net0 -device virtio-net-pci,netdev=net0` - network NIC
- `-enable-kvm -cpu host` - when the host supports KVM and the target matches the host architecture
5. Wait for QEMU to exit (init powers the VM off when the command/shell exits; `-no-reboot` makes QEMU terminate)
6. Cleanup temporary files
### Initramfs Creation
The rootfs directory is converted to an uncompressed CPIO archive (newc format) using the `cpio` crate, streamed entry by entry so large rootfs images never need to fit in memory. Hard links are preserved: the first occurrence of a (device, inode) pair carries the data with a synthetic inode, subsequent occurrences are zero-size entries sharing that inode, which the kernel's initramfs loader turns into real hard links.
### Architecture Support
| ecr Arch | QEMU System Binary |
|----------|-------------------|
| amd64/x86_64 | qemu-system-x86_64 |
| arm64/aarch64 | qemu-system-aarch64 |
| armhf/armv7 | qemu-system-arm |
| riscv64 | qemu-system-riscv64 |
| ppc64el | qemu-system-ppc64 |
| s390x | qemu-system-s390x |
### Requirements
- QEMU system emulator installed (`qemu-system-<arch>`)
- Kernel with required drivers (serial console, virtio-net for network)
### Differences from Namespace Mode
| Feature | Namespace Mode | QEMU Mode |
|---------|---------------|-----------|
| Isolation | User namespace | Full VM |
| Performance | Near-native | Emulated (KVM-accelerated when available) |
| Root access | No | Yes (inside the VM) |
| Foreign arch | binfmt_misc required | Built-in emulation |
| Bind mounts | Overlay/bind | Not supported (flags are ignored with a warning) |
| Network | Host network | User-mode NIC (not configured inside the guest) |
## File Handling ## File Handling
### Overlay Mount (Default) ### Overlay Mount (Default)
+57 -12
View File
@@ -18,22 +18,13 @@ pub fn run_chroot(
eprintln!("Warning: Failed to set hostname: {}", e); 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 // Change to root directory in chroot
chroot(rootfs).context("Failed to chroot")?; chroot(rootfs).context("Failed to chroot")?;
// Now we're inside the chroot - set up environment based on chroot filesystem // Now we're inside the chroot - set up environment based on chroot filesystem
// Determine shell path (check inside chroot, not host)
let shell = if Path::new("/bin/bash").exists() {
"/bin/bash"
} else if Path::new("/bin/sh").exists() {
"/bin/sh"
} else if Path::new("/usr/bin/bash").exists() {
"/usr/bin/bash"
} else if Path::new("/usr/bin/sh").exists() {
"/usr/bin/sh"
} else {
"/bin/sh" // Will fail with clear error if not present
};
// Set up environment variables (after chroot, so paths are correct) // Set up environment variables (after chroot, so paths are correct)
let env = setup_environment(shell, &host_term); let env = setup_environment(shell, &host_term);
@@ -142,3 +133,57 @@ fn setup_environment(shell: &str, term: &str) -> HashMap<&'static str, String> {
env 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"));
}
}
+20
View File
@@ -33,6 +33,26 @@ pub struct Args {
#[arg(short = 'v', long)] #[arg(short = 'v', long)]
pub verbose: bool, 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) /// Command to run inside the chroot (default: interactive shell)
#[arg( #[arg(
trailing_var_arg = true, trailing_var_arg = true,
+6 -29
View File
@@ -144,39 +144,16 @@ fn parse_oci_ref(input: &str, arch: &str) -> Result<ImageSource> {
/// Map architecture to OCI standard names /// Map architecture to OCI standard names
pub fn map_oci_arch(arch: &str) -> String { pub fn map_oci_arch(arch: &str) -> String {
match arch { crate::utils::map_oci_arch(arch)
"amd64" | "x86_64" => "amd64".to_string(),
"arm64" | "aarch64" => "arm64".to_string(),
"armhf" | "armv7" => "arm".to_string(),
"riscv64" => "riscv64".to_string(),
"ppc64el" | "ppc64le" => "ppc64le".to_string(),
"s390x" => "s390x".to_string(),
_ => arch.to_string(),
}
} }
/// Map ecr architecture names to distro-specific names /// Map ecr architecture names to distro-specific names
pub fn map_arch(distro: Distro, arch: &str) -> String { pub fn map_arch(distro: Distro, arch: &str) -> String {
match distro { let distro_name = match distro {
Distro::Ubuntu => match arch { Distro::Ubuntu => "ubuntu",
"amd64" => "amd64".to_string(), Distro::Alpine => "alpine",
"arm64" => "arm64".to_string(), };
"armhf" => "armhf".to_string(), crate::utils::map_arch_for_distro(distro_name, arch)
"riscv64" => "riscv64".to_string(),
"ppc64el" => "ppc64el".to_string(),
"s390x" => "s390x".to_string(),
_ => arch.to_string(),
},
Distro::Alpine => match arch {
"amd64" => "x86_64".to_string(),
"arm64" => "aarch64".to_string(),
"armhf" => "armv7".to_string(),
"riscv64" => "riscv64".to_string(),
"ppc64el" => "ppc64le".to_string(),
"s390x" => "s390x".to_string(),
_ => arch.to_string(),
},
}
} }
/// Resolve the download URL for a known distro (optimized path) /// Resolve the download URL for a known distro (optimized path)
+81 -86
View File
@@ -1,5 +1,6 @@
use crate::veprintln; use crate::veprintln;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use std::fs::File; use std::fs::File;
use std::io::{BufReader, Read}; use std::io::{BufReader, Read};
use std::path::Path; use std::path::Path;
@@ -130,16 +131,22 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
let reader = BufReader::new(file); let reader = BufReader::new(file);
if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") { if layer_name.ends_with(".tar.gz") || layer_name.ends_with(".tgz") {
extract_archive_with_whiteouts( extract_with_progress(
tar::Archive::new(flate2::read::GzDecoder::new(reader)), tar::Archive::new(flate2::read::GzDecoder::new(reader)),
dest, dest,
"Extracting OCI layer",
) )
} else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") { } else if layer_name.ends_with(".tar.xz") || layer_name.ends_with(".txz") {
extract_archive_with_whiteouts(tar::Archive::new(xz2::read::XzDecoder::new(reader)), dest) 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") { } else if layer_name.ends_with(".tar.zst") || layer_name.ends_with(".tar.zstd") {
extract_archive_with_whiteouts( extract_with_progress(
tar::Archive::new(zstd::stream::read::Decoder::new(reader)?), tar::Archive::new(zstd::stream::read::Decoder::new(reader)?),
dest, dest,
"Extracting OCI layer",
) )
} else { } else {
// Fall back to magic-byte detection // Fall back to magic-byte detection
@@ -148,53 +155,97 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
let _ = peek.read_exact(&mut magic); // short reads are fine for detection let _ = peek.read_exact(&mut magic); // short reads are fine for detection
drop(peek); drop(peek);
match magic { match magic {
[0x1f, 0x8b, ..] => extract_archive_with_whiteouts( [0x1f, 0x8b, ..] => extract_with_progress(
tar::Archive::new(flate2::read::GzDecoder::new(BufReader::new(File::open( tar::Archive::new(flate2::read::GzDecoder::new(BufReader::new(File::open(
layer_path, layer_path,
)?))), )?))),
dest, dest,
"Extracting OCI layer",
), ),
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_archive_with_whiteouts( [0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_with_progress(
tar::Archive::new(xz2::read::XzDecoder::new(BufReader::new(File::open( tar::Archive::new(xz2::read::XzDecoder::new(BufReader::new(File::open(
layer_path, layer_path,
)?))), )?))),
dest, dest,
"Extracting OCI layer",
), ),
[0x28, 0xb5, 0x2f, 0xfd, ..] => extract_archive_with_whiteouts( [0x28, 0xb5, 0x2f, 0xfd, ..] => extract_with_progress(
tar::Archive::new(zstd::stream::read::Decoder::new(BufReader::new( tar::Archive::new(zstd::stream::read::Decoder::new(BufReader::new(
File::open(layer_path)?, File::open(layer_path)?,
))?), ))?),
dest, dest,
"Extracting OCI layer",
), ),
_ => extract_archive_with_whiteouts( _ => extract_with_progress(
tar::Archive::new(BufReader::new(File::open(layer_path)?)), tar::Archive::new(BufReader::new(File::open(layer_path)?)),
dest, dest,
"Extracting OCI layer",
), ),
} }
} }
} }
/// Apply one OCI layer archive to `dest`, interpreting Docker whiteout markers: fn extract_gz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
/// let gz_decoder = flate2::read::GzDecoder::new(reader);
/// - `.wh.<name>` — Delete `<name>` from a lower layer that was already let archive = tar::Archive::new(gz_decoder);
/// extracted into `dest`.
/// - `.wh..wh..opq` — Opaque whiteout: the directory that contains this entry extract_with_progress(archive, dest, "Extracting gzip archive")?;
/// is new in this layer; delete everything already in that
/// directory from lower layers before applying new content. Ok(())
/// }
/// All other entries are extracted normally via `Entry::unpack_in`.
fn extract_archive_with_whiteouts<R: std::io::Read>( 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>, mut archive: tar::Archive<R>,
dest: &Path, dest: &Path,
msg: &str,
) -> Result<()> { ) -> 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_permissions(true);
archive.set_preserve_ownerships(false); archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false); archive.set_unpack_xattrs(false);
for entry in archive.entries().context("Failed to iterate tar entries")? { let entries = archive
let mut entry = entry.context("Failed to read tar entry")?; .entries()
.context("Failed to read archive entries")?;
// Clone the path before any mutable borrow of entry (needed for unpack_in) 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 path = entry.path().context("Invalid tar entry path")?.into_owned();
let filename = path let filename = path
@@ -202,9 +253,9 @@ fn extract_archive_with_whiteouts<R: std::io::Read>(
.map(|n| n.to_string_lossy().into_owned()) .map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(); .unwrap_or_default();
// Handle whiteout files (OCI layer markers for deletions)
if filename == ".wh..wh..opq" { if filename == ".wh..wh..opq" {
// Opaque whiteout: clear all previously-extracted content in the // Opaque whiteout: clear all previously-extracted content in the parent directory
// parent directory so only this layer's content is visible.
let parent = path.parent().unwrap_or(Path::new("")); let parent = path.parent().unwrap_or(Path::new(""));
let dest_dir = dest.join(parent); let dest_dir = dest.join(parent);
if dest_dir.symlink_metadata().is_ok() { if dest_dir.symlink_metadata().is_ok() {
@@ -218,24 +269,27 @@ fn extract_archive_with_whiteouts<R: std::io::Read>(
})?; })?;
} }
} }
// Do not extract the .wh..wh..opq marker itself. // Do not extract the .wh..wh..opq marker itself
} else if let Some(real_name) = filename.strip_prefix(".wh.") { } else if let Some(real_name) = filename.strip_prefix(".wh.") {
// Regular whiteout: delete the named path from lower layers. // Regular whiteout: delete the named path from lower layers
let parent = path.parent().unwrap_or(Path::new("")); let parent = path.parent().unwrap_or(Path::new(""));
let target = dest.join(parent).join(real_name); let target = dest.join(parent).join(real_name);
// symlink_metadata (lstat) does not follow symlinks, so a dangling
// symlink is correctly detected and removed rather than silently skipped.
if target.symlink_metadata().is_ok() { if target.symlink_metadata().is_ok() {
remove_path(&target) remove_path(&target)
.with_context(|| format!("Whiteout: failed to remove {}", target.display()))?; .with_context(|| format!("Whiteout: failed to remove {}", target.display()))?;
} }
// Do not extract the .wh.* marker itself. // Do not extract the .wh.* marker itself
} else { } else {
entry entry
.unpack_in(dest) .unpack_in(dest)
.with_context(|| format!("Failed to extract {}", path.display()))?; .with_context(|| format!("Failed to extract {}", path.display()))?;
} }
pb.inc(1);
} }
pb.finish_and_clear();
veprintln!("Extracted {} files", pb.position());
Ok(()) Ok(())
} }
@@ -251,62 +305,3 @@ fn remove_path(path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path) std::fs::remove_file(path)
} }
} }
fn extract_gz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let gz_decoder = flate2::read::GzDecoder::new(reader);
let mut archive = tar::Archive::new(gz_decoder);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
archive
.unpack(dest)
.context("Failed to extract gzip archive")?;
Ok(())
}
fn extract_xz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let xz_decoder = xz2::read::XzDecoder::new(reader);
let mut archive = tar::Archive::new(xz_decoder);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
archive
.unpack(dest)
.context("Failed to extract xz archive")?;
Ok(())
}
fn extract_zst<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let zst_decoder = zstd::Decoder::new(reader)?;
let mut archive = tar::Archive::new(zst_decoder);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
archive
.unpack(dest)
.context("Failed to extract zstd archive")?;
Ok(())
}
fn extract_tar<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
let mut archive = tar::Archive::new(reader);
archive.set_preserve_permissions(true);
archive.set_preserve_ownerships(false);
archive.set_unpack_xattrs(false);
archive
.unpack(dest)
.context("Failed to extract tar archive")?;
Ok(())
}
+368
View File
@@ -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");
}
}
+103 -32
View File
@@ -4,9 +4,12 @@ mod config;
mod distro; mod distro;
mod download; mod download;
mod extract; mod extract;
mod kernel;
mod mount; mod mount;
mod namespace; mod namespace;
mod qemu; mod qemu;
mod qemu_vm;
mod utils;
mod verbose; mod verbose;
/// Print to stderr only when --verbose / -v is active. /// Print to stderr only when --verbose / -v is active.
@@ -19,7 +22,7 @@ macro_rules! veprintln {
}; };
} }
use anyhow::Result; use anyhow::{Context, Result};
use clap::Parser; use clap::Parser;
use cli::Args; use cli::Args;
@@ -113,11 +116,81 @@ fn main() -> Result<()> {
veprintln!("Using cached tarball: {}", cache_path.display()); veprintln!("Using cached tarball: {}", cache_path.display());
} }
// Check QEMU if foreign architecture // Check QEMU if foreign architecture (for namespace mode)
if arch != host_arch { // 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)?; 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 // Check user namespace availability
namespace::check_user_namespace()?; namespace::check_user_namespace()?;
@@ -140,13 +213,6 @@ fn main() -> Result<()> {
} }
let bind_rw_paths: Vec<std::path::PathBuf> = args.bind_rw.clone(); let bind_rw_paths: Vec<std::path::PathBuf> = args.bind_rw.clone();
// 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)?;
// Prepare data for the closure // Prepare data for the closure
let bind_paths_clone = bind_paths.clone(); let bind_paths_clone = bind_paths.clone();
let bind_rw_paths_clone = bind_rw_paths.clone(); let bind_rw_paths_clone = bind_rw_paths.clone();
@@ -243,32 +309,19 @@ fn get_tarball_extension(url: &str) -> &str {
} }
fn get_host_arch() -> String { fn get_host_arch() -> String {
// Use the uname(2) syscall directly — no subprocess, no PATH dependency, // Use the consolidated architecture detection from utils
// no panic-on-missing-binary. This gives the runtime machine string utils::get_host_arch().debian_name().to_string()
// (e.g. "x86_64", "aarch64") exactly as `uname -m` would, which is what
// we need for the QEMU check. std::env::consts::ARCH is compile-time and
// would be wrong if the binary itself is running under emulation.
let utsname = nix::sys::utsname::uname()
.expect("uname(2) syscall failed — cannot determine host architecture");
let machine = utsname.machine().to_string_lossy();
match machine.as_ref() {
"x86_64" => "amd64".to_string(),
"aarch64" => "arm64".to_string(),
"armv7l" | "armv7" => "armhf".to_string(),
"riscv64" => "riscv64".to_string(),
"ppc64le" => "ppc64el".to_string(),
"s390x" => "s390x".to_string(),
other => other.to_string(),
}
} }
fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> { fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> {
use std::io::Write;
let resolv_conf = rootfs.join("etc/resolv.conf"); let resolv_conf = rootfs.join("etc/resolv.conf");
// Create /etc if it doesn't exist // Create /etc if it doesn't exist
if let Some(parent) = resolv_conf.parent() { if let Some(parent) = resolv_conf.parent() {
std::fs::create_dir_all(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 // Copy host's resolv.conf if dns is empty, otherwise use provided DNS
@@ -288,13 +341,31 @@ fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> {
c c
}; };
// Remove any existing file or symlink before writing so that std::fs::write // Remove any existing file or symlink before writing so that we always
// always creates a plain file. Without this, an absolute symlink such as // create a plain file. Without this, an absolute symlink such as
// /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf would cause the // /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 // write to follow the symlink through the *host* root (chroot() has not been
// called yet) and corrupt the host's DNS configuration. // 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 let _ = std::fs::remove_file(&resolv_conf); // ignore ENOENT
std::fs::write(&resolv_conf, content)?;
// 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(()) Ok(())
} }
+104 -11
View File
@@ -58,8 +58,10 @@ pub fn check_user_namespace() -> Result<()> {
Ok(()) Ok(())
} }
/// Setup namespaces and run the provided function inside them /// Setup namespaces and run the provided function inside them.
pub fn setup_namespaces<F>(f: F) -> Result<()> /// 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 where
F: FnOnce() -> Result<()> + Send + 'static, F: FnOnce() -> Result<()> + Send + 'static,
{ {
@@ -105,7 +107,7 @@ where
} }
// Stack for the child process // Stack for the child process
let stack_size = 1024 * 1024; let stack_size = crate::utils::CHILD_STACK_SIZE;
let mut stack = vec![0u8; stack_size]; let mut stack = vec![0u8; stack_size];
// Wrap f in Option to allow taking it once inside the child closure // Wrap f in Option to allow taking it once inside the child closure
@@ -198,7 +200,7 @@ where
// O_CLOEXEC (on successful exec), so this read always terminates. // O_CLOEXEC (on successful exec), so this read always terminates.
let child_error: Option<String> = unsafe { let child_error: Option<String> = unsafe {
let mut error_bytes = Vec::new(); let mut error_bytes = Vec::new();
let mut tmp = [0u8; 4096]; let mut tmp = [0u8; crate::utils::ERROR_BUFFER_SIZE];
loop { loop {
let n = libc::read( let n = libc::read(
error_read.raw(), error_read.raw(),
@@ -222,20 +224,18 @@ where
let status = nix::sys::wait::waitpid(pid, None)?; let status = nix::sys::wait::waitpid(pid, None)?;
match status { match status {
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(()), nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0),
nix::sys::wait::WaitStatus::Exited(_, code) => { nix::sys::wait::WaitStatus::Exited(_, code) => {
// If the child reported an error (e.g., setup failure), return it. // If the child reported an error (e.g., setup failure), return it.
// Otherwise, just forward the exit code without an error message. // Otherwise, just forward the exit code without an error message.
if let Some(msg) = child_error { if let Some(msg) = child_error {
Err(anyhow!("{}", msg)) Err(anyhow!("{}", msg))
} else { } else {
std::process::exit(code); Ok(code)
} }
} }
nix::sys::wait::WaitStatus::Signaled(_, sig, _) => { nix::sys::wait::WaitStatus::Signaled(_, sig, _) => Ok(128 + sig as i32),
Err(anyhow!("Child process killed by signal {:?}", sig)) _ => Ok(0),
}
_ => Ok(()),
} }
} }
@@ -397,7 +397,9 @@ pub fn set_hostname(distro: &str) -> Result<()> {
let mut state = hasher.finish(); let mut state = hasher.finish();
let chars = b"abcdefghijklmnopqrstuvwxyz0123456789"; let chars = b"abcdefghijklmnopqrstuvwxyz0123456789";
let random_suffix: String = (0..6) // 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(|_| { .map(|_| {
// Knuth multiplicative LCG — each step advances the full 64-bit state. // Knuth multiplicative LCG — each step advances the full 64-bit state.
state = state state = state
@@ -413,3 +415,94 @@ pub fn set_hostname(distro: &str) -> Result<()> {
Ok(()) 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");
}
}
+1 -14
View File
@@ -4,7 +4,7 @@ use std::path::Path;
/// Check if binfmt_misc is registered for the target architecture /// Check if binfmt_misc is registered for the target architecture
pub fn check_binfmt(arch: &str) -> Result<()> { pub fn check_binfmt(arch: &str) -> Result<()> {
let qemu_arch = map_arch_to_qemu(arch); let qemu_arch = crate::utils::Arch::from_str(arch).qemu_binfmt_name();
let binfmt_path = format!("/proc/sys/fs/binfmt_misc/qemu-{}", qemu_arch); let binfmt_path = format!("/proc/sys/fs/binfmt_misc/qemu-{}", qemu_arch);
@@ -22,16 +22,3 @@ pub fn check_binfmt(arch: &str) -> Result<()> {
veprintln!("QEMU binfmt_misc registered for {}", arch); veprintln!("QEMU binfmt_misc registered for {}", arch);
Ok(()) Ok(())
} }
/// Map ecr architecture names to QEMU binary names
fn map_arch_to_qemu(arch: &str) -> &str {
match arch {
"amd64" | "x86_64" => "x86_64",
"arm64" | "aarch64" => "aarch64",
"armhf" | "armv7" => "arm",
"riscv64" => "riscv64",
"ppc64el" | "ppc64le" => "ppc64le",
"s390x" => "s390x",
_ => arch,
}
}
+726
View File
@@ -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"));
}
}
+285
View File
@@ -0,0 +1,285 @@
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)
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());
}
}