Compare commits
6
Commits
1d2031b3ca
...
f-kernel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c47a5c662 | ||
|
|
240a66c532 | ||
|
|
d64da0671b | ||
|
|
ef00776414 | ||
|
|
e1d69eaed6 | ||
|
|
3188566b6e |
Generated
+1
@@ -301,6 +301,7 @@ name = "ecr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"clap",
|
||||
"cpio",
|
||||
"dirs",
|
||||
|
||||
@@ -37,6 +37,7 @@ anyhow = "1"
|
||||
dirs = "6"
|
||||
which = "7"
|
||||
cpio = "0.4"
|
||||
base64 = "0.22"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] }
|
||||
futures-util = "0.3"
|
||||
indicatif = "0.18"
|
||||
|
||||
@@ -38,7 +38,7 @@ 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 using specified kernel |
|
||||
| `--kernel[=PATH]` | Boot with QEMU system emulation. Downloads Alpine's `linux-virt` kernel if no `=PATH` given |
|
||||
| `-m, --memory <SIZE>` | Memory for QEMU VM (default: 2G, only with `--kernel`) |
|
||||
|
||||
## Examples
|
||||
@@ -59,11 +59,14 @@ ecr --arch arm64 alpine -- uname -m
|
||||
# Always pull a fresh image
|
||||
ecr --no-cache fedora
|
||||
|
||||
# Boot with QEMU system emulation (requires qemu-system-<arch>)
|
||||
ecr --kernel /boot/vmlinuz ubuntu
|
||||
# Boot with QEMU system emulation (auto-downloads default kernel)
|
||||
ecr --kernel alpine
|
||||
|
||||
# Boot with your own kernel (note the `=` — a space would parse the path as the distro)
|
||||
ecr --kernel=/boot/vmlinuz ubuntu
|
||||
|
||||
# Boot with custom memory
|
||||
ecr --kernel /boot/vmlinuz --memory 4G alpine
|
||||
ecr --kernel --memory 4G alpine
|
||||
```
|
||||
|
||||
## QEMU System Mode
|
||||
@@ -71,18 +74,25 @@ ecr --kernel /boot/vmlinuz --memory 4G alpine
|
||||
When `--kernel` is specified, ecr boots the rootfs in a full QEMU virtual machine instead of using namespaces:
|
||||
|
||||
```sh
|
||||
ecr --kernel /boot/vmlinuz alpine
|
||||
# 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
|
||||
- Creates an uncompressed CPIO initramfs from the rootfs (streamed to disk)
|
||||
- Boots QEMU with your kernel (or auto-downloads Alpine's `linux-virt` kernel)
|
||||
- Provides full VM isolation
|
||||
- Works for any architecture (no binfmt_misc needed)
|
||||
- Caches the default kernel in `~/.cache/ecr/`
|
||||
|
||||
Host bind mounts (`--bind`, `--bind-rw`) are not applied in this mode.
|
||||
|
||||
Requirements:
|
||||
- `qemu-system-<arch>` installed
|
||||
- Kernel with serial console support
|
||||
- For custom kernels: kernel must have serial console support
|
||||
|
||||
## Supported distributions
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ 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) |
|
||||
| `--kernel[=PATH]` | none | Boot with QEMU system emulation; downloads the default Alpine `linux-virt` kernel when no `=PATH` is given |
|
||||
| `-m, --memory <size>` | 2G | Memory size for QEMU VM (only used with `--kernel`) |
|
||||
| `-v, --verbose` | false | Print diagnostic messages |
|
||||
| `-h, --help` | - | Show help |
|
||||
@@ -187,35 +187,38 @@ No action required. Modern qemu-user-static packages register binfmt_misc with t
|
||||
|
||||
## 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.
|
||||
When `--kernel` is specified, ecr switches from namespace/chroot mode to QEMU system emulation. The extracted rootfs is converted to an uncompressed CPIO initramfs and booted with the provided kernel.
|
||||
|
||||
### Usage
|
||||
|
||||
The kernel path uses `=` syntax (`--kernel=PATH`); `--kernel` without a value downloads the default kernel. Without `=`, a following path would be parsed as the DISTRO argument.
|
||||
|
||||
```sh
|
||||
ecr --kernel /boot/vmlinuz ubuntu:noble
|
||||
ecr --kernel /boot/vmlinuz --memory 4G alpine
|
||||
ecr --kernel /boot/vmlinuz debian -- /bin/sh -c "echo hello"
|
||||
ecr --kernel ubuntu:noble
|
||||
ecr --kernel=/boot/vmlinuz ubuntu:noble
|
||||
ecr --kernel=/boot/vmlinuz --memory 4G alpine
|
||||
ecr --kernel=/boot/vmlinuz debian -- /bin/sh -c "echo hello"
|
||||
```
|
||||
|
||||
### Execution Flow
|
||||
|
||||
1. Download/cache rootfs tarball (same as namespace mode)
|
||||
2. Extract tarball to temporary directory
|
||||
3. Create gzipped CPIO initramfs from rootfs
|
||||
3. Create uncompressed CPIO initramfs from rootfs (streamed to disk), including essential device nodes (/dev/ttyS0, /dev/null, /dev/tty) and an `/init` script that mounts proc/sys/dev, sets the hostname, execs the requested command argv verbatim (each argv element base64-encoded in the cmdline as `ECR_ARGV`), and powers off on exit
|
||||
4. Launch QEMU with:
|
||||
- `-kernel <path>` - provided 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
|
||||
- `-kernel <path>` - provided (or downloaded) kernel
|
||||
- `-initrd initramfs.cpio` - rootfs as initramfs
|
||||
- `-append "console=ttyS0 [quiet] ECR_SHELL=... [ECR_ARGV=...] ECR_HOSTNAME=..."` - kernel command line (`quiet` unless `-v`)
|
||||
- `-m <memory>` - memory size (default 2G)
|
||||
- `-display none -serial mon:stdio` - console on stdio
|
||||
- `-netdev user,id=net0 -device virtio-net-pci,netdev=net0` - network
|
||||
5. Wait for QEMU to exit
|
||||
- `-netdev user,id=net0 -device virtio-net-pci,netdev=net0` - network NIC
|
||||
- `-enable-kvm -cpu host` - when the host supports KVM and the target matches the host architecture
|
||||
5. Wait for QEMU to exit (init powers the VM off when the command/shell exits; `-no-reboot` makes QEMU terminate)
|
||||
6. Cleanup temporary files
|
||||
|
||||
### Initramfs Creation
|
||||
|
||||
The rootfs directory is converted to a gzipped CPIO archive (newc format) using the `cpio` crate.
|
||||
The rootfs directory is converted to an uncompressed CPIO archive (newc format) using the `cpio` crate, streamed entry by entry so large rootfs images never need to fit in memory. Hard links are preserved: the first occurrence of a (device, inode) pair carries the data with a synthetic inode, subsequent occurrences are zero-size entries sharing that inode, which the kernel's initramfs loader turns into real hard links.
|
||||
|
||||
### Architecture Support
|
||||
|
||||
@@ -238,11 +241,11 @@ The rootfs directory is converted to a gzipped CPIO archive (newc format) using
|
||||
| Feature | Namespace Mode | QEMU Mode |
|
||||
|---------|---------------|-----------|
|
||||
| Isolation | User namespace | Full VM |
|
||||
| Performance | Near-native | Emulated (slower) |
|
||||
| Root access | No | No |
|
||||
| Performance | Near-native | Emulated (KVM-accelerated when available) |
|
||||
| Root access | No | Yes (inside the VM) |
|
||||
| Foreign arch | binfmt_misc required | Built-in emulation |
|
||||
| Bind mounts | Overlay/bind | Not supported |
|
||||
| Network | Host network | User-mode network |
|
||||
| Bind mounts | Overlay/bind | Not supported (flags are ignored with a warning) |
|
||||
| Network | Host network | User-mode NIC (not configured inside the guest) |
|
||||
|
||||
## File Handling
|
||||
|
||||
|
||||
+3
-3
@@ -170,9 +170,9 @@ mod tests {
|
||||
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());
|
||||
assert!(!env.contains_key("LANG"));
|
||||
assert!(!env.contains_key("DISPLAY"));
|
||||
assert!(!env.contains_key("PWD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+15
-3
@@ -33,9 +33,21 @@ pub struct Args {
|
||||
#[arg(short = 'v', long)]
|
||||
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>,
|
||||
/// Boot with QEMU system emulation (optionally specify kernel path with =PATH, or omit to download default)
|
||||
///
|
||||
/// Examples:
|
||||
/// --kernel Download and use the default Alpine linux-virt kernel
|
||||
/// --kernel=./vmlinuz Use a specific kernel file
|
||||
///
|
||||
/// The kernel path must use `=` syntax: with `--kernel ./vmlinuz` the
|
||||
/// path would be parsed as the DISTRO argument.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "KERNEL_PATH",
|
||||
num_args = 0..=1,
|
||||
require_equals = true
|
||||
)]
|
||||
pub kernel: Option<Option<PathBuf>>,
|
||||
|
||||
/// Memory size for QEMU VM (only used with --kernel, e.g., 512M, 2G)
|
||||
#[arg(short = 'm', long, default_value = "2G", value_name = "SIZE")]
|
||||
|
||||
+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");
|
||||
// Remove the ~45MB APK whatever the extraction outcome — don't leave
|
||||
// it behind in the cache directory on failure.
|
||||
let result = extract_kernel_from_apk(&temp_apk, dest);
|
||||
std::fs::remove_file(&temp_apk).ok();
|
||||
result
|
||||
}
|
||||
|
||||
async fn download_kernel_async(url: &str, dest: &Path) -> Result<()> {
|
||||
use futures_util::StreamExt;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to start kernel download")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Kernel download failed: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
|
||||
// Setup progress bar
|
||||
let pb = ProgressBar::new(total_size);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
let temp_apk = dest.with_extension("apk.partial");
|
||||
let mut file = tokio::fs::File::create(&temp_apk)
|
||||
.await
|
||||
.context("Failed to create temp APK file")?;
|
||||
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.context("Failed to read chunk")?;
|
||||
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
|
||||
.await
|
||||
.context("Failed to write chunk")?;
|
||||
downloaded += chunk.len() as u64;
|
||||
pb.set_position(downloaded);
|
||||
}
|
||||
|
||||
tokio::io::AsyncWriteExt::flush(&mut file)
|
||||
.await
|
||||
.context("Failed to flush file")?;
|
||||
|
||||
pb.finish_with_message("Download complete");
|
||||
|
||||
// Rename to final name
|
||||
let final_apk = dest.with_extension("apk");
|
||||
tokio::fs::rename(&temp_apk, &final_apk)
|
||||
.await
|
||||
.context("Failed to rename temp file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract vmlinuz-virt from an Alpine APK file
|
||||
fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> {
|
||||
veprintln!("Extracting kernel from APK...");
|
||||
|
||||
let file = std::fs::File::open(apk_path).context("Failed to open APK file")?;
|
||||
// Use MultiGzDecoder because Alpine APKs have concatenated gzip members
|
||||
let gz_decoder = flate2::read::MultiGzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(gz_decoder);
|
||||
|
||||
for entry in archive.entries().context("Failed to read APK entries")? {
|
||||
let mut entry = entry.context("Failed to read tar entry")?;
|
||||
let path = entry.path().context("Failed to get entry path")?;
|
||||
let path_str = path.to_string_lossy();
|
||||
|
||||
veprintln!(" APK entry: {}", path_str);
|
||||
|
||||
// Look for the kernel file: boot/vmlinuz-virt
|
||||
if path_str == "boot/vmlinuz-virt" || path_str == "./boot/vmlinuz-virt" {
|
||||
// Extract to destination
|
||||
entry.unpack(dest).context("Failed to extract kernel")?;
|
||||
veprintln!(" Extracted kernel to: {}", dest.display());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("vmlinuz-virt not found in APK package"))
|
||||
}
|
||||
|
||||
/// Get the path to the cached default kernel for the given architecture.
|
||||
/// Downloads and caches it if not present.
|
||||
pub fn get_default_kernel(cache_dir: &Path, arch: &str) -> Result<PathBuf> {
|
||||
let alpine_arch = alpine_kernel_arch(arch);
|
||||
|
||||
// Cache filename includes architecture
|
||||
let kernel_filename = format!("ecr-default-kernel-{}.vmlinuz", alpine_arch);
|
||||
let kernel_path = cache_dir.join(&kernel_filename);
|
||||
|
||||
// Check if already cached
|
||||
if kernel_path.exists() {
|
||||
veprintln!("Using cached default kernel: {}", kernel_path.display());
|
||||
return Ok(kernel_path);
|
||||
}
|
||||
|
||||
// Create cache directory if needed
|
||||
std::fs::create_dir_all(cache_dir).context("Failed to create cache directory")?;
|
||||
|
||||
// Determine Alpine branch
|
||||
let branch = get_alpine_branch()?;
|
||||
veprintln!("Using Alpine branch: {}", branch);
|
||||
|
||||
// Download and extract the kernel
|
||||
download_alpine_kernel(&branch, alpine_arch, &kernel_path)?;
|
||||
|
||||
Ok(kernel_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_alpine_kernel_arch() {
|
||||
assert_eq!(alpine_kernel_arch("amd64"), "x86_64");
|
||||
assert_eq!(alpine_kernel_arch("x86_64"), "x86_64");
|
||||
assert_eq!(alpine_kernel_arch("arm64"), "aarch64");
|
||||
assert_eq!(alpine_kernel_arch("aarch64"), "aarch64");
|
||||
assert_eq!(alpine_kernel_arch("armhf"), "armv7");
|
||||
assert_eq!(alpine_kernel_arch("riscv64"), "riscv64");
|
||||
assert_eq!(alpine_kernel_arch("ppc64le"), "ppc64le");
|
||||
assert_eq!(alpine_kernel_arch("s390x"), "s390x");
|
||||
}
|
||||
}
|
||||
+36
-6
@@ -4,6 +4,7 @@ mod config;
|
||||
mod distro;
|
||||
mod download;
|
||||
mod extract;
|
||||
mod kernel;
|
||||
mod mount;
|
||||
mod namespace;
|
||||
mod qemu;
|
||||
@@ -129,9 +130,31 @@ fn main() -> Result<()> {
|
||||
extract_tarball(&cache_path, &rootfs)?;
|
||||
|
||||
// Branch based on --kernel flag
|
||||
if let Some(kernel_path) = &args.kernel {
|
||||
// Option<Option<PathBuf>> (require_equals: the value must use =PATH syntax
|
||||
// so it can never swallow the DISTRO positional):
|
||||
// None -> --kernel not specified, use namespace mode
|
||||
// Some(None) -> --kernel without path, download default kernel
|
||||
// Some(Some(path)) -> --kernel=/path/to/vmlinuz, use provided kernel
|
||||
if let Some(kernel_opt) = &args.kernel {
|
||||
// VM mode boots an initramfs: host bind mounts are never applied
|
||||
if !args.bind.is_empty() || !args.bind_rw.is_empty() {
|
||||
eprintln!(
|
||||
"Warning: --bind/--bind-rw are ignored with --kernel \
|
||||
(the VM boots from an initramfs, no host directories are mounted)"
|
||||
);
|
||||
}
|
||||
|
||||
// QEMU system mode
|
||||
veprintln!("QEMU mode: booting with kernel {}", kernel_path.display());
|
||||
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
|
||||
@@ -140,7 +163,7 @@ fn main() -> Result<()> {
|
||||
};
|
||||
|
||||
let result = qemu_vm::launch_qemu(qemu_vm::QemuConfig {
|
||||
kernel_path: kernel_path.clone(),
|
||||
kernel_path,
|
||||
rootfs_path: rootfs,
|
||||
memory: args.memory.clone(),
|
||||
arch: arch.clone(),
|
||||
@@ -155,12 +178,19 @@ fn main() -> Result<()> {
|
||||
result
|
||||
} else {
|
||||
// Namespace/chroot mode
|
||||
namespace_mode(args, rootfs, config)
|
||||
let exit_code = namespace_mode(args, rootfs, config)?;
|
||||
// Propagate the command's exit code, cleaning up the extracted rootfs
|
||||
// first: process::exit does not run destructors.
|
||||
drop(temp_dir);
|
||||
if exit_code != 0 {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run in namespace/chroot mode
|
||||
fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Result<()> {
|
||||
/// Run in namespace/chroot mode, returning the command's exit code
|
||||
fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Result<i32> {
|
||||
// Check user namespace availability
|
||||
namespace::check_user_namespace()?;
|
||||
|
||||
|
||||
+8
-8
@@ -58,8 +58,10 @@ pub fn check_user_namespace() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Setup namespaces and run the provided function inside them
|
||||
pub fn setup_namespaces<F>(f: F) -> Result<()>
|
||||
/// Setup namespaces and run the provided function inside them.
|
||||
/// Returns the child's exit code (0 on success, 128+signal when killed by a
|
||||
/// signal). Setup failures are returned as Err.
|
||||
pub fn setup_namespaces<F>(f: F) -> Result<i32>
|
||||
where
|
||||
F: FnOnce() -> Result<()> + Send + 'static,
|
||||
{
|
||||
@@ -222,20 +224,18 @@ where
|
||||
let status = nix::sys::wait::waitpid(pid, None)?;
|
||||
|
||||
match status {
|
||||
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(()),
|
||||
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0),
|
||||
nix::sys::wait::WaitStatus::Exited(_, code) => {
|
||||
// If the child reported an error (e.g., setup failure), return it.
|
||||
// Otherwise, just forward the exit code without an error message.
|
||||
if let Some(msg) = child_error {
|
||||
Err(anyhow!("{}", msg))
|
||||
} else {
|
||||
std::process::exit(code);
|
||||
Ok(code)
|
||||
}
|
||||
}
|
||||
nix::sys::wait::WaitStatus::Signaled(_, sig, _) => {
|
||||
Err(anyhow!("Child process killed by signal {:?}", sig))
|
||||
}
|
||||
_ => Ok(()),
|
||||
nix::sys::wait::WaitStatus::Signaled(_, sig, _) => Ok(128 + sig as i32),
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+322
-123
@@ -88,18 +88,33 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> {
|
||||
// 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
|
||||
// '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(" ");
|
||||
// The argv travels through the kernel cmdline, where quotes and
|
||||
// spaces would be mangled — pass each element base64-encoded,
|
||||
// comma-separated (the base64 alphabet contains neither). The init
|
||||
// script decodes it back and execs the argv verbatim.
|
||||
use base64::Engine as _;
|
||||
let argv_b64 = cmd
|
||||
.iter()
|
||||
.map(|arg| base64::engine::general_purpose::STANDARD.encode(arg.as_bytes()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(
|
||||
"console=ttyS0 quiet ECR_SHELL={} ECR_CMD=\"{}\" ECR_HOSTNAME={}",
|
||||
shell, cmd_str, hostname
|
||||
"console=ttyS0{} ECR_SHELL={} ECR_ARGV={} ECR_HOSTNAME={}",
|
||||
quiet_flag, shell, argv_b64, hostname
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"console=ttyS0 quiet ECR_SHELL={} ECR_HOSTNAME={}",
|
||||
shell, hostname
|
||||
"console=ttyS0{} ECR_SHELL={} ECR_HOSTNAME={}",
|
||||
quiet_flag, shell, hostname
|
||||
)
|
||||
};
|
||||
|
||||
@@ -220,10 +235,9 @@ fn check_kvm_capabilities() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>);
|
||||
|
||||
/// Create an uncompressed cpio initramfs from a directory.
|
||||
/// Entries are streamed straight to disk so large rootfs images never have
|
||||
/// to fit in memory as a whole archive.
|
||||
fn create_initramfs(rootfs: &Path) -> Result<PathBuf> {
|
||||
// Create a temporary file for the initramfs (uncompressed cpio)
|
||||
// Use a temp file in the same directory as rootfs, or fall back to /tmp
|
||||
@@ -241,16 +255,26 @@ fn create_initramfs(rootfs: &Path) -> Result<PathBuf> {
|
||||
);
|
||||
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();
|
||||
let file = std::fs::File::create(&initramfs_path)
|
||||
.with_context(|| format!("Failed to create {}", initramfs_path.display()))?;
|
||||
let mut writer = std::io::BufWriter::new(file);
|
||||
|
||||
// Write directly to file (no compression)
|
||||
std::fs::write(&initramfs_path, &cpio_data).context("Failed to write initramfs file")?;
|
||||
let result = write_cpio_archive(rootfs, &mut writer, &pb);
|
||||
let flushed = writer.flush().context("Failed to flush initramfs");
|
||||
|
||||
// On any error, don't leave a partial archive behind
|
||||
if let Err(e) = result.or(flushed) {
|
||||
std::fs::remove_file(&initramfs_path).ok();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Finish progress bar
|
||||
let file_count = pb.position();
|
||||
pb.finish_and_clear();
|
||||
|
||||
let total_bytes = std::fs::metadata(&initramfs_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
veprintln!(
|
||||
"Initramfs created: {} bytes, {} files",
|
||||
total_bytes,
|
||||
@@ -260,43 +284,34 @@ fn create_initramfs(rootfs: &Path) -> Result<PathBuf> {
|
||||
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();
|
||||
/// Write the newc-format cpio archive for a directory tree, streaming to `writer`
|
||||
fn write_cpio_archive<W: Write>(rootfs: &Path, writer: &mut W, pb: &ProgressBar) -> Result<()> {
|
||||
// Track hard links by (device, inode): the value is the synthetic cpio
|
||||
// inode assigned to the first occurrence. The Linux initramfs loader
|
||||
// turns later zero-size entries sharing that inode into hard links.
|
||||
let mut seen_inodes: std::collections::HashMap<(u64, u64), u32> =
|
||||
std::collections::HashMap::new();
|
||||
let mut next_ino: u32 = 1;
|
||||
// Names of entries already written, so we can skip duplicate device nodes
|
||||
let mut written_names: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut total_data: u64 = 0;
|
||||
|
||||
// Track seen inodes to handle hard links properly
|
||||
let mut seen_inodes = std::collections::HashMap::new();
|
||||
write_dir_entries(
|
||||
rootfs,
|
||||
rootfs,
|
||||
writer,
|
||||
pb,
|
||||
&mut seen_inodes,
|
||||
&mut next_ino,
|
||||
&mut written_names,
|
||||
&mut total_data,
|
||||
)?;
|
||||
|
||||
// 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")?;
|
||||
}
|
||||
veprintln!(
|
||||
"Collected {} entries, {} bytes total data",
|
||||
written_names.len(),
|
||||
total_data
|
||||
);
|
||||
|
||||
// Add essential device nodes for serial console
|
||||
// These are character devices (mode 0o020xxx)
|
||||
@@ -311,7 +326,7 @@ fn create_cpio_archive(rootfs: &Path, pb: &ProgressBar) -> Result<Vec<u8>> {
|
||||
|
||||
for (name, mode, major, minor) in device_nodes {
|
||||
// Check if this device node already exists in the archive
|
||||
if entry_names.contains(&name) {
|
||||
if written_names.contains(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -325,8 +340,8 @@ fn create_cpio_archive(rootfs: &Path, pb: &ProgressBar) -> Result<Vec<u8>> {
|
||||
.rdev_minor(minor);
|
||||
|
||||
// Device nodes have zero size
|
||||
let writer = builder.write(&mut archive, 0);
|
||||
writer
|
||||
let entry_writer = builder.write(&mut *writer, 0);
|
||||
entry_writer
|
||||
.finish()
|
||||
.context("Failed to finish device node entry")?;
|
||||
}
|
||||
@@ -342,10 +357,22 @@ mount -t proc proc /proc
|
||||
mount -t sysfs sysfs /sys
|
||||
mount -t devtmpfs devtmpfs /dev 2>/dev/null || true
|
||||
|
||||
# Set hostname from kernel cmdline
|
||||
# Parse our parameters straight from the kernel cmdline. More robust than
|
||||
# relying on the kernel forwarding unknown key=value params to init's env.
|
||||
ECR_SHELL="/bin/sh"
|
||||
ECR_ARGV=""
|
||||
for param in $(cat /proc/cmdline); do
|
||||
case "$param" in
|
||||
ECR_SHELL=*) ECR_SHELL="${param#ECR_SHELL=}" ;;
|
||||
ECR_ARGV=*) ECR_ARGV="${param#ECR_ARGV=}" ;;
|
||||
ECR_HOSTNAME=*) ECR_HOSTNAME="${param#ECR_HOSTNAME=}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Set hostname from kernel cmdline (via procfs — no hostname binary needed)
|
||||
if [ -n "$ECR_HOSTNAME" ]; then
|
||||
echo "$ECR_HOSTNAME" > /etc/hostname
|
||||
hostname "$ECR_HOSTNAME"
|
||||
echo "$ECR_HOSTNAME" > /proc/sys/kernel/hostname
|
||||
fi
|
||||
|
||||
# Create console device if missing
|
||||
@@ -354,6 +381,9 @@ mknod -m 666 /dev/ttyS0 c 4 64 2>/dev/null || true
|
||||
|
||||
# Function to poweroff - use sysrq-trigger which works without external binaries
|
||||
do_poweroff() {
|
||||
# Give the serial console a moment to drain pending output, otherwise
|
||||
# the last command output can be dropped when the VM powers off
|
||||
sleep 1
|
||||
# Silence kernel printk to suppress shutdown messages
|
||||
echo 0 > /proc/sys/kernel/printk
|
||||
# 'o' means power off, see Documentation/admin-guide/sysrq.rst
|
||||
@@ -365,49 +395,76 @@ do_poweroff() {
|
||||
# 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'"
|
||||
# Rebuild the argv: each element is base64-encoded, elements are separated
|
||||
# by commas. Decoding into "$@" avoids any shell re-parsing of the command.
|
||||
set --
|
||||
if [ -n "$ECR_ARGV" ]; then
|
||||
for enc in $(printf '%s' "$ECR_ARGV" | tr ',' ' '); do
|
||||
dec=$(printf '%s' "$enc" | base64 -d 2>/dev/null)
|
||||
set -- "$@" "$dec"
|
||||
done
|
||||
fi
|
||||
|
||||
# Run the requested command verbatim, or an interactive shell in its own
|
||||
# session (setsid enables job control on the serial console)
|
||||
if [ "$#" -gt 0 ]; then
|
||||
"$@"
|
||||
else
|
||||
# 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);
|
||||
write_cpio_entry(writer, "init", 0o100755, 0, 1, 0, init_script.as_bytes())?;
|
||||
|
||||
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
|
||||
newc::trailer(&mut *writer).context("Failed to write cpio trailer")?;
|
||||
|
||||
// Write the trailer (takes ownership and returns the writer)
|
||||
archive = newc::trailer(archive).context("Failed to write cpio trailer")?;
|
||||
|
||||
Ok(archive)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect all filesystem entries recursively
|
||||
/// Uses a HashMap to track hard links by (device, inode) - only stores data for first occurrence
|
||||
fn collect_entries(
|
||||
/// Write a single newc-format cpio entry
|
||||
fn write_cpio_entry<W: Write>(
|
||||
writer: &mut W,
|
||||
name: &str,
|
||||
mode: u32,
|
||||
mtime: u32,
|
||||
nlink: u32,
|
||||
ino: u32,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
let builder = NewcBuilder::new(name)
|
||||
.mode(mode)
|
||||
.uid(0)
|
||||
.gid(0)
|
||||
.ino(ino)
|
||||
.nlink(nlink)
|
||||
.mtime(mtime);
|
||||
|
||||
let mut entry_writer = builder.write(&mut *writer, data.len() as u32);
|
||||
entry_writer
|
||||
.write_all(data)
|
||||
.with_context(|| format!("Failed to write {} to cpio archive", name))?;
|
||||
entry_writer
|
||||
.finish()
|
||||
.with_context(|| format!("Failed to finish cpio entry {}", name))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk a directory tree, writing every entry to the cpio archive as it goes
|
||||
/// Hard links are handled by assigning a synthetic inode to the first
|
||||
/// occurrence of a (device, inode) pair; subsequent occurrences are written
|
||||
/// as zero-size entries reusing that inode.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn write_dir_entries(
|
||||
base: &Path,
|
||||
current: &Path,
|
||||
writer: &mut impl Write,
|
||||
pb: &ProgressBar,
|
||||
seen_inodes: &mut std::collections::HashMap<(u64, u64), String>,
|
||||
) -> Result<Vec<CpioEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
let mut total_data: u64 = 0;
|
||||
|
||||
seen_inodes: &mut std::collections::HashMap<(u64, u64), u32>,
|
||||
next_ino: &mut u32,
|
||||
written_names: &mut std::collections::HashSet<String>,
|
||||
total_data: &mut u64,
|
||||
) -> Result<()> {
|
||||
// Read directory entries
|
||||
let dir_entries: Vec<_> = match std::fs::read_dir(current) {
|
||||
Ok(entries) => entries.collect::<std::result::Result<_, _>>()?,
|
||||
@@ -417,7 +474,7 @@ fn collect_entries(
|
||||
current.display(),
|
||||
e
|
||||
);
|
||||
return Ok(entries);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -459,25 +516,31 @@ fn collect_entries(
|
||||
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
|
||||
// Determine (data, nlink, cpio inode).
|
||||
// The kernel's initramfs loader records the first entry of a hard-link
|
||||
// group (the one carrying the data) and turns later zero-size entries
|
||||
// with the same inode into sys_link calls.
|
||||
let (data, nlink, cpio_ino) = if file_type.is_file() && metadata.nlink() > 1 {
|
||||
let inode_key = (metadata.dev(), metadata.ino());
|
||||
|
||||
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)
|
||||
match seen_inodes.get(&inode_key) {
|
||||
Some(seen_cpio_ino) => {
|
||||
// Subsequent occurrence: zero-size hard-link entry
|
||||
(Vec::new(), metadata.nlink() as u32, *seen_cpio_ino)
|
||||
}
|
||||
None => {
|
||||
// First occurrence: read the data, assign a synthetic inode
|
||||
let cpio_ino = *next_ino;
|
||||
*next_ino = next_ino.wrapping_add(1);
|
||||
seen_inodes.insert(inode_key, cpio_ino);
|
||||
let data = match std::fs::read(&path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
veprintln!("Warning: cannot read file {}: {}", path.display(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
(data, metadata.nlink() as u32, cpio_ino)
|
||||
}
|
||||
}
|
||||
} else if file_type.is_file() {
|
||||
// Regular file with nlink=1
|
||||
@@ -488,10 +551,10 @@ fn collect_entries(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
(data, 1)
|
||||
(data, 1, 0)
|
||||
} else if file_type.is_symlink() {
|
||||
match std::fs::read_link(&path) {
|
||||
Ok(target) => (target.to_string_lossy().into_owned().into_bytes(), 1),
|
||||
Ok(target) => (target.to_string_lossy().into_owned().into_bytes(), 1, 0),
|
||||
Err(e) => {
|
||||
veprintln!("Warning: cannot read symlink {}: {}", path.display(), e);
|
||||
continue;
|
||||
@@ -499,29 +562,165 @@ fn collect_entries(
|
||||
}
|
||||
} else {
|
||||
// Directory
|
||||
(Vec::new(), 2)
|
||||
(Vec::new(), 2, 0)
|
||||
};
|
||||
|
||||
total_data += data.len() as u64;
|
||||
entries.push((entry_name, mode, metadata.mtime() as u32, nlink, data));
|
||||
*total_data += data.len() as u64;
|
||||
|
||||
write_cpio_entry(
|
||||
writer,
|
||||
&entry_name,
|
||||
mode,
|
||||
metadata.mtime() as u32,
|
||||
nlink,
|
||||
cpio_ino,
|
||||
&data,
|
||||
)?;
|
||||
written_names.insert(entry_name);
|
||||
|
||||
// Recurse into directories
|
||||
if file_type.is_dir() {
|
||||
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);
|
||||
write_dir_entries(
|
||||
base,
|
||||
&path,
|
||||
writer,
|
||||
pb,
|
||||
seen_inodes,
|
||||
next_ino,
|
||||
written_names,
|
||||
total_data,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Only print summary for the root directory
|
||||
if current == base {
|
||||
veprintln!(
|
||||
"Collected {} entries, {} bytes total data",
|
||||
entries.len(),
|
||||
total_data
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read as _;
|
||||
|
||||
/// Parse a newc cpio archive into (name, ino, mode, nlink, file_size, data) tuples
|
||||
fn parse_cpio(archive: Vec<u8>) -> Vec<(String, u32, u32, u32, u32, Vec<u8>)> {
|
||||
let mut cursor = std::io::Cursor::new(archive);
|
||||
let mut entries = Vec::new();
|
||||
loop {
|
||||
let mut reader = newc::Reader::new(&mut cursor).expect("valid cpio entry");
|
||||
let name = reader.entry().name().to_string();
|
||||
let ino = reader.entry().ino();
|
||||
let mode = reader.entry().mode();
|
||||
let nlink = reader.entry().nlink();
|
||||
let file_size = reader.entry().file_size();
|
||||
let mut data = Vec::new();
|
||||
reader.read_to_end(&mut data).expect("read entry data");
|
||||
let is_trailer = reader.entry().is_trailer();
|
||||
// Skip the padding after the entry data before parsing the next one
|
||||
reader.finish().expect("skip entry padding");
|
||||
entries.push((name, ino, mode, nlink, file_size, data));
|
||||
if is_trailer {
|
||||
break;
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hard_links_share_inode() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Two distinct hard-link groups, each with two names
|
||||
std::fs::write(dir.path().join("group1.txt"), b"hello").unwrap();
|
||||
std::fs::hard_link(
|
||||
dir.path().join("group1.txt"),
|
||||
dir.path().join("group1b.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("group2.txt"), b"world!").unwrap();
|
||||
std::fs::hard_link(
|
||||
dir.path().join("group2.txt"),
|
||||
dir.path().join("group2b.txt"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pb = ProgressBar::hidden();
|
||||
let mut archive = Vec::new();
|
||||
write_cpio_archive(dir.path(), &mut archive, &pb).unwrap();
|
||||
|
||||
let entries = parse_cpio(archive)
|
||||
.into_iter()
|
||||
.map(|(name, ino, _, nlink, size, data)| (name, (ino, nlink, size, data)))
|
||||
.collect::<std::collections::HashMap<String, (u32, u32, u32, Vec<u8>)>>();
|
||||
let get = |name: &str| entries[name].clone();
|
||||
|
||||
// Both names of a hard link share one synthetic (nonzero) inode.
|
||||
// readdir order decides which occurrence is walked first, so exactly
|
||||
// one of the two entries carries the data and the other is zero-size.
|
||||
let (ino1, nlink1, size1, data1) = get("group1.txt");
|
||||
let (ino1b, nlink1b, size1b, data1b) = get("group1b.txt");
|
||||
assert_eq!(nlink1, 2);
|
||||
assert_eq!(nlink1b, 2);
|
||||
assert_eq!(ino1, ino1b, "both names of a hard link must share an inode");
|
||||
assert_ne!(ino1, 0, "hard-link group must get a synthetic inode");
|
||||
assert_eq!(size1 + size1b, 5);
|
||||
assert_eq!(
|
||||
data1
|
||||
.iter()
|
||||
.chain(data1b.iter())
|
||||
.cloned()
|
||||
.collect::<Vec<u8>>(),
|
||||
b"hello".to_vec()
|
||||
);
|
||||
|
||||
// The other group gets a different inode — this is what keeps the
|
||||
// kernel from linking group2 names to group1's file
|
||||
let (ino2, _, size2, data2) = get("group2.txt");
|
||||
let (ino2b, _, size2b, data2b) = get("group2b.txt");
|
||||
assert_eq!(ino2, ino2b);
|
||||
assert_ne!(
|
||||
ino2, ino1,
|
||||
"distinct hard-link groups must not share an inode"
|
||||
);
|
||||
assert_eq!(size2 + size2b, 6);
|
||||
assert_eq!(
|
||||
data2
|
||||
.iter()
|
||||
.chain(data2b.iter())
|
||||
.cloned()
|
||||
.collect::<Vec<u8>>(),
|
||||
b"world!".to_vec()
|
||||
);
|
||||
}
|
||||
Ok(entries)
|
||||
|
||||
#[test]
|
||||
fn test_regular_files_have_zero_inode_and_data() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("sub")).unwrap();
|
||||
std::fs::write(dir.path().join("sub/plain.txt"), b"plain").unwrap();
|
||||
|
||||
let pb = ProgressBar::hidden();
|
||||
let mut archive = Vec::new();
|
||||
write_cpio_archive(dir.path(), &mut archive, &pb).unwrap();
|
||||
|
||||
let entries = parse_cpio(archive);
|
||||
let find = |name: &str| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|(n, ..)| n == name)
|
||||
.map(|(_, ino, mode, nlink, size, data)| (*ino, *mode, *nlink, *size, data.clone()))
|
||||
.expect("entry missing")
|
||||
};
|
||||
|
||||
// Regular files with nlink=1 keep inode 0 and must keep their data
|
||||
let (ino, _, nlink, size, data) = find("sub/plain.txt");
|
||||
assert_eq!(ino, 0);
|
||||
assert_eq!((nlink, size), (1, 5));
|
||||
assert_eq!(data, b"plain");
|
||||
|
||||
// The init script is always appended, executable, and non-empty
|
||||
let (_, init_mode, _, init_size, init_data) = find("init");
|
||||
assert_eq!(init_mode, 0o100755);
|
||||
assert!(init_size > 0);
|
||||
assert!(init_data.starts_with(b"#!/bin/sh"));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-11
@@ -250,10 +250,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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");
|
||||
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]
|
||||
@@ -263,13 +263,6 @@ mod tests {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user