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.
This commit is contained in:
2026-09-20 22:37:33 +02:00
parent d64da0671b
commit 240a66c532
3 changed files with 315 additions and 120 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"
+313 -120
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,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<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"));
}
}