Compare commits

..
4 Commits
Author SHA1 Message Date
vhaudiquet 2c47a5c662 docs: update QEMU mode docs for =PATH syntax and uncompressed initramfs
CI / Check (push) Successful in 1m7s
CI / Format (push) Successful in 14s
CI / Clippy (push) Successful in 1m8s
CI / Test (push) Successful in 1m24s
2026-09-20 22:37:33 +02:00
vhaudiquet 240a66c532 fix: initramfs hard links, streaming archive, verbatim argv transport
- hard links in the rootfs were written as zero-size entries with no
  inode set (all zero), so the kernel could link them to the wrong file;
  assign a synthetic inode per (device, inode) group.
- build the cpio archive by streaming entries to disk instead of holding
  the whole archive in memory.
- the VM command was joined into a single string and shell-expanded in
  the guest, mangling quotes and nested commands; pass the argv
  base64-encoded (ECR_ARGV) and exec it verbatim.
- parse ecr parameters from /proc/cmdline in the init script, set the
  hostname via procfs (arch has no hostname binary), and give the serial
  console a moment to drain before poweroff.
2026-09-20 22:37:33 +02:00
vhaudiquet d64da0671b fix: --kernel =PATH syntax, exit-code cleanup, bind warning in VM mode
- --kernel took an optional value greedily, swallowing the DISTRO
  positional: 'ecr --kernel alpine' failed with a missing-argument error
  and 'ecr --kernel alpine -- cmd' pulled a garbage OCI ref. require
  =PATH syntax for the kernel path instead.
- setup_namespaces returns the child's exit code (or 128+signal) instead
  of calling process::exit, so the extracted rootfs tempdir is cleaned
  up before exiting instead of leaking into /tmp.
- warn that --bind/--bind-rw are ignored when booting with --kernel.
2026-09-20 22:37:11 +02:00
vhaudiquet ef00776414 fix: return Ok after extracting kernel from APK
extract_kernel_from_apk fell through to an unconditional error after a
successful extraction, so every first --kernel run failed after
downloading the 45MB package; the second run worked from cache. Also
remove the downloaded APK even when extraction fails.
2026-09-20 22:36:49 +02:00
9 changed files with 388 additions and 164 deletions
Generated
+1
View File
@@ -301,6 +301,7 @@ name = "ecr"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"clap",
"cpio",
"dirs",
+1
View File
@@ -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"
+7 -5
View File
@@ -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. Downloads Alpine's `linux-virt` kernel if no path provided |
| `--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
@@ -62,8 +62,8 @@ ecr --no-cache fedora
# Boot with QEMU system emulation (auto-downloads default kernel)
ecr --kernel alpine
# Boot with your own kernel
ecr --kernel /boot/vmlinuz ubuntu
# Boot with your own kernel (note the `=` — a space would parse the path as the distro)
ecr --kernel=/boot/vmlinuz ubuntu
# Boot with custom memory
ecr --kernel --memory 4G alpine
@@ -78,16 +78,18 @@ When `--kernel` is specified, ecr boots the rootfs in a full QEMU virtual machin
ecr --kernel alpine
# Use your own kernel
ecr --kernel /boot/vmlinuz ubuntu
ecr --kernel=/boot/vmlinuz ubuntu
```
This mode:
- Creates a gzipped CPIO initramfs from the rootfs
- Creates an uncompressed CPIO initramfs from the rootfs (streamed to disk)
- Boots QEMU with your kernel (or auto-downloads Alpine's `linux-virt` kernel)
- Provides full VM isolation
- Works for any architecture (no binfmt_misc needed)
- Caches the default kernel in `~/.cache/ecr/`
Host bind mounts (`--bind`, `--bind-rw`) are not applied in this mode.
Requirements:
- `qemu-system-<arch>` installed
- For custom kernels: kernel must have serial console support
+20 -17
View File
@@ -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
+11 -3
View File
@@ -33,12 +33,20 @@ pub struct Args {
#[arg(short = 'v', long)]
pub verbose: bool,
/// Boot with QEMU system emulation (optionally specify kernel path, or omit to download default)
/// 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
#[arg(long, value_name = "KERNEL_PATH", num_args = 0..=1)]
/// --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)
+5 -5
View File
@@ -224,12 +224,11 @@ fn download_alpine_kernel(branch: &str, arch: &str, dest: &Path) -> Result<()> {
// Extract vmlinuz-virt from the APK
// APK files are gzip-compressed tar archives
let temp_apk = dest.with_extension("apk");
extract_kernel_from_apk(&temp_apk, dest)?;
// Clean up the APK
// 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();
Ok(())
result
}
async fn download_kernel_async(url: &str, dest: &Path) -> Result<()> {
@@ -316,6 +315,7 @@ fn extract_kernel_from_apk(apk_path: &Path, dest: &Path) -> Result<()> {
// Extract to destination
entry.unpack(dest).context("Failed to extract kernel")?;
veprintln!(" Extracted kernel to: {}", dest.display());
return Ok(());
}
}
+21 -5
View File
@@ -130,11 +130,20 @@ fn main() -> Result<()> {
extract_tarball(&cache_path, &rootfs)?;
// Branch based on --kernel flag
// Option<Option<PathBuf>>:
// 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
// Some(Some(path)) -> --kernel=/path/to/vmlinuz, use provided kernel
if let Some(kernel_opt) = &args.kernel {
// VM mode boots an initramfs: host bind mounts are never applied
if !args.bind.is_empty() || !args.bind_rw.is_empty() {
eprintln!(
"Warning: --bind/--bind-rw are ignored with --kernel \
(the VM boots from an initramfs, no host directories are mounted)"
);
}
// QEMU system mode
let kernel_path = match kernel_opt {
Some(path) => {
@@ -169,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
View File
@@ -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),
}
}
+306 -113
View File
@@ -97,10 +97,19 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> {
};
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{} ECR_SHELL={} ECR_CMD=\"{}\" ECR_HOSTNAME={}",
quiet_flag, shell, cmd_str, hostname
"console=ttyS0{} ECR_SHELL={} ECR_ARGV={} ECR_HOSTNAME={}",
quiet_flag, shell, argv_b64, hostname
)
} else {
format!(
@@ -226,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
@@ -247,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,
@@ -266,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)
@@ -317,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;
}
@@ -331,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")?;
}
@@ -348,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
@@ -360,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
@@ -371,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<_, _>>()?,
@@ -423,7 +474,7 @@ fn collect_entries(
current.display(),
e
);
return Ok(entries);
return Ok(());
}
};
@@ -465,17 +516,22 @@ 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());
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) => {
@@ -483,7 +539,8 @@ fn collect_entries(
continue;
}
};
(data, metadata.nlink() as u32)
(data, metadata.nlink() as u32, cpio_ino)
}
}
} else if file_type.is_file() {
// Regular file with nlink=1
@@ -494,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;
@@ -505,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"));
}
}