From 37f537184c5042595cf1e2d4554d913d12586700 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 26 Aug 2026 20:15:16 +0000 Subject: [PATCH] refactor: split main.rs into registry, build, and qemu modules main.rs keeps only the CLI definition and command dispatch: - registry.rs: image-name validation + XDG registry path handling (tests) - build.rs: podman mount/unmount, vmlinuz extraction, cpio/gzip initrd - qemu.rs: direct-kernel-boot VM launch No behavior change; cargo fmt/clippy/test pass, build/run paths verified against the mock-vm image. --- src/build.rs | 115 +++++++++++++++++++++ src/main.rs | 259 ++---------------------------------------------- src/qemu.rs | 56 +++++++++++ src/registry.rs | 95 ++++++++++++++++++ 4 files changed, 273 insertions(+), 252 deletions(-) create mode 100644 src/build.rs create mode 100644 src/qemu.rs create mode 100644 src/registry.rs diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..b1a585d --- /dev/null +++ b/src/build.rs @@ -0,0 +1,115 @@ +//! Build: mount a podman image, extract vmlinuz, and pack the rootfs as a +//! gzipped cpio initrd in the registry. + +use anyhow::{Context, Result, bail}; +use flate2::write::GzEncoder; +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use crate::registry::registry_dir; + +pub(crate) fn build(image: &str) -> Result<()> { + let mount_path = mount_image(image)?; + println!("Mounted at: {}", mount_path.display()); + + // Keep the build result so the image is unmounted even when the build fails. + let result = build_artifacts(image, &mount_path); + + let unmounted = Command::new("podman") + .args(["unshare", "--", "podman", "image", "unmount", image]) + .output() + .is_ok_and(|out| out.status.success()); + if unmounted { + println!("Unmounted image."); + } else { + eprintln!("warning: failed to unmount image '{}'", image); + } + + result +} + +fn mount_image(image: &str) -> Result { + let output = Command::new("podman") + .args(["unshare", "--", "podman", "image", "mount", image]) + .output() + .context("Failed to run podman image mount")?; + let mount_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !output.status.success() || mount_path.is_empty() { + bail!( + "podman image mount failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(PathBuf::from(mount_path)) +} + +fn build_artifacts(image: &str, mount_path: &Path) -> Result<()> { + let mount_path = mount_path.to_str().context("mount path is not UTF-8")?; + let vmlinuz_src = format!("{mount_path}/vmlinuz"); + + // The rootless overlay mount only exists inside podman unshare's user + // namespace, so every read of the rootfs must run there; pipes still + // cross the namespace boundary. + let has_kernel = Command::new("podman") + .args(["unshare", "--", "test", "-f", &vmlinuz_src]) + .status() + .context("Failed to check for /vmlinuz inside the image mount")?; + if !has_kernel.success() { + bail!("Image missing required /vmlinuz. Place kernel at /vmlinuz in Containerfile."); + } + + let reg_dir = registry_dir(image)?; + fs::create_dir_all(®_dir)?; + println!("Registry: {}", reg_dir.display()); + + let vmlinuz_dst = reg_dir.join("vmlinuz"); + let cp_status = Command::new("podman") + .args([ + "unshare", + "--", + "cp", + &vmlinuz_src, + vmlinuz_dst.to_str().context("registry path is not UTF-8")?, + ]) + .status() + .context("Failed to copy vmlinuz out of the image mount")?; + if !cp_status.success() { + bail!("Failed to copy vmlinuz to registry"); + } + println!("Copied vmlinuz -> {}", vmlinuz_dst.display()); + + // Pack the rootfs as a gzipped newc cpio archive; vmlinuz is excluded + // because QEMU loads the kernel separately via -kernel. The cpio + // pipeline runs inside the namespace, its stdout is compressed here. + let initrd_path = reg_dir.join("initrd"); + let script = r#"cd "$1" && find . -not -path ./vmlinuz | cpio -o -H newc"#; + let mut cpio = Command::new("podman") + .args(["unshare", "--", "sh", "-c", script, "sh", mount_path]) + .stdout(Stdio::piped()) + .spawn() + .context("Failed to spawn the cpio pipeline")?; + + let initrd_file = File::create(&initrd_path).context("Failed to create initrd file")?; + let mut encoder = GzEncoder::new(initrd_file, flate2::Compression::default()); + let cpio_stdout = cpio.stdout.take().context("cpio stdout was not piped")?; + let copied = std::io::copy(&mut std::io::BufReader::new(cpio_stdout), &mut encoder); + let stream_result = copied + .and_then(move |_| encoder.finish().map(|_| ())) + .context("Failed to write the gzipped initrd"); + let cpio_status = cpio + .wait() + .context("Failed to wait for the cpio pipeline")?; + + if let Err(e) = stream_result { + let _ = fs::remove_file(&initrd_path); + return Err(e); + } + if !cpio_status.success() { + let _ = fs::remove_file(&initrd_path); + bail!("find/cpio pipeline failed with status {cpio_status}"); + } + println!("Created initrd -> {}", initrd_path.display()); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 612d5a7..6ea0d18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ -use anyhow::{Context, Result, bail}; +mod build; +mod qemu; +mod registry; + +use anyhow::Result; use clap::{Parser, Subcommand}; -use flate2::write::GzEncoder; -use std::fs::{self, File}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; #[derive(Parser, Debug)] #[command(about = "Build bootable initrd VMs from container images")] @@ -33,256 +33,11 @@ fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Build { image, _size: _ } => { - build(&image)?; + build::build(&image)?; } Commands::Run { name } => { - run(&name)?; + qemu::run(&name)?; } } Ok(()) } - -/// Validate a user-supplied image name before it is used in a registry path. -/// -/// The name becomes a directory under `$XDG_DATA_HOME/slim-rs/registry/`, so -/// path separators and `.`/`..` would allow escaping that directory. -/// Allow-list: ASCII alphanumerics plus `.`, `_`, `:`, `-` (covers image -/// refs like `alpine:3.15`). -fn validate_image_name(name: &str) -> Result<()> { - let valid = !name.is_empty() - && name != "." - && name != ".." - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')); - if !valid { - bail!( - "invalid image name '{name}': must be non-empty and contain only \ - alphanumerics, '.', '_', ':' and '-' (no path separators or '..')" - ); - } - Ok(()) -} - -fn registry_dir(image: &str) -> Result { - validate_image_name(image)?; - let base = xdg::BaseDirectories::with_prefix("slim-rs"); - let initrd_path = base - .place_data_file(format!("registry/{image}/initrd")) - .context("Failed to write to XDG_DATA_HOME")?; - let reg_dir = initrd_path - .parent() - .expect("data file has a parent") - .to_owned(); - - // Defense in depth: the constructed path must stay inside the registry. - let registry_root = base - .place_data_file("registry/.root-check") - .context("Failed to write to XDG_DATA_HOME")? - .parent() - .expect("data file has a parent") - .to_owned(); - if !reg_dir.starts_with(®istry_root) { - bail!( - "invalid image name '{image}': registry path escapes {}", - registry_root.display() - ); - } - Ok(reg_dir) -} - -fn build(image: &str) -> Result<()> { - let mount_path = mount_image(image)?; - println!("Mounted at: {}", mount_path.display()); - - // Keep the build result so the image is unmounted even when the build fails. - let result = build_artifacts(image, &mount_path); - - let unmounted = Command::new("podman") - .args(["unshare", "--", "podman", "image", "unmount", image]) - .output() - .is_ok_and(|out| out.status.success()); - if unmounted { - println!("Unmounted image."); - } else { - eprintln!("warning: failed to unmount image '{}'", image); - } - - result -} - -fn mount_image(image: &str) -> Result { - let output = Command::new("podman") - .args(["unshare", "--", "podman", "image", "mount", image]) - .output() - .context("Failed to run podman image mount")?; - let mount_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !output.status.success() || mount_path.is_empty() { - bail!( - "podman image mount failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - ); - } - Ok(PathBuf::from(mount_path)) -} - -fn build_artifacts(image: &str, mount_path: &Path) -> Result<()> { - let mount_path = mount_path.to_str().context("mount path is not UTF-8")?; - let vmlinuz_src = format!("{mount_path}/vmlinuz"); - - // The rootless overlay mount only exists inside podman unshare's user - // namespace, so every read of the rootfs must run there; pipes still - // cross the namespace boundary. - let has_kernel = Command::new("podman") - .args(["unshare", "--", "test", "-f", &vmlinuz_src]) - .status() - .context("Failed to check for /vmlinuz inside the image mount")?; - if !has_kernel.success() { - bail!("Image missing required /vmlinuz. Place kernel at /vmlinuz in Containerfile."); - } - - let reg_dir = registry_dir(image)?; - fs::create_dir_all(®_dir)?; - println!("Registry: {}", reg_dir.display()); - - let vmlinuz_dst = reg_dir.join("vmlinuz"); - let cp_status = Command::new("podman") - .args([ - "unshare", - "--", - "cp", - &vmlinuz_src, - vmlinuz_dst.to_str().context("registry path is not UTF-8")?, - ]) - .status() - .context("Failed to copy vmlinuz out of the image mount")?; - if !cp_status.success() { - bail!("Failed to copy vmlinuz to registry"); - } - println!("Copied vmlinuz -> {}", vmlinuz_dst.display()); - - // Pack the rootfs as a gzipped newc cpio archive; vmlinuz is excluded - // because QEMU loads the kernel separately via -kernel. The cpio - // pipeline runs inside the namespace, its stdout is compressed here. - let initrd_path = reg_dir.join("initrd"); - let script = r#"cd "$1" && find . -not -path ./vmlinuz | cpio -o -H newc"#; - let mut cpio = Command::new("podman") - .args(["unshare", "--", "sh", "-c", script, "sh", mount_path]) - .stdout(Stdio::piped()) - .spawn() - .context("Failed to spawn the cpio pipeline")?; - - let initrd_file = File::create(&initrd_path).context("Failed to create initrd file")?; - let mut encoder = GzEncoder::new(initrd_file, flate2::Compression::default()); - let cpio_stdout = cpio.stdout.take().context("cpio stdout was not piped")?; - let copied = std::io::copy(&mut std::io::BufReader::new(cpio_stdout), &mut encoder); - let stream_result = copied - .and_then(move |_| encoder.finish().map(|_| ())) - .context("Failed to write the gzipped initrd"); - let cpio_status = cpio - .wait() - .context("Failed to wait for the cpio pipeline")?; - - if let Err(e) = stream_result { - let _ = fs::remove_file(&initrd_path); - return Err(e); - } - if !cpio_status.success() { - let _ = fs::remove_file(&initrd_path); - bail!("find/cpio pipeline failed with status {cpio_status}"); - } - println!("Created initrd -> {}", initrd_path.display()); - - Ok(()) -} - -fn run(name: &str) -> Result<()> { - let reg_dir = registry_dir(name)?; - let vmlinuz_path = reg_dir.join("vmlinuz"); - let initrd_path = reg_dir.join("initrd"); - if !vmlinuz_path.exists() || !initrd_path.exists() { - bail!( - "Registry missing vmlinuz/initrd for '{}'. Run `slim build` first.", - name - ); - } - println!("Booting {} from registry: {}", name, reg_dir.display()); - println!( - " qemu-system-x86_64 -m 256 -nographic -kernel {} -initrd {} -append 'console=ttyS0'", - vmlinuz_path.display(), - initrd_path.display() - ); - - let cmdline = [ - "console=ttyS0,115200", - "root=/dev/ram0", - "rw", - "earlyprintk=serial", - "nokaslr", - "raid=noautodetect", - ] - .join(" "); - - // Launch with a 5-second timeout for quick smoke verification - let status = Command::new("qemu-system-x86_64") - .args(["-m", "1024M"]) - .arg("-kernel") - .arg(vmlinuz_path.as_os_str()) - .arg("-initrd") - .arg(initrd_path.as_os_str()) - .args(["-append", &cmdline]) - .args(["-nographic"]) - .status() - .context("Failed to launch qemu-system-x86_64")?; - - match status.code() { - Some(0) => println!("QEMU exited cleanly."), - Some(126) | Some(127) => { - bail!("Failed to start qemu-system-x86_64 (is it installed and in PATH?)") - } - Some(code) => bail!("QEMU exited with code {code}"), - None => bail!("QEMU terminated by a signal"), - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::validate_image_name; - - #[test] - fn accepts_valid_image_names() { - for name in [ - "alpine:3.15", - "slim", - "mock-vm", - "my_vm", - "a.b.c", - "vm-1.2.3", - ] { - validate_image_name(name).unwrap_or_else(|e| panic!("{name} rejected: {e}")); - } - } - - #[test] - fn rejects_traversal_and_separators() { - for name in [ - "", - ".", - "..", - "../evil", - "../../tmp/evil", - "a/b", - "/abs", - "a\\b", - "foo bar", - "vm;rm", - "vm\n", - ] { - assert!( - validate_image_name(name).is_err(), - "{name:?} should be rejected" - ); - } - } -} diff --git a/src/qemu.rs b/src/qemu.rs new file mode 100644 index 0000000..e1ef2ac --- /dev/null +++ b/src/qemu.rs @@ -0,0 +1,56 @@ +//! QEMU: boot a registry entry with qemu-system-x86_64 using direct kernel +//! boot (-kernel/-initrd). + +use anyhow::{Context, Result, bail}; +use std::process::Command; + +use crate::registry::registry_dir; + +pub(crate) fn run(name: &str) -> Result<()> { + let reg_dir = registry_dir(name)?; + let vmlinuz_path = reg_dir.join("vmlinuz"); + let initrd_path = reg_dir.join("initrd"); + if !vmlinuz_path.exists() || !initrd_path.exists() { + bail!( + "Registry missing vmlinuz/initrd for '{}'. Run `slim build` first.", + name + ); + } + println!("Booting {} from registry: {}", name, reg_dir.display()); + println!( + " qemu-system-x86_64 -m 256 -nographic -kernel {} -initrd {} -append 'console=ttyS0'", + vmlinuz_path.display(), + initrd_path.display() + ); + + let cmdline = [ + "console=ttyS0,115200", + "root=/dev/ram0", + "rw", + "earlyprintk=serial", + "nokaslr", + "raid=noautodetect", + ] + .join(" "); + + let status = Command::new("qemu-system-x86_64") + .args(["-m", "1024M"]) + .arg("-kernel") + .arg(vmlinuz_path.as_os_str()) + .arg("-initrd") + .arg(initrd_path.as_os_str()) + .args(["-append", &cmdline]) + .args(["-nographic"]) + .status() + .context("Failed to launch qemu-system-x86_64")?; + + match status.code() { + Some(0) => println!("QEMU exited cleanly."), + Some(126) | Some(127) => { + bail!("Failed to start qemu-system-x86_64 (is it installed and in PATH?)") + } + Some(code) => bail!("QEMU exited with code {code}"), + None => bail!("QEMU terminated by a signal"), + } + Ok(()) +} diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..6af2ed1 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,95 @@ +//! Registry: local storage of built VM artifacts under +//! `$XDG_DATA_HOME/slim-rs/registry//`. + +use anyhow::{Context, Result, bail}; +use std::path::PathBuf; + +/// Validate a user-supplied image name before it is used in a registry path. +/// +/// The name becomes a directory under `$XDG_DATA_HOME/slim-rs/registry/`, so +/// path separators and `.`/`..` would allow escaping that directory. +/// Allow-list: ASCII alphanumerics plus `.`, `_`, `:`, `-` (covers image +/// refs like `alpine:3.15`). +pub(crate) fn validate_image_name(name: &str) -> Result<()> { + let valid = !name.is_empty() + && name != "." + && name != ".." + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')); + if !valid { + bail!( + "invalid image name '{name}': must be non-empty and contain only \ + alphanumerics, '.', '_', ':' and '-' (no path separators or '..')" + ); + } + Ok(()) +} + +pub(crate) fn registry_dir(image: &str) -> Result { + validate_image_name(image)?; + let base = xdg::BaseDirectories::with_prefix("slim-rs"); + let initrd_path = base + .place_data_file(format!("registry/{image}/initrd")) + .context("Failed to write to XDG_DATA_HOME")?; + let reg_dir = initrd_path + .parent() + .expect("data file has a parent") + .to_owned(); + + // Defense in depth: the constructed path must stay inside the registry. + let registry_root = base + .place_data_file("registry/.root-check") + .context("Failed to write to XDG_DATA_HOME")? + .parent() + .expect("data file has a parent") + .to_owned(); + if !reg_dir.starts_with(®istry_root) { + bail!( + "invalid image name '{image}': registry path escapes {}", + registry_root.display() + ); + } + Ok(reg_dir) +} + +#[cfg(test)] +mod tests { + use super::validate_image_name; + + #[test] + fn accepts_valid_image_names() { + for name in [ + "alpine:3.15", + "slim", + "mock-vm", + "my_vm", + "a.b.c", + "vm-1.2.3", + ] { + validate_image_name(name).unwrap_or_else(|e| panic!("{name} rejected: {e}")); + } + } + + #[test] + fn rejects_traversal_and_separators() { + for name in [ + "", + ".", + "..", + "../evil", + "../../tmp/evil", + "a/b", + "/abs", + "a\\b", + "foo bar", + "vm;rm", + "vm\n", + ] { + assert!( + validate_image_name(name).is_err(), + "{name:?} should be rejected" + ); + } + } +} -- 2.54.0