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" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64",
"clap", "clap",
"cpio", "cpio",
"dirs", "dirs",
+1
View File
@@ -37,6 +37,7 @@ anyhow = "1"
dirs = "6" dirs = "6"
which = "7" which = "7"
cpio = "0.4" cpio = "0.4"
base64 = "0.22"
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"
+7 -5
View File
@@ -38,7 +38,7 @@ ecr [OPTIONS] <DISTRO[:VERSION]> [-- COMMAND...]
| `--no-cache` | Force a fresh download, bypassing the cache | | `--no-cache` | Force a fresh download, bypassing the cache |
| `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) | | `-v, --verbose` | Print diagnostic output (URLs, layer info, extraction steps) |
| `-a, --arch <ARCH>` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) | | `-a, --arch <ARCH>` | Target architecture (`amd64`, `arm64`, `armhf`, `riscv64`, …) |
| `--kernel [PATH]` | Boot with QEMU system emulation. Downloads Alpine's `linux-virt` kernel if no path provided | | `--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`) | | `-m, --memory <SIZE>` | Memory for QEMU VM (default: 2G, only with `--kernel`) |
## Examples ## Examples
@@ -62,8 +62,8 @@ ecr --no-cache fedora
# Boot with QEMU system emulation (auto-downloads default kernel) # Boot with QEMU system emulation (auto-downloads default kernel)
ecr --kernel alpine ecr --kernel alpine
# Boot with your own kernel # Boot with your own kernel (note the `=` — a space would parse the path as the distro)
ecr --kernel /boot/vmlinuz ubuntu ecr --kernel=/boot/vmlinuz ubuntu
# Boot with custom memory # Boot with custom memory
ecr --kernel --memory 4G alpine 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 ecr --kernel alpine
# Use your own kernel # Use your own kernel
ecr --kernel /boot/vmlinuz ubuntu ecr --kernel=/boot/vmlinuz ubuntu
``` ```
This mode: 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) - Boots QEMU with your kernel (or auto-downloads Alpine's `linux-virt` kernel)
- Provides full VM isolation - Provides full VM isolation
- Works for any architecture (no binfmt_misc needed) - Works for any architecture (no binfmt_misc needed)
- Caches the default kernel in `~/.cache/ecr/` - Caches the default kernel in `~/.cache/ecr/`
Host bind mounts (`--bind`, `--bind-rw`) are not applied in this mode.
Requirements: Requirements:
- `qemu-system-<arch>` installed - `qemu-system-<arch>` installed
- For custom kernels: kernel must have serial console support - 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) | | `--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) | | `--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`) | | `-m, --memory <size>` | 2G | Memory size for QEMU VM (only used with `--kernel`) |
| `-v, --verbose` | false | Print diagnostic messages | | `-v, --verbose` | false | Print diagnostic messages |
| `-h, --help` | - | Show help | | `-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 ## 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 ### 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 ```sh
ecr --kernel /boot/vmlinuz ubuntu:noble ecr --kernel ubuntu:noble
ecr --kernel /boot/vmlinuz --memory 4G alpine ecr --kernel=/boot/vmlinuz ubuntu:noble
ecr --kernel /boot/vmlinuz debian -- /bin/sh -c "echo hello" ecr --kernel=/boot/vmlinuz --memory 4G alpine
ecr --kernel=/boot/vmlinuz debian -- /bin/sh -c "echo hello"
``` ```
### Execution Flow ### Execution Flow
1. Download/cache rootfs tarball (same as namespace mode) 1. Download/cache rootfs tarball (same as namespace mode)
2. Extract tarball to temporary directory 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: 4. Launch QEMU with:
- `-kernel <path>` - provided kernel - `-kernel <path>` - provided (or downloaded) kernel
- `-initrd initramfs.cpio.gz` - rootfs as initramfs - `-initrd initramfs.cpio` - 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 - `-append "console=ttyS0 [quiet] ECR_SHELL=... [ECR_ARGV=...] ECR_HOSTNAME=..."` - kernel command line (`quiet` unless `-v`)
7. Essential device nodes (/dev/ttyS0, /dev/null, /dev/tty) are added to initramfs for proper console support
- `-m <memory>` - memory size (default 2G) - `-m <memory>` - memory size (default 2G)
- `-display none -serial mon:stdio` - console on stdio - `-display none -serial mon:stdio` - console on stdio
- `-netdev user,id=net0 -device virtio-net-pci,netdev=net0` - network - `-netdev user,id=net0 -device virtio-net-pci,netdev=net0` - network NIC
5. Wait for QEMU to exit - `-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 6. Cleanup temporary files
### Initramfs Creation ### 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 ### Architecture Support
@@ -238,11 +241,11 @@ The rootfs directory is converted to a gzipped CPIO archive (newc format) using
| Feature | Namespace Mode | QEMU Mode | | Feature | Namespace Mode | QEMU Mode |
|---------|---------------|-----------| |---------|---------------|-----------|
| Isolation | User namespace | Full VM | | Isolation | User namespace | Full VM |
| Performance | Near-native | Emulated (slower) | | Performance | Near-native | Emulated (KVM-accelerated when available) |
| Root access | No | No | | Root access | No | Yes (inside the VM) |
| Foreign arch | binfmt_misc required | Built-in emulation | | Foreign arch | binfmt_misc required | Built-in emulation |
| Bind mounts | Overlay/bind | Not supported | | Bind mounts | Overlay/bind | Not supported (flags are ignored with a warning) |
| Network | Host network | User-mode network | | Network | Host network | User-mode NIC (not configured inside the guest) |
## File Handling ## File Handling
+11 -3
View File
@@ -33,12 +33,20 @@ pub struct Args {
#[arg(short = 'v', long)] #[arg(short = 'v', long)]
pub verbose: bool, pub verbose: bool,
/// Boot with QEMU system emulation (optionally specify kernel path, or omit to download default) /// Boot with QEMU system emulation (optionally specify kernel path with =PATH, or omit to download default)
/// ///
/// Examples: /// Examples:
/// --kernel Download and use the default Alpine linux-virt kernel /// --kernel Download and use the default Alpine linux-virt kernel
/// --kernel ./vmlinuz Use a specific kernel file /// --kernel=./vmlinuz Use a specific kernel file
#[arg(long, value_name = "KERNEL_PATH", num_args = 0..=1)] ///
/// 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>>, pub kernel: Option<Option<PathBuf>>,
/// Memory size for QEMU VM (only used with --kernel, e.g., 512M, 2G) /// 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 // Extract vmlinuz-virt from the APK
// APK files are gzip-compressed tar archives // APK files are gzip-compressed tar archives
let temp_apk = dest.with_extension("apk"); let temp_apk = dest.with_extension("apk");
extract_kernel_from_apk(&temp_apk, dest)?; // Remove the ~45MB APK whatever the extraction outcome — don't leave
// it behind in the cache directory on failure.
// Clean up the APK let result = extract_kernel_from_apk(&temp_apk, dest);
std::fs::remove_file(&temp_apk).ok(); std::fs::remove_file(&temp_apk).ok();
result
Ok(())
} }
async fn download_kernel_async(url: &str, dest: &Path) -> 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 // Extract to destination
entry.unpack(dest).context("Failed to extract kernel")?; entry.unpack(dest).context("Failed to extract kernel")?;
veprintln!(" Extracted kernel to: {}", dest.display()); veprintln!(" Extracted kernel to: {}", dest.display());
return Ok(());
} }
} }
+21 -5
View File
@@ -130,11 +130,20 @@ fn main() -> Result<()> {
extract_tarball(&cache_path, &rootfs)?; extract_tarball(&cache_path, &rootfs)?;
// Branch based on --kernel flag // 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 // None -> --kernel not specified, use namespace mode
// Some(None) -> --kernel without path, download default kernel // 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 { 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 // QEMU system mode
let kernel_path = match kernel_opt { let kernel_path = match kernel_opt {
Some(path) => { Some(path) => {
@@ -169,12 +178,19 @@ fn main() -> Result<()> {
result result
} else { } else {
// Namespace/chroot mode // 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 /// Run in namespace/chroot mode, returning the command's exit code
fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Result<()> { fn namespace_mode(args: Args, rootfs: std::path::PathBuf, config: Config) -> Result<i32> {
// Check user namespace availability // Check user namespace availability
namespace::check_user_namespace()?; namespace::check_user_namespace()?;
+8 -8
View File
@@ -58,8 +58,10 @@ pub fn check_user_namespace() -> Result<()> {
Ok(()) Ok(())
} }
/// Setup namespaces and run the provided function inside them /// Setup namespaces and run the provided function inside them.
pub fn setup_namespaces<F>(f: F) -> Result<()> /// 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 where
F: FnOnce() -> Result<()> + Send + 'static, F: FnOnce() -> Result<()> + Send + 'static,
{ {
@@ -222,20 +224,18 @@ where
let status = nix::sys::wait::waitpid(pid, None)?; let status = nix::sys::wait::waitpid(pid, None)?;
match status { match status {
nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(()), nix::sys::wait::WaitStatus::Exited(_, 0) => Ok(0),
nix::sys::wait::WaitStatus::Exited(_, code) => { nix::sys::wait::WaitStatus::Exited(_, code) => {
// If the child reported an error (e.g., setup failure), return it. // If the child reported an error (e.g., setup failure), return it.
// Otherwise, just forward the exit code without an error message. // Otherwise, just forward the exit code without an error message.
if let Some(msg) = child_error { if let Some(msg) = child_error {
Err(anyhow!("{}", msg)) Err(anyhow!("{}", msg))
} else { } else {
std::process::exit(code); Ok(code)
} }
} }
nix::sys::wait::WaitStatus::Signaled(_, sig, _) => { nix::sys::wait::WaitStatus::Signaled(_, sig, _) => Ok(128 + sig as i32),
Err(anyhow!("Child process killed by signal {:?}", sig)) _ => Ok(0),
}
_ => Ok(()),
} }
} }
+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 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!( format!(
"console=ttyS0{} ECR_SHELL={} ECR_CMD=\"{}\" ECR_HOSTNAME={}", "console=ttyS0{} ECR_SHELL={} ECR_ARGV={} ECR_HOSTNAME={}",
quiet_flag, shell, cmd_str, hostname quiet_flag, shell, argv_b64, hostname
) )
} else { } else {
format!( format!(
@@ -226,10 +235,9 @@ fn check_kvm_capabilities() -> bool {
} }
} }
/// Create an uncompressed cpio initramfs from a directory /// Create an uncompressed cpio initramfs from a directory.
/// A filesystem entry for cpio archives: (name, mode, mtime, nlink, data) /// Entries are streamed straight to disk so large rootfs images never have
type CpioEntry = (String, u32, u32, u32, Vec<u8>); /// to fit in memory as a whole archive.
fn create_initramfs(rootfs: &Path) -> Result<PathBuf> { fn create_initramfs(rootfs: &Path) -> Result<PathBuf> {
// Create a temporary file for the initramfs (uncompressed cpio) // Create a temporary file for the initramfs (uncompressed cpio)
// Use a temp file in the same directory as rootfs, or fall back to /tmp // 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..."); pb.set_message("Creating initramfs...");
// Create the cpio archive with progress let file = std::fs::File::create(&initramfs_path)
let cpio_data = create_cpio_archive(rootfs, &pb)?; .with_context(|| format!("Failed to create {}", initramfs_path.display()))?;
let total_bytes = cpio_data.len() as u64; let mut writer = std::io::BufWriter::new(file);
let file_count = pb.position();
// Write directly to file (no compression) let result = write_cpio_archive(rootfs, &mut writer, &pb);
std::fs::write(&initramfs_path, &cpio_data).context("Failed to write initramfs file")?; 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 // Finish progress bar
let file_count = pb.position();
pb.finish_and_clear(); pb.finish_and_clear();
let total_bytes = std::fs::metadata(&initramfs_path)
.map(|m| m.len())
.unwrap_or(0);
veprintln!( veprintln!(
"Initramfs created: {} bytes, {} files", "Initramfs created: {} bytes, {} files",
total_bytes, total_bytes,
@@ -266,43 +284,34 @@ fn create_initramfs(rootfs: &Path) -> Result<PathBuf> {
Ok(initramfs_path) Ok(initramfs_path)
} }
/// Create a newc-format cpio archive from a directory using the cpio crate /// Write the newc-format cpio archive for a directory tree, streaming to `writer`
fn create_cpio_archive(rootfs: &Path, pb: &ProgressBar) -> Result<Vec<u8>> { fn write_cpio_archive<W: Write>(rootfs: &Path, writer: &mut W, pb: &ProgressBar) -> Result<()> {
let mut archive = Vec::new(); // 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 write_dir_entries(
let mut seen_inodes = std::collections::HashMap::new(); rootfs,
rootfs,
writer,
pb,
&mut seen_inodes,
&mut next_ino,
&mut written_names,
&mut total_data,
)?;
// Collect all entries with their data veprintln!(
let entries = collect_entries(rootfs, rootfs, pb, &mut seen_inodes)?; "Collected {} entries, {} bytes total data",
written_names.len(),
// Collect entry names for checking existence later total_data
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 // Add essential device nodes for serial console
// These are character devices (mode 0o020xxx) // 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 { for (name, mode, major, minor) in device_nodes {
// Check if this device node already exists in the archive // Check if this device node already exists in the archive
if entry_names.contains(&name) { if written_names.contains(name) {
continue; continue;
} }
@@ -331,8 +340,8 @@ fn create_cpio_archive(rootfs: &Path, pb: &ProgressBar) -> Result<Vec<u8>> {
.rdev_minor(minor); .rdev_minor(minor);
// Device nodes have zero size // Device nodes have zero size
let writer = builder.write(&mut archive, 0); let entry_writer = builder.write(&mut *writer, 0);
writer entry_writer
.finish() .finish()
.context("Failed to finish device node entry")?; .context("Failed to finish device node entry")?;
} }
@@ -348,10 +357,22 @@ mount -t proc proc /proc
mount -t sysfs sysfs /sys mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev 2>/dev/null || true 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 if [ -n "$ECR_HOSTNAME" ]; then
echo "$ECR_HOSTNAME" > /etc/hostname echo "$ECR_HOSTNAME" > /etc/hostname
hostname "$ECR_HOSTNAME" echo "$ECR_HOSTNAME" > /proc/sys/kernel/hostname
fi fi
# Create console device if missing # 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 # Function to poweroff - use sysrq-trigger which works without external binaries
do_poweroff() { 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 # Silence kernel printk to suppress shutdown messages
echo 0 > /proc/sys/kernel/printk echo 0 > /proc/sys/kernel/printk
# 'o' means power off, see Documentation/admin-guide/sysrq.rst # 'o' means power off, see Documentation/admin-guide/sysrq.rst
@@ -371,49 +395,76 @@ do_poweroff() {
# Trap exit to ensure poweroff runs # Trap exit to ensure poweroff runs
trap do_poweroff EXIT trap do_poweroff EXIT
# Run the shell or command # Rebuild the argv: each element is base64-encoded, elements are separated
if [ -n "$ECR_CMD" ]; then # by commas. Decoding into "$@" avoids any shell re-parsing of the command.
# Run command with the specified shell set --
setsid sh -c "exec $ECR_SHELL </dev/ttyS0 >/dev/ttyS0 2>&1 -c '$ECR_CMD'" 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 else
# Run interactive shell
setsid sh -c "exec $ECR_SHELL </dev/ttyS0 >/dev/ttyS0 2>&1" setsid sh -c "exec $ECR_SHELL </dev/ttyS0 >/dev/ttyS0 2>&1"
fi fi
"#; "#;
let init_data = init_script.as_bytes(); write_cpio_entry(writer, "init", 0o100755, 0, 1, 0, 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); // Write the trailer
init_writer newc::trailer(&mut *writer).context("Failed to write cpio trailer")?;
.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) Ok(())
archive = newc::trailer(archive).context("Failed to write cpio trailer")?;
Ok(archive)
} }
/// Collect all filesystem entries recursively /// Write a single newc-format cpio entry
/// Uses a HashMap to track hard links by (device, inode) - only stores data for first occurrence fn write_cpio_entry<W: Write>(
fn collect_entries( 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, base: &Path,
current: &Path, current: &Path,
writer: &mut impl Write,
pb: &ProgressBar, pb: &ProgressBar,
seen_inodes: &mut std::collections::HashMap<(u64, u64), String>, seen_inodes: &mut std::collections::HashMap<(u64, u64), u32>,
) -> Result<Vec<CpioEntry>> { next_ino: &mut u32,
let mut entries = Vec::new(); written_names: &mut std::collections::HashSet<String>,
let mut total_data: u64 = 0; total_data: &mut u64,
) -> Result<()> {
// Read directory entries // Read directory entries
let dir_entries: Vec<_> = match std::fs::read_dir(current) { let dir_entries: Vec<_> = match std::fs::read_dir(current) {
Ok(entries) => entries.collect::<std::result::Result<_, _>>()?, Ok(entries) => entries.collect::<std::result::Result<_, _>>()?,
@@ -423,7 +474,7 @@ fn collect_entries(
current.display(), current.display(),
e e
); );
return Ok(entries); return Ok(());
} }
}; };
@@ -465,17 +516,22 @@ fn collect_entries(
let relative = path.strip_prefix(base).unwrap(); let relative = path.strip_prefix(base).unwrap();
let entry_name = relative.to_string_lossy().into_owned(); let entry_name = relative.to_string_lossy().into_owned();
// Handle hard links: only store data for first occurrence // Determine (data, nlink, cpio inode).
let (data, nlink) = if file_type.is_file() && metadata.nlink() > 1 { // The kernel's initramfs loader records the first entry of a hard-link
// This file has multiple hard links - check if we've seen it before // 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()); let inode_key = (metadata.dev(), metadata.ino());
match seen_inodes.get(&inode_key) {
if let Some(_first_path) = seen_inodes.get(&inode_key) { Some(seen_cpio_ino) => {
// We've seen this inode before - create a hard link entry with no data // Subsequent occurrence: zero-size hard-link entry
(Vec::new(), metadata.nlink() as u32) (Vec::new(), metadata.nlink() as u32, *seen_cpio_ino)
} else { }
// First occurrence - read the data and record this inode None => {
seen_inodes.insert(inode_key, entry_name.clone()); // 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) { let data = match std::fs::read(&path) {
Ok(data) => data, Ok(data) => data,
Err(e) => { Err(e) => {
@@ -483,7 +539,8 @@ fn collect_entries(
continue; continue;
} }
}; };
(data, metadata.nlink() as u32) (data, metadata.nlink() as u32, cpio_ino)
}
} }
} else if file_type.is_file() { } else if file_type.is_file() {
// Regular file with nlink=1 // Regular file with nlink=1
@@ -494,10 +551,10 @@ fn collect_entries(
continue; continue;
} }
}; };
(data, 1) (data, 1, 0)
} else if file_type.is_symlink() { } else if file_type.is_symlink() {
match std::fs::read_link(&path) { 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) => { Err(e) => {
veprintln!("Warning: cannot read symlink {}: {}", path.display(), e); veprintln!("Warning: cannot read symlink {}: {}", path.display(), e);
continue; continue;
@@ -505,29 +562,165 @@ fn collect_entries(
} }
} else { } else {
// Directory // Directory
(Vec::new(), 2) (Vec::new(), 2, 0)
}; };
total_data += data.len() as u64; *total_data += data.len() as u64;
entries.push((entry_name, mode, metadata.mtime() as u32, nlink, data));
write_cpio_entry(
writer,
&entry_name,
mode,
metadata.mtime() as u32,
nlink,
cpio_ino,
&data,
)?;
written_names.insert(entry_name);
// Recurse into directories // Recurse into directories
if file_type.is_dir() { if file_type.is_dir() {
let mut sub_entries = collect_entries(base, &path, pb, seen_inodes)?; write_dir_entries(
for (_, _, _, _, d) in &sub_entries { base,
total_data += d.len() as u64; &path,
} writer,
entries.append(&mut sub_entries); pb,
seen_inodes,
next_ino,
written_names,
total_data,
)?;
} }
} }
// Only print summary for the root directory Ok(())
if current == base { }
veprintln!(
"Collected {} entries, {} bytes total data", #[cfg(test)]
entries.len(), mod tests {
total_data 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"));
}
} }