Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1d69eaed6 | |||
| 3188566b6e | |||
| 1d2031b3ca | |||
| 49343e5811 | |||
| f3aec10618 | |||
| 5834630d60 | |||
| 6bd6f2cf77 | |||
| 09661ec9e0 | |||
| 503578d648 | |||
| 7a37f99030 | |||
| d52310c0f4 | |||
| b3ffa89faa | |||
| 3e3af3dab8 | |||
| 4475dff141 | |||
| a81e699619 | |||
| 8875bcc92a | |||
| 931a6dcfd5 | |||
| 4f44af4449 |
Generated
+38
@@ -243,6 +243,12 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpio"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "938e716cb1ade5d6c8f959c13a7248b889c07491fc7e41167c3afe20f8f0de1e"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -296,6 +302,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"cpio",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
@@ -310,10 +317,17 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"users",
|
||||
"which",
|
||||
"xz2",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
@@ -329,6 +343,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_home"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1997,6 +2017,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -2197,6 +2229,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winsafe"
|
||||
version = "0.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -35,6 +35,8 @@ anyhow = "1"
|
||||
|
||||
# Utilities
|
||||
dirs = "6"
|
||||
which = "7"
|
||||
cpio = "0.4"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
|
||||
futures-util = "0.3"
|
||||
indicatif = "0.18"
|
||||
|
||||
@@ -38,6 +38,8 @@ ecr [OPTIONS] <DISTRO[:VERSION]> [-- COMMAND...]
|
||||
| `--no-cache` | Force a fresh download, bypassing the cache |
|
||||
| `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) |
|
||||
| `-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
|
||||
|
||||
@@ -56,8 +58,40 @@ ecr --arch arm64 alpine -- uname -m
|
||||
|
||||
# Always pull a fresh image
|
||||
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
|
||||
|
||||
| Name | Source | Version examples |
|
||||
|
||||
@@ -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) |
|
||||
| `--no-cache` | false | Download fresh tarball, ignore cache |
|
||||
| `--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 |
|
||||
| `-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.
|
||||
|
||||
## 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
|
||||
|
||||
### Overlay Mount (Default)
|
||||
|
||||
+57
-12
@@ -18,22 +18,13 @@ pub fn run_chroot(
|
||||
eprintln!("Warning: Failed to set hostname: {}", e);
|
||||
}
|
||||
|
||||
// Detect shell before chroot (we're still outside)
|
||||
let shell = crate::utils::detect_shell(rootfs);
|
||||
|
||||
// Change to root directory in chroot
|
||||
chroot(rootfs).context("Failed to chroot")?;
|
||||
|
||||
// Now we're inside the chroot - set up environment based on chroot filesystem
|
||||
// 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)
|
||||
let env = setup_environment(shell, &host_term);
|
||||
@@ -142,3 +133,57 @@ fn setup_environment(shell: &str, term: &str) -> HashMap<&'static str, String> {
|
||||
|
||||
env
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_setup_environment_defaults() {
|
||||
let env = setup_environment("/bin/bash", "xterm-256color");
|
||||
|
||||
assert_eq!(env.get("HOME"), Some(&"/root".to_string()));
|
||||
assert_eq!(env.get("USER"), Some(&"root".to_string()));
|
||||
assert_eq!(env.get("SHELL"), Some(&"/bin/bash".to_string()));
|
||||
assert_eq!(env.get("TERM"), Some(&"xterm-256color".to_string()));
|
||||
assert_eq!(
|
||||
env.get("PATH"),
|
||||
Some(&"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_setup_environment_custom_shell() {
|
||||
let env = setup_environment("/usr/bin/zsh", "screen");
|
||||
|
||||
assert_eq!(env.get("SHELL"), Some(&"/usr/bin/zsh".to_string()));
|
||||
assert_eq!(env.get("TERM"), Some(&"screen".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_environment_isolation() {
|
||||
// Verify that setup_environment creates a clean environment
|
||||
// without inheriting from the host
|
||||
let env = setup_environment("/bin/sh", "dumb");
|
||||
|
||||
// Should have exactly 5 environment variables
|
||||
assert_eq!(env.len(), 5);
|
||||
|
||||
// Should NOT have any host-specific variables
|
||||
assert!(!env.contains_key("LANG"));
|
||||
assert!(!env.contains_key("DISPLAY"));
|
||||
assert!(!env.contains_key("PWD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_contains_standard_directories() {
|
||||
let env = setup_environment("/bin/bash", "xterm");
|
||||
let path = env.get("PATH").expect("PATH should be set");
|
||||
|
||||
// Verify essential directories are in PATH
|
||||
assert!(path.contains("/bin"));
|
||||
assert!(path.contains("/usr/bin"));
|
||||
assert!(path.contains("/sbin"));
|
||||
assert!(path.contains("/usr/sbin"));
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -33,6 +33,18 @@ pub struct Args {
|
||||
#[arg(short = 'v', long)]
|
||||
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)
|
||||
#[arg(
|
||||
trailing_var_arg = true,
|
||||
|
||||
+6
-29
@@ -144,39 +144,16 @@ fn parse_oci_ref(input: &str, arch: &str) -> Result<ImageSource> {
|
||||
|
||||
/// Map architecture to OCI standard names
|
||||
pub fn map_oci_arch(arch: &str) -> String {
|
||||
match 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(),
|
||||
}
|
||||
crate::utils::map_oci_arch(arch)
|
||||
}
|
||||
|
||||
/// Map ecr architecture names to distro-specific names
|
||||
pub fn map_arch(distro: Distro, arch: &str) -> String {
|
||||
match distro {
|
||||
Distro::Ubuntu => match arch {
|
||||
"amd64" => "amd64".to_string(),
|
||||
"arm64" => "arm64".to_string(),
|
||||
"armhf" => "armhf".to_string(),
|
||||
"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(),
|
||||
},
|
||||
}
|
||||
let distro_name = match distro {
|
||||
Distro::Ubuntu => "ubuntu",
|
||||
Distro::Alpine => "alpine",
|
||||
};
|
||||
crate::utils::map_arch_for_distro(distro_name, arch)
|
||||
}
|
||||
|
||||
/// Resolve the download URL for a known distro (optimized path)
|
||||
|
||||
+81
-86
@@ -1,5 +1,6 @@
|
||||
use crate::veprintln;
|
||||
use anyhow::{Context, Result};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::Path;
|
||||
@@ -130,16 +131,22 @@ fn extract_oci_layer(layer_path: &Path, dest: &Path) -> Result<()> {
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
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)),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
)
|
||||
} 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") {
|
||||
extract_archive_with_whiteouts(
|
||||
extract_with_progress(
|
||||
tar::Archive::new(zstd::stream::read::Decoder::new(reader)?),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
)
|
||||
} else {
|
||||
// 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
|
||||
drop(peek);
|
||||
match magic {
|
||||
[0x1f, 0x8b, ..] => extract_archive_with_whiteouts(
|
||||
[0x1f, 0x8b, ..] => extract_with_progress(
|
||||
tar::Archive::new(flate2::read::GzDecoder::new(BufReader::new(File::open(
|
||||
layer_path,
|
||||
)?))),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
),
|
||||
[0xfd, b'7', b'z', b'X', b'Z', 0x00] => extract_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(
|
||||
layer_path,
|
||||
)?))),
|
||||
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(
|
||||
File::open(layer_path)?,
|
||||
))?),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
),
|
||||
_ => extract_archive_with_whiteouts(
|
||||
_ => extract_with_progress(
|
||||
tar::Archive::new(BufReader::new(File::open(layer_path)?)),
|
||||
dest,
|
||||
"Extracting OCI layer",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one OCI layer archive to `dest`, interpreting Docker whiteout markers:
|
||||
///
|
||||
/// - `.wh.<name>` — Delete `<name>` from a lower layer that was already
|
||||
/// extracted into `dest`.
|
||||
/// - `.wh..wh..opq` — Opaque whiteout: the directory that contains this entry
|
||||
/// is new in this layer; delete everything already in that
|
||||
/// directory from lower layers before applying new content.
|
||||
///
|
||||
/// All other entries are extracted normally via `Entry::unpack_in`.
|
||||
fn extract_archive_with_whiteouts<R: std::io::Read>(
|
||||
fn extract_gz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
let gz_decoder = flate2::read::GzDecoder::new(reader);
|
||||
let archive = tar::Archive::new(gz_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting gzip archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_xz<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
let xz_decoder = xz2::read::XzDecoder::new(reader);
|
||||
let archive = tar::Archive::new(xz_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting xz archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_zst<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
let zst_decoder = zstd::Decoder::new(reader)?;
|
||||
let archive = tar::Archive::new(zst_decoder);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting zstd archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar<R: std::io::Read>(reader: R, dest: &Path) -> Result<()> {
|
||||
let archive = tar::Archive::new(reader);
|
||||
|
||||
extract_with_progress(archive, dest, "Extracting tar archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract tar archive with progress bar, handling whiteout files for OCI layers
|
||||
fn extract_with_progress<R: std::io::Read>(
|
||||
mut archive: tar::Archive<R>,
|
||||
dest: &Path,
|
||||
msg: &str,
|
||||
) -> Result<()> {
|
||||
let pb = ProgressBar::new_spinner();
|
||||
pb.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.green} {msg} ({pos} files)")
|
||||
.unwrap(),
|
||||
);
|
||||
pb.set_message(msg.to_string());
|
||||
|
||||
archive.set_preserve_permissions(true);
|
||||
archive.set_preserve_ownerships(false);
|
||||
archive.set_unpack_xattrs(false);
|
||||
|
||||
for entry in archive.entries().context("Failed to iterate tar entries")? {
|
||||
let mut entry = entry.context("Failed to read tar entry")?;
|
||||
let entries = archive
|
||||
.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 filename = path
|
||||
@@ -202,9 +253,9 @@ fn extract_archive_with_whiteouts<R: std::io::Read>(
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Handle whiteout files (OCI layer markers for deletions)
|
||||
if filename == ".wh..wh..opq" {
|
||||
// Opaque whiteout: clear all previously-extracted content in the
|
||||
// parent directory so only this layer's content is visible.
|
||||
// Opaque whiteout: clear all previously-extracted content in the parent directory
|
||||
let parent = path.parent().unwrap_or(Path::new(""));
|
||||
let dest_dir = dest.join(parent);
|
||||
if dest_dir.symlink_metadata().is_ok() {
|
||||
@@ -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.") {
|
||||
// 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 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() {
|
||||
remove_path(&target)
|
||||
.with_context(|| format!("Whiteout: failed to remove {}", target.display()))?;
|
||||
}
|
||||
// Do not extract the .wh.* marker itself.
|
||||
// Do not extract the .wh.* marker itself
|
||||
} else {
|
||||
entry
|
||||
.unpack_in(dest)
|
||||
.with_context(|| format!("Failed to extract {}", path.display()))?;
|
||||
}
|
||||
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
pb.finish_and_clear();
|
||||
veprintln!("Extracted {} files", pb.position());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -251,62 +305,3 @@ fn remove_path(path: &Path) -> std::io::Result<()> {
|
||||
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
@@ -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
@@ -4,9 +4,12 @@ mod config;
|
||||
mod distro;
|
||||
mod download;
|
||||
mod extract;
|
||||
mod kernel;
|
||||
mod mount;
|
||||
mod namespace;
|
||||
mod qemu;
|
||||
mod qemu_vm;
|
||||
mod utils;
|
||||
mod verbose;
|
||||
|
||||
/// 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 cli::Args;
|
||||
@@ -113,11 +116,65 @@ fn main() -> Result<()> {
|
||||
veprintln!("Using cached tarball: {}", cache_path.display());
|
||||
}
|
||||
|
||||
// Check QEMU if foreign architecture
|
||||
if arch != host_arch {
|
||||
// Check QEMU if foreign architecture (for namespace mode)
|
||||
// For VM mode, we don't need binfmt_misc since we're using system emulation
|
||||
if args.kernel.is_none() && arch != host_arch {
|
||||
qemu::check_binfmt(&arch)?;
|
||||
}
|
||||
|
||||
// Create temp directory for extraction
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let rootfs = temp_dir.path().to_path_buf();
|
||||
|
||||
veprintln!("Extracting to: {}", rootfs.display());
|
||||
extract_tarball(&cache_path, &rootfs)?;
|
||||
|
||||
// Branch based on --kernel flag
|
||||
// Option<Option<PathBuf>>:
|
||||
// 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
|
||||
namespace::check_user_namespace()?;
|
||||
|
||||
@@ -140,13 +197,6 @@ fn main() -> Result<()> {
|
||||
}
|
||||
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
|
||||
let bind_paths_clone = bind_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 {
|
||||
// Use the uname(2) syscall directly — no subprocess, no PATH dependency,
|
||||
// no panic-on-missing-binary. This gives the runtime machine 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(),
|
||||
}
|
||||
// Use the consolidated architecture detection from utils
|
||||
utils::get_host_arch().debian_name().to_string()
|
||||
}
|
||||
|
||||
fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
let resolv_conf = rootfs.join("etc/resolv.conf");
|
||||
|
||||
// Create /etc if it doesn't exist
|
||||
if let Some(parent) = resolv_conf.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
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
|
||||
@@ -288,13 +325,31 @@ fn write_resolv_conf(rootfs: &std::path::Path, dns: &[String]) -> Result<()> {
|
||||
c
|
||||
};
|
||||
|
||||
// Remove any existing file or symlink before writing so that std::fs::write
|
||||
// always creates a plain file. Without this, an absolute symlink such as
|
||||
// Remove any existing file or symlink before writing so that we always
|
||||
// create a plain file. Without this, an absolute symlink such as
|
||||
// /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf would cause the
|
||||
// write to follow the symlink through the *host* root (chroot() has not been
|
||||
// called yet) and corrupt the host's DNS configuration.
|
||||
//
|
||||
// Use atomic file creation with O_CREAT | O_EXCL to prevent TOCTOU race:
|
||||
// if an attacker creates a symlink between our remove_file and write, the
|
||||
// exclusive create will fail rather than writing to the symlink target.
|
||||
let _ = std::fs::remove_file(&resolv_conf); // ignore ENOENT
|
||||
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(())
|
||||
}
|
||||
|
||||
+96
-3
@@ -105,7 +105,7 @@ where
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
// 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.
|
||||
let child_error: Option<String> = unsafe {
|
||||
let mut error_bytes = Vec::new();
|
||||
let mut tmp = [0u8; 4096];
|
||||
let mut tmp = [0u8; crate::utils::ERROR_BUFFER_SIZE];
|
||||
loop {
|
||||
let n = libc::read(
|
||||
error_read.raw(),
|
||||
@@ -397,7 +397,9 @@ pub fn set_hostname(distro: &str) -> Result<()> {
|
||||
let mut state = hasher.finish();
|
||||
|
||||
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(|_| {
|
||||
// Knuth multiplicative LCG — each step advances the full 64-bit state.
|
||||
state = state
|
||||
@@ -413,3 +415,94 @@ pub fn set_hostname(distro: &str) -> Result<()> {
|
||||
|
||||
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
@@ -4,7 +4,7 @@ use std::path::Path;
|
||||
|
||||
/// Check if binfmt_misc is registered for the target architecture
|
||||
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);
|
||||
|
||||
@@ -22,16 +22,3 @@ pub fn check_binfmt(arch: &str) -> Result<()> {
|
||||
veprintln!("QEMU binfmt_misc registered for {}", arch);
|
||||
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
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user