Compare commits
16 Commits
main
...
1d2031b3ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 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"
|
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 using specified kernel |
|
||||||
|
| `-m, --memory <SIZE>` | Memory for QEMU VM (default: 2G, only with `--kernel`) |
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -56,8 +58,32 @@ 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 (requires qemu-system-<arch>)
|
||||||
|
ecr --kernel /boot/vmlinuz ubuntu
|
||||||
|
|
||||||
|
# Boot with custom memory
|
||||||
|
ecr --kernel /boot/vmlinuz --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
|
||||||
|
ecr --kernel /boot/vmlinuz alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
This mode:
|
||||||
|
- Creates a gzipped CPIO initramfs from the rootfs
|
||||||
|
- Boots QEMU with your kernel
|
||||||
|
- Provides full VM isolation
|
||||||
|
- Works for any architecture (no binfmt_misc needed)
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- `qemu-system-<arch>` installed
|
||||||
|
- Kernel with serial console support
|
||||||
|
|
||||||
## Supported distributions
|
## Supported distributions
|
||||||
|
|
||||||
| Name | Source | Version examples |
|
| 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) |
|
| `--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
@@ -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.get("LANG").is_none());
|
||||||
|
assert!(env.get("DISPLAY").is_none());
|
||||||
|
assert!(env.get("PWD").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ pub struct Args {
|
|||||||
#[arg(short = 'v', long)]
|
#[arg(short = 'v', long)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
|
|
||||||
|
/// Boot with QEMU system emulation using specified kernel (extracts rootfs as disk image)
|
||||||
|
#[arg(long, value_name = "KERNEL_PATH")]
|
||||||
|
pub kernel: 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
@@ -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
@@ -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(())
|
|
||||||
}
|
|
||||||
|
|||||||
+73
-32
@@ -7,6 +7,8 @@ mod extract;
|
|||||||
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 +21,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 +115,52 @@ 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
|
||||||
|
if let Some(kernel_path) = &args.kernel {
|
||||||
|
// QEMU system mode
|
||||||
|
veprintln!("QEMU mode: booting with kernel {}", kernel_path.display());
|
||||||
|
|
||||||
|
let command = if args.command.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(args.command.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = qemu_vm::launch_qemu(qemu_vm::QemuConfig {
|
||||||
|
kernel_path: kernel_path.clone(),
|
||||||
|
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 +183,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 +279,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 +311,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
@@ -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
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+527
@@ -0,0 +1,527 @@
|
|||||||
|
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
|
||||||
|
// The init script (added to initramfs) handles hostname, shell, and poweroff
|
||||||
|
let kernel_append = if let Some(ref cmd) = config.command {
|
||||||
|
let cmd_str = cmd.join(" ");
|
||||||
|
format!(
|
||||||
|
"console=ttyS0 quiet ECR_SHELL={} ECR_CMD=\"{}\" ECR_HOSTNAME={}",
|
||||||
|
shell, cmd_str, hostname
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"console=ttyS0 quiet ECR_SHELL={} ECR_HOSTNAME={}",
|
||||||
|
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)
|
||||||
|
}
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
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_canonical_name() {
|
||||||
|
assert_eq!(Arch::Amd64.canonical_name(), "x86_64");
|
||||||
|
assert_eq!(Arch::Arm64.canonical_name(), "aarch64");
|
||||||
|
assert_eq!(Arch::Armhf.canonical_name(), "armv7l");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_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_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