From 240a66c5326ab54773738c39eb741e608bb14860 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 20 Sep 2026 22:37:33 +0200 Subject: [PATCH] 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. --- Cargo.lock | 1 + Cargo.toml | 1 + src/qemu_vm.rs | 433 +++++++++++++++++++++++++++++++++++-------------- 3 files changed, 315 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ead2cc9..fd092d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,6 +301,7 @@ name = "ecr" version = "0.1.0" dependencies = [ "anyhow", + "base64", "clap", "cpio", "dirs", diff --git a/Cargo.toml b/Cargo.toml index b7e351d..ff246bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/qemu_vm.rs b/src/qemu_vm.rs index 0b9e438..abe870f 100644 --- a/src/qemu_vm.rs +++ b/src/qemu_vm.rs @@ -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::>() + .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); - +/// 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 { // 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 { ); 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 { 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> { - let mut archive = Vec::new(); +/// Write the newc-format cpio archive for a directory tree, streaming to `writer` +fn write_cpio_archive(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 = 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> { 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> { .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 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 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( + 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> { - 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, + total_data: &mut u64, +) -> Result<()> { // Read directory entries let dir_entries: Vec<_> = match std::fs::read_dir(current) { Ok(entries) => entries.collect::>()?, @@ -423,7 +474,7 @@ fn collect_entries( current.display(), e ); - return Ok(entries); + return Ok(()); } }; @@ -465,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 @@ -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) -> Vec<(String, u32, u32, u32, u32, Vec)> { + 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::)>>(); + 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::>(), + 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::>(), + 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")); + } }