From 9a07a0e20b88838d22bb04fae80b2353d41b2cd0 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 01:16:54 +0000 Subject: Add native initramfs stage adapter --- Cargo.lock | 72 ++++++++++++++++++++ Cargo.toml | 1 + src/initramfs.rs | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/initramfs.rs | 83 +++++++++++++++++++++++ 5 files changed, 352 insertions(+) create mode 100644 src/initramfs.rs create mode 100644 tests/initramfs.rs diff --git a/Cargo.lock b/Cargo.lock index aa4be7e..9202766 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,7 @@ dependencies = [ "anyhow", "clap", "serde", + "sha2", "tar", "tempfile", "toml", @@ -77,6 +78,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -129,6 +139,35 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -161,6 +200,16 @@ dependencies = [ "libc", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -309,6 +358,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "strsim" version = "0.11.1" @@ -389,6 +449,12 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -401,6 +467,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index f5568c4..f642720 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" anyhow = "1.0" clap = { version = "4.6", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } +sha2 = "0.10" toml = "1.0" tar = "0.4" walkdir = "2.5" diff --git a/src/initramfs.rs b/src/initramfs.rs new file mode 100644 index 0000000..ddb1010 --- /dev/null +++ b/src/initramfs.rs @@ -0,0 +1,195 @@ +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KernelVersion(String); + +impl KernelVersion { + pub fn discover_rt(rootfs: &Path) -> Result { + let boot = rootfs.join("boot"); + let entries = fs::read_dir(&boot) + .with_context(|| format!("read boot directory {}", boot.display()))?; + let mut kernels = entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_str()?; + let version = name.strip_prefix("vmlinuz-")?; + (version.contains("rt")).then(|| version.to_owned()) + }) + .collect::>(); + kernels.sort(); + match kernels.as_slice() { + [] => bail!("no RT kernel image found in {}", boot.display()), + [version] => Ok(Self(version.clone())), + _ => bail!("multiple RT kernel images found in {}: {}", boot.display(), kernels.join(", ")), + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InitramfsRecipe { + features: Vec, + modules: Vec, +} + +impl InitramfsRecipe { + pub fn new( + features: impl IntoIterator>, + modules: impl IntoIterator>, + ) -> Result { + let mut features = normalize("initrd feature", features)?; + let mut modules = normalize("initrd module", modules)?; + features.sort(); + features.dedup(); + modules.sort(); + modules.dedup(); + Ok(Self { features, modules }) + } + + pub fn render(&self) -> String { + format!( + "FEATURES += {}\nMODULES += {}\n", + self.features.join(" "), + self.modules.join(" ") + ) + } +} + +fn normalize(kind: &str, values: impl IntoIterator>) -> Result> { + values + .into_iter() + .map(Into::into) + .map(|value: String| { + if value.trim().is_empty() || value.chars().any(char::is_whitespace) { + bail!("{kind} cannot be empty or contain whitespace"); + } + Ok(value) + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InitramfsRequest { + rootfs: PathBuf, + kernel: KernelVersion, +} + +impl InitramfsRequest { + pub fn discover(rootfs: impl AsRef) -> Result { + let rootfs = rootfs.as_ref(); + if !rootfs.is_dir() { + bail!("rootfs is not a directory: {}", rootfs.display()); + } + Ok(Self { + rootfs: rootfs.to_path_buf(), + kernel: KernelVersion::discover_rt(rootfs)?, + }) + } + + pub fn rootfs(&self) -> &Path { + &self.rootfs + } + + pub fn kernel(&self) -> &KernelVersion { + &self.kernel + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invocation { + program: OsString, + arguments: Vec, +} + +impl Invocation { + pub fn new(program: impl Into, arguments: impl IntoIterator>) -> Self { + Self { program: program.into(), arguments: arguments.into_iter().map(Into::into).collect() } + } +} + +pub trait CommandRunner { + fn run(&mut self, invocation: Invocation) -> Result<()>; +} + +#[derive(Debug, Default)] +pub struct ProcessRunner; + +impl CommandRunner for ProcessRunner { + fn run(&mut self, invocation: Invocation) -> Result<()> { + let status = Command::new(&invocation.program) + .args(&invocation.arguments) + .status() + .with_context(|| format!("run {}", invocation.program.to_string_lossy()))?; + if !status.success() { + bail!("{} exited with {status}", invocation.program.to_string_lossy()); + } + Ok(()) + } +} + +pub trait InitramfsBuilder { + fn build(&mut self, request: &InitramfsRequest) -> Result; +} + +#[derive(Debug)] +pub struct MakeInitrdBuilder { + runner: R, +} + +impl MakeInitrdBuilder { + pub fn new(runner: R) -> Self { + Self { runner } + } + + pub fn runner(&self) -> &R { + &self.runner + } +} + +impl InitramfsBuilder for MakeInitrdBuilder { + fn build(&mut self, request: &InitramfsRequest) -> Result { + self.runner.run(Invocation::new( + "chroot", + [ + request.rootfs().as_os_str().to_owned(), + "make-initrd".into(), + "-k".into(), + request.kernel().as_str().into(), + ], + ))?; + InitramfsResult::from_rootfs(request.rootfs(), request.kernel()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InitramfsResult { + initrd_path: PathBuf, + sha256: String, +} + +impl InitramfsResult { + fn from_rootfs(rootfs: &Path, kernel: &KernelVersion) -> Result { + let initrd_path = PathBuf::from(format!("boot/initrd-{}.img", kernel.as_str())); + let contents = fs::read(rootfs.join(&initrd_path)) + .with_context(|| format!("read generated initrd {}", initrd_path.display()))?; + Ok(Self { initrd_path, sha256: format!("{:x}", Sha256::digest(contents)) }) + } + + pub fn initrd_path(&self) -> &Path { + &self.initrd_path + } + + pub fn sha256(&self) -> &str { + &self.sha256 + } +} diff --git a/src/lib.rs b/src/lib.rs index cfcc645..92ad4bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod cli; pub mod files; pub mod hasher; +pub mod initramfs; pub mod model; pub mod package_installer; pub mod plan; diff --git a/tests/initramfs.rs b/tests/initramfs.rs new file mode 100644 index 0000000..b15c54f --- /dev/null +++ b/tests/initramfs.rs @@ -0,0 +1,83 @@ +use std::fs; +use std::path::Path; + +use alt_controller_image::initramfs::{ + CommandRunner, InitramfsBuilder, InitramfsRecipe, InitramfsRequest, Invocation, KernelVersion, + MakeInitrdBuilder, +}; +use tempfile::tempdir; + +#[test] +fn discovers_the_single_rt_kernel_and_renders_a_sorted_oem_recipe() { + let rootfs = tempdir().expect("rootfs directory"); + fs::create_dir(rootfs.path().join("boot")).expect("boot directory"); + fs::write(rootfs.path().join("boot/vmlinuz-6.12.8-alt1.rt1"), "kernel") + .expect("RT kernel"); + fs::write(rootfs.path().join("boot/vmlinuz-6.12.8-alt1.std"), "kernel") + .expect("non-RT kernel"); + + let kernel = KernelVersion::discover_rt(rootfs.path()).expect("discover RT kernel"); + assert_eq!(kernel.as_str(), "6.12.8-alt1.rt1"); + assert_eq!( + InitramfsRecipe::new(["rootfs", "compress"], ["virtio_blk.ko", "ext4"]) + .expect("valid recipe") + .render(), + "FEATURES += compress rootfs\nMODULES += ext4 virtio_blk.ko\n" + ); +} + +#[test] +fn diagnoses_missing_or_ambiguous_rt_kernels() { + let rootfs = tempdir().expect("rootfs directory"); + fs::create_dir(rootfs.path().join("boot")).expect("boot directory"); + let missing = KernelVersion::discover_rt(rootfs.path()).expect_err("missing RT kernel"); + assert!(missing.to_string().contains("no RT kernel image")); + + fs::write(rootfs.path().join("boot/vmlinuz-6.12-rt1"), "kernel").expect("first RT kernel"); + fs::write(rootfs.path().join("boot/vmlinuz-6.11-rt1"), "kernel").expect("second RT kernel"); + let ambiguous = KernelVersion::discover_rt(rootfs.path()).expect_err("ambiguous RT kernel"); + assert!(ambiguous.to_string().contains("multiple RT kernel images")); +} + +#[test] +fn make_initrd_adapter_uses_typed_chroot_arguments_and_records_output_digest() { + let rootfs = tempdir().expect("rootfs directory"); + fs::create_dir(rootfs.path().join("boot")).expect("boot directory"); + fs::write(rootfs.path().join("boot/vmlinuz-6.12-rt1"), "kernel").expect("RT kernel"); + fs::write(rootfs.path().join("boot/initrd-6.12-rt1.img"), b"generated initrd") + .expect("generated initrd"); + let request = InitramfsRequest::discover(rootfs.path()).expect("initramfs request"); + let mut builder = MakeInitrdBuilder::new(RecordingRunner::default()); + + let result = builder.build(&request).expect("build initramfs"); + + assert_eq!( + builder.runner().invocations, + vec![Invocation::new( + "chroot", + [ + rootfs.path().as_os_str().to_owned(), + "make-initrd".into(), + "-k".into(), + "6.12-rt1".into(), + ], + )] + ); + assert_eq!(result.initrd_path(), Path::new("boot/initrd-6.12-rt1.img")); + assert_eq!( + result.sha256(), + "aec42bc86f526931d7c7f01ecba8d643dbb748f9102539a99db5d4855ee4435f" + ); +} + +#[derive(Debug, Default)] +struct RecordingRunner { + invocations: Vec, +} + +impl CommandRunner for RecordingRunner { + fn run(&mut self, invocation: Invocation) -> anyhow::Result<()> { + self.invocations.push(invocation); + Ok(()) + } +} -- cgit v1.2.3