From 931a6dcfd5d13a9800e808b7a8b9e9b76959949b Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 16 Jun 2026 20:31:25 +0200 Subject: [PATCH] feat: use cpio crate for initramfs creation Add the `cpio` crate as dependency, removing e2fsprogs external dependency. --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 7 +- SPEC.md | 21 ++- src/qemu_vm.rs | 348 ++++++++++++++++++++----------------------------- 5 files changed, 163 insertions(+), 221 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec0da83..ead2cc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,6 +243,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpio" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "938e716cb1ade5d6c8f959c13a7248b889c07491fc7e41167c3afe20f8f0de1e" + [[package]] name = "crc32fast" version = "1.5.0" @@ -296,6 +302,7 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "cpio", "dirs", "flate2", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 47b798c..b7e351d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ anyhow = "1" # Utilities dirs = "6" which = "7" +cpio = "0.4" tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util"] } futures-util = "0.3" indicatif = "0.18" diff --git a/README.md b/README.md index 1946627..5ba6d2d 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ ecr --arch arm64 alpine -- uname -m # Always pull a fresh image ecr --no-cache fedora -# Boot with QEMU system emulation (requires qemu-system- and e2fsprogs) +# Boot with QEMU system emulation (requires qemu-system-) ecr --kernel /boot/vmlinuz ubuntu # Boot with custom memory @@ -75,15 +75,14 @@ ecr --kernel /boot/vmlinuz alpine ``` This mode: -- Creates an ext4 disk image from the rootfs +- Creates a gzipped CPIO initramfs from the rootfs - Boots QEMU with your kernel - Provides full VM isolation - Works for any architecture (no binfmt_misc needed) Requirements: - `qemu-system-` installed -- `e2fsprogs` for disk image creation -- Kernel with virtio support +- Kernel with serial console support ## Supported distributions diff --git a/SPEC.md b/SPEC.md index da3b1ba..6026d57 100644 --- a/SPEC.md +++ b/SPEC.md @@ -187,7 +187,7 @@ 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 disk image 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 a gzipped CPIO initramfs and booted with the provided kernel. ### Usage @@ -201,24 +201,20 @@ ecr --kernel /boot/vmlinuz debian -- /bin/sh -c "echo hello" 1. Download/cache rootfs tarball (same as namespace mode) 2. Extract tarball to temporary directory -3. Create ext4 disk image from rootfs using `mke2fs -d` (requires `e2fsprogs`) +3. Create gzipped CPIO initramfs from rootfs 4. Launch QEMU with: - `-kernel ` - provided kernel - - `-append "root=/dev/vda rw console=ttyS0"` - kernel command line + - `-initrd initramfs.cpio.gz` - rootfs as initramfs + - `-append "console=ttyS0 rdinit=/bin/sh"` - kernel command line - `-m ` - memory size (default 2G) - - `-nographic` - console on stdio - - `-drive file=rootfs.img,format=raw,if=virtio` - rootfs disk + - `-display none -serial mon:stdio` - console on stdio - `-netdev user,id=net0 -device virtio-net-pci,netdev=net0` - network 5. Wait for QEMU to exit 6. Cleanup temporary files -### Disk Image Creation +### Initramfs Creation -The rootfs directory is converted to an ext4 disk image using `mke2fs -t ext4 -d `. This requires the `e2fsprogs` package: - -- Ubuntu/Debian: `sudo apt install e2fsprogs` -- Arch: `sudo pacman -S e2fsprogs` -- Alpine: `sudo apk add e2fsprogs` +The rootfs directory is converted to a gzipped CPIO archive (newc format) using the `cpio` crate. ### Architecture Support @@ -234,8 +230,7 @@ The rootfs directory is converted to an ext4 disk image using `mke2fs -t ext4 -d ### Requirements - QEMU system emulator installed (`qemu-system-`) -- `e2fsprogs` for disk image creation -- Kernel with virtio support (for disk and network drivers) +- Kernel with required drivers (serial console, virtio-net for network) ### Differences from Namespace Mode diff --git a/src/qemu_vm.rs b/src/qemu_vm.rs index d6f34e4..a3f998f 100644 --- a/src/qemu_vm.rs +++ b/src/qemu_vm.rs @@ -1,8 +1,10 @@ use crate::veprintln; use anyhow::{anyhow, Context, Result}; +use cpio::{newc, NewcBuilder}; use std::io::Write; -use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::process::Command; /// QEMU system emulation configuration pub struct QemuConfig { @@ -36,8 +38,8 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> { )); } - // Create a disk image from the rootfs - let disk_image = create_disk_image(&config.rootfs_path)?; + // Create a gzipped cpio initramfs from the rootfs + let initramfs = create_initramfs(&config.rootfs_path)?; // Get QEMU binary for architecture let qemu_bin = qemu_binary_for_arch(&config.arch); @@ -52,22 +54,22 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> { ))?; // Build kernel command line - // Container rootfs images don't have /sbin/init - they expect a command as PID 1 - // We use init=/bin/sh as default, and if a command is specified, we pass it to sh + // For initramfs boot, use rdinit= instead of init= + // No root= needed as initramfs becomes the rootfs let kernel_append = if let Some(ref cmd) = config.command { let cmd_str = cmd.join(" "); format!( - "root=/dev/vda rw console=ttyS0 init=/bin/sh -- -c \"{}\"", + "console=ttyS0 rdinit=/bin/sh -- -c \"{}\"", cmd_str ) } else { // Default to interactive shell - "root=/dev/vda rw console=ttyS0 init=/bin/sh".to_string() + "console=ttyS0 rdinit=/bin/sh".to_string() }; veprintln!("Launching QEMU: {}", qemu_bin); veprintln!(" Kernel: {}", config.kernel_path.display()); - veprintln!(" Disk image: {}", disk_image.display()); + veprintln!(" Initramfs: {}", initramfs.display()); veprintln!(" Memory: {}", config.memory); veprintln!(" Kernel append: {}", kernel_append); @@ -77,6 +79,8 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> { let args = vec![ "-kernel".to_string(), config.kernel_path.to_string_lossy().to_string(), + "-initrd".to_string(), + initramfs.to_string_lossy().to_string(), "-append".to_string(), kernel_append, "-m".to_string(), @@ -85,11 +89,6 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> { "none".to_string(), "-serial".to_string(), "mon:stdio".to_string(), - "-drive".to_string(), - format!( - "file={},format=raw,if=virtio", - disk_image.to_string_lossy() - ), "-netdev".to_string(), "user,id=net0".to_string(), "-device".to_string(), @@ -102,9 +101,9 @@ pub fn launch_qemu(config: QemuConfig) -> Result<()> { .status() .context("Failed to execute QEMU")?; - // Cleanup disk image - if let Err(e) = std::fs::remove_file(&disk_image) { - veprintln!("Warning: failed to cleanup disk image: {}", e); + // Cleanup initramfs + if let Err(e) = std::fs::remove_file(&initramfs) { + veprintln!("Warning: failed to cleanup initramfs: {}", e); } if !status.success() { @@ -143,203 +142,144 @@ fn get_arch_package_suffix(arch: &str) -> &str { } } -/// Create a raw disk image from a directory -fn create_disk_image(rootfs: &PathBuf) -> Result { - veprintln!("Creating disk image from rootfs..."); +/// Create a gzipped cpio initramfs from a directory +fn create_initramfs(rootfs: &PathBuf) -> Result { + veprintln!("Creating initramfs from rootfs..."); - // Create a temporary file for the disk image - let disk_image = rootfs.parent().unwrap().join("rootfs.img"); + // Create a temporary file for the initramfs + let initramfs_path = rootfs.parent().unwrap().join("initramfs.cpio.gz"); - // Use mke2fs to create an ext4 filesystem image - // First, calculate size needed (du -sb) - let du_output = Command::new("du") - .arg("-sb") - .arg(rootfs) - .output() - .context("Failed to calculate rootfs size")?; + // Create the cpio archive + let cpio_data = create_cpio_archive(rootfs)?; - let size_str = String::from_utf8_lossy(&du_output.stdout); - let size: u64 = size_str - .split_whitespace() - .next() - .context("Failed to parse du output")? - .parse() - .context("Failed to parse size")?; - - // Add 50% overhead for filesystem metadata, journal, and some free space - // ext4 with journal can have significant overhead - let image_size = size + (size / 2); - // Minimum 64MB for small rootfs to ensure enough space for metadata - let image_size = image_size.max(64 * 1024 * 1024); - - veprintln!("Rootfs size: {} bytes, image size: {} bytes", size, image_size); - - // Create the image file - let image_file = std::fs::File::create(&disk_image) - .context("Failed to create disk image file")?; + // Compress with gzip + let mut output_file = std::fs::File::create(&initramfs_path) + .context("Failed to create initramfs file")?; - // Pre-allocate the file - image_file - .set_len(image_size) - .context("Failed to allocate disk image")?; - drop(image_file); + let mut encoder = flate2::write::GzEncoder::new(&mut output_file, flate2::Compression::default()); + encoder.write_all(&cpio_data) + .context("Failed to write compressed initramfs")?; + encoder.finish() + .context("Failed to finalize gzip compression")?; - // Try to use mke2fs to create an ext4 image with the directory contents - // This is the most efficient way on Linux - veprintln!("Running: mke2fs -t ext4 -d {} {}", rootfs.display(), disk_image.display()); - let mke2fs_result = Command::new("mke2fs") - .arg("-t") - .arg("ext4") - .arg("-d") - .arg(rootfs) - .arg(&disk_image) - .output(); + veprintln!("Initramfs created: {} bytes (uncompressed)", cpio_data.len()); - match mke2fs_result { - Ok(output) => { - if output.status.success() { - veprintln!("Disk image created successfully with mke2fs"); - // Verify the image has content by checking if we can list files - let verify = Command::new("debugfs") - .arg("-R") - .arg("ls -l /") - .arg(&disk_image) - .output(); - if let Ok(v) = verify { - veprintln!("Root directory contents:\n{}", String::from_utf8_lossy(&v.stdout)); - } - return Ok(disk_image); - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - veprintln!("mke2fs failed!"); - veprintln!(" stdout: {}", stdout); - veprintln!(" stderr: {}", stderr); - // Don't continue if mke2fs exists but failed - it's the only reliable method - return Err(anyhow!( - "mke2fs -d failed to create disk image.\n\ - stdout: {}\n\ - stderr: {}", - stdout, stderr - )); - } - } - Err(e) => { - veprintln!("mke2fs not available: {}", e); - } - } - - // Fallback: create a simple ext4 image and copy files - // Try mkfs.ext4 - let mkfs_result = Command::new("mkfs.ext4") - .arg("-F") - .arg(&disk_image) - .output(); - - match mkfs_result { - Ok(output) => { - if !output.status.success() { - return Err(anyhow!( - "mkfs.ext4 failed: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - } - Err(e) => { - return Err(anyhow!( - "Neither mke2fs nor mkfs.ext4 available. Install e2fsprogs:\n\ - Ubuntu/Debian: sudo apt install e2fsprogs\n\ - Arch: sudo pacman -S e2fsprogs\n\ - Alpine: sudo apk add e2fsprogs\n\ - Error: {}", - e - )); - } - } - - // Mount the image and copy files - veprintln!("Mounting disk image and copying files..."); - - // Use debugfs to copy files (doesn't require root/mount) - let debugfs_result = copy_with_debugfs(rootfs, &disk_image)?; - - if debugfs_result { - veprintln!("Disk image created successfully"); - return Ok(disk_image); - } - - // If debugfs failed, we need to try mounting (requires root or fuse) - // This is a last resort - Err(anyhow!( - "Could not create disk image. Please ensure e2fsprogs is installed with mke2fs support.\n\ - The mke2fs -d option is required for non-root disk image creation." - )) + Ok(initramfs_path) } -/// Copy files to the disk image using debugfs -fn copy_with_debugfs(rootfs: &PathBuf, disk_image: &PathBuf) -> Result { - // Use debugfs to write files - this doesn't require mounting - let mut debugfs = match Command::new("debugfs") - .arg("-w") - .arg(disk_image) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(_) => return Ok(false), +/// Create a newc-format cpio archive from a directory using the cpio crate +fn create_cpio_archive(rootfs: &Path) -> Result> { + let mut archive = Vec::new(); + + // Collect all entries with their data + let entries = collect_entries(rootfs, rootfs)?; + + // 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")?; + } + + // Write the trailer (takes ownership and returns the writer) + archive = newc::trailer(archive) + .context("Failed to write cpio trailer")?; + + Ok(archive) +} + +/// Collect all filesystem entries recursively +fn collect_entries(base: &Path, current: &Path) -> Result)>> { + let mut entries = Vec::new(); + + // Read directory entries + let dir_entries: Vec<_> = match std::fs::read_dir(current) { + Ok(entries) => entries.collect::>()?, + Err(e) => { + veprintln!("Warning: cannot read directory {}: {}", current.display(), e); + return Ok(entries); + } }; - - let stdin = debugfs.stdin.as_mut().context("Failed to open debugfs stdin")?; - - // Write files recursively - fn write_directory( - dir: &PathBuf, - prefix: &str, - stdin: &mut std::process::ChildStdin, - ) -> Result<()> { - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - let name = entry.file_name().to_string_lossy().into_owned(); - let target = if prefix.is_empty() { - format!("/{}", name) - } else { - format!("{}/{}", prefix, name) - }; - - if path.is_dir() { - // Create directory - writeln!(stdin, "mkdir {}", target)?; - write_directory(&path, &target, stdin)?; - } else { - // Write file - writeln!(stdin, "write {} {}", path.display(), target)?; + + for entry in dir_entries { + let path = entry.path(); + + // Get metadata + let metadata = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(e) => { + veprintln!("Warning: skipping {} due to metadata error: {}", path.display(), e); + continue; } + }; + + let file_type = metadata.file_type(); + + // Determine mode (file type + permissions from filesystem) + let mode = if file_type.is_dir() { + // Directory: preserve permissions, ensure at least rwx for owner + 0o040000 | (metadata.mode() & 0o7777) + } else if file_type.is_symlink() { + 0o120777 // symlink with rwxrwxrwx (permissions don't matter for symlinks) + } else if file_type.is_file() { + // Regular file: preserve permissions from filesystem + 0o100000 | (metadata.mode() & 0o7777) + } else { + continue; // Skip other types (sockets, fifos, etc.) + }; + + // Get file content or symlink target + let data: Vec = if file_type.is_file() { + match std::fs::read(&path) { + Ok(data) => data, + Err(e) => { + veprintln!("Warning: cannot read file {}: {}", path.display(), e); + continue; + } + } + } else if file_type.is_symlink() { + match std::fs::read_link(&path) { + Ok(target) => target.to_string_lossy().into_owned().into_bytes(), + Err(e) => { + veprintln!("Warning: cannot read symlink {}: {}", path.display(), e); + continue; + } + } + } else { + Vec::new() + }; + + // Build the entry name (relative path from base) + let relative = path.strip_prefix(base).unwrap(); + let entry_name = relative.to_string_lossy().into_owned(); + + // nlink: directories have 2 (. and ..), files/symlinks have 1 + let nlink = if file_type.is_dir() { 2 } else { 1 }; + + entries.push((entry_name, mode, metadata.mtime() as u32, nlink, data)); + + // Recurse into directories + if file_type.is_dir() { + let mut sub_entries = collect_entries(base, &path)?; + entries.append(&mut sub_entries); } - Ok(()) } - - if let Err(e) = write_directory(rootfs, "", stdin) { - veprintln!("debugfs write failed: {}", e); - let _ = debugfs.kill(); - return Ok(false); - } - - // stdin is implicitly dropped here when it goes out of scope - - let output = debugfs - .wait_with_output() - .context("Failed to wait for debugfs")?; - - if !output.status.success() { - veprintln!( - "debugfs failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - return Ok(false); - } - - Ok(true) + + Ok(entries) }