Compare commits

18 Commits

Author SHA1 Message Date
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 1662 additions and 176 deletions
Generated
+38
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"
@@ -296,6 +302,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
"cpio",
"dirs", "dirs",
"flate2", "flate2",
"futures-util", "futures-util",
@@ -310,10 +317,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 +343,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 +2017,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 +2229,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"
+2
View File
@@ -35,6 +35,8 @@ anyhow = "1"
# Utilities # Utilities
dirs = "6" dirs = "6"
which = "7"
cpio = "0.4"
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"
+34
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 provided |
| `-m, --memory <SIZE>` | Memory for QEMU VM (default: 2G, only with `--kernel`) |
## Examples ## Examples
@@ -56,8 +58,40 @@ 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
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 a gzipped CPIO initramfs from the rootfs
- 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/`
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 |
+62
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 using specified kernel (triggers disk image creation) |
| `-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,65 @@ 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 a gzipped CPIO initramfs and booted with the provided kernel.
### Usage
```sh
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 gzipped CPIO initramfs from rootfs
4. Launch QEMU with:
- `-kernel <path>` - provided kernel
- `-initrd initramfs.cpio.gz` - rootfs as initramfs
- `-append "console=ttyS0 quiet rdinit=/bin/sh -- -c \"setsid sh -c 'exec sh </dev/ttyS0 >/dev/ttyS0 2>&1'\""` - kernel command line
7. Essential device nodes (/dev/ttyS0, /dev/null, /dev/tty) are added to initramfs for proper console support
- `-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
5. Wait for QEMU to exit
6. Cleanup temporary files
### Initramfs Creation
The rootfs directory is converted to a gzipped CPIO archive (newc format) using the `cpio` crate.
### 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 (slower) |
| Root access | No | No |
| Foreign arch | binfmt_misc required | Built-in emulation |
| Bind mounts | Overlay/bind | Not supported |
| Network | Host network | User-mode network |
## 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"));
}
}
+12
View File
@@ -33,6 +33,18 @@ 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, or omit to download default)
///
/// Examples:
/// --kernel Download and use the default Alpine linux-virt kernel
/// --kernel ./vmlinuz Use a specific kernel file
#[arg(long, value_name = "KERNEL_PATH", num_args = 0..=1)]
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");
extract_kernel_from_apk(&temp_apk, dest)?;
// Clean up the APK
std::fs::remove_file(&temp_apk).ok();
Ok(())
}
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());
}
}
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");
}
}
+87 -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,65 @@ 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>>:
// 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 {
// 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
namespace_mode(args, rootfs, config)
}
}
/// Run in namespace/chroot mode
fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Result<()> {
// Check user namespace availability // Check user namespace availability
namespace::check_user_namespace()?; namespace::check_user_namespace()?;
@@ -140,13 +197,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 +293,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 +325,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(())
} }
+96 -3
View File
@@ -105,7 +105,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 +198,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(),
@@ -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,
}
}
+533
View File
@@ -0,0 +1,533 @@
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 {
let cmd_str = cmd.join(" ");
format!(
"console=ttyS0{} ECR_SHELL={} ECR_CMD=\"{}\" ECR_HOSTNAME={}",
quiet_flag, shell, cmd_str, 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
/// A filesystem entry for cpio archives: (name, mode, mtime, nlink, data)
type CpioEntry = (String, u32, u32, u32, Vec<u8>);
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...");
// Create the cpio archive with progress
let cpio_data = create_cpio_archive(rootfs, &pb)?;
let total_bytes = cpio_data.len() as u64;
let file_count = pb.position();
// Write directly to file (no compression)
std::fs::write(&initramfs_path, &cpio_data).context("Failed to write initramfs file")?;
// Finish progress bar
pb.finish_and_clear();
veprintln!(
"Initramfs created: {} bytes, {} files",
total_bytes,
file_count
);
Ok(initramfs_path)
}
/// Create a newc-format cpio archive from a directory using the cpio crate
fn create_cpio_archive(rootfs: &Path, pb: &ProgressBar) -> Result<Vec<u8>> {
let mut archive = Vec::new();
// Track seen inodes to handle hard links properly
let mut seen_inodes = std::collections::HashMap::new();
// Collect all entries with their data
let entries = collect_entries(rootfs, rootfs, pb, &mut seen_inodes)?;
// Collect entry names for checking existence later
let entry_names: Vec<&str> = entries.iter().map(|(n, _, _, _, _)| n.as_str()).collect();
pb.set_message("Writing initramfs...");
// Write each entry using the cpio crate
for (name, mode, mtime, nlink, data) in &entries {
let file_size = data.len() as u32;
let builder = NewcBuilder::new(name)
.mode(*mode)
.uid(0)
.gid(0)
.nlink(*nlink)
.mtime(*mtime);
// Write header and get a writer
let mut writer = builder.write(&mut archive, file_size);
// Write the file content
writer
.write_all(data)
.context("Failed to write file content to cpio archive")?;
// Finish this entry (returns the underlying writer)
writer.finish().context("Failed to finish cpio entry")?;
}
// 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 entry_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 writer = builder.write(&mut archive, 0);
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
# Set hostname from kernel cmdline
if [ -n "$ECR_HOSTNAME" ]; then
echo "$ECR_HOSTNAME" > /etc/hostname
hostname "$ECR_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() {
# 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
# Run the shell or command
if [ -n "$ECR_CMD" ]; then
# Run command with the specified shell
setsid sh -c "exec $ECR_SHELL </dev/ttyS0 >/dev/ttyS0 2>&1 -c '$ECR_CMD'"
else
# Run interactive shell
setsid sh -c "exec $ECR_SHELL </dev/ttyS0 >/dev/ttyS0 2>&1"
fi
"#;
let init_data = init_script.as_bytes();
let init_builder = NewcBuilder::new("init")
.mode(0o100755) // executable
.uid(0)
.gid(0)
.nlink(1)
.mtime(0);
let mut init_writer = init_builder.write(&mut archive, init_data.len() as u32);
init_writer
.write_all(init_data)
.context("Failed to write init script to cpio archive")?;
init_writer
.finish()
.context("Failed to finish init script entry")?;
// Write the trailer (takes ownership and returns the writer)
archive = newc::trailer(archive).context("Failed to write cpio trailer")?;
Ok(archive)
}
/// Collect all filesystem entries recursively
/// Uses a HashMap to track hard links by (device, inode) - only stores data for first occurrence
fn collect_entries(
base: &Path,
current: &Path,
pb: &ProgressBar,
seen_inodes: &mut std::collections::HashMap<(u64, u64), String>,
) -> Result<Vec<CpioEntry>> {
let mut entries = Vec::new();
let mut total_data: u64 = 0;
// 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(entries);
}
};
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();
// Handle hard links: only store data for first occurrence
let (data, nlink) = if file_type.is_file() && metadata.nlink() > 1 {
// This file has multiple hard links - check if we've seen it before
let inode_key = (metadata.dev(), metadata.ino());
if let Some(_first_path) = seen_inodes.get(&inode_key) {
// We've seen this inode before - create a hard link entry with no data
(Vec::new(), metadata.nlink() as u32)
} else {
// First occurrence - read the data and record this inode
seen_inodes.insert(inode_key, entry_name.clone());
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)
}
} 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)
} else if file_type.is_symlink() {
match std::fs::read_link(&path) {
Ok(target) => (target.to_string_lossy().into_owned().into_bytes(), 1),
Err(e) => {
veprintln!("Warning: cannot read symlink {}: {}", path.display(), e);
continue;
}
}
} else {
// Directory
(Vec::new(), 2)
};
total_data += data.len() as u64;
entries.push((entry_name, mode, metadata.mtime() as u32, nlink, data));
// Recurse into directories
if file_type.is_dir() {
let mut sub_entries = collect_entries(base, &path, pb, seen_inodes)?;
for (_, _, _, _, d) in &sub_entries {
total_data += d.len() as u64;
}
entries.append(&mut sub_entries);
}
}
// Only print summary for the root directory
if current == base {
veprintln!(
"Collected {} entries, {} bytes total data",
entries.len(),
total_data
);
}
Ok(entries)
}
+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());
}
}