From 5e840ed4ce8097f0c53abdc3c5b2a25d052611fd Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 00:56:41 +0000 Subject: Add native rootfs finalization --- src/files.rs | 52 +++++++++++ src/lib.rs | 2 + src/rootfs.rs | 211 +++++++++++++++++++++++++++++++++++++++++++ tests/rootfs_finalization.rs | 81 +++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 src/files.rs create mode 100644 src/rootfs.rs create mode 100644 tests/rootfs_finalization.rs diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..f49bd5e --- /dev/null +++ b/src/files.rs @@ -0,0 +1,52 @@ +use std::path::{Component, Path, PathBuf}; + +use anyhow::{bail, Result}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RootfsPath(PathBuf); + +impl RootfsPath { + pub fn new(path: impl AsRef) -> Result { + let path = path.as_ref(); + if path.as_os_str().is_empty() || path.is_absolute() { + bail!("rootfs path must be a non-empty relative path: {}", path.display()); + } + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(component) => normalized.push(component), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + bail!("rootfs path cannot escape the rootfs: {}", path.display()); + } + } + } + Ok(Self(normalized)) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } +} + +pub fn relative_symlink_is_safe(link_parent: &Path, target: &Path) -> bool { + if target.is_absolute() { + return false; + } + + let mut depth = link_parent.components().count(); + for component in target.components() { + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir => { + if depth == 0 { + return false; + } + depth -= 1; + } + Component::RootDir | Component::Prefix(_) => return false, + } + } + true +} diff --git a/src/lib.rs b/src/lib.rs index 792e7a5..8016607 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ pub mod cli; +pub mod files; pub mod model; pub mod plan; +pub mod rootfs; pub mod stage; pub use model::{Architecture, BootSpec, ImageSpec, OutputFormat, PackageSelector, PackageSpec, Target}; diff --git a/src/rootfs.rs b/src/rootfs.rs new file mode 100644 index 0000000..06b2557 --- /dev/null +++ b/src/rootfs.rs @@ -0,0 +1,211 @@ +use std::collections::BTreeSet; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; + +use crate::files::{relative_symlink_is_safe, RootfsPath}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CopyTree { + source: PathBuf, + destination: RootfsPath, +} + +impl CopyTree { + pub fn new(source: impl AsRef, destination: impl AsRef) -> Result { + Ok(Self { + source: source.as_ref().to_path_buf(), + destination: RootfsPath::new(destination)?, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InitrdOem { + features: BTreeSet, + modules: BTreeSet, +} + +impl InitrdOem { + pub fn new( + features: impl IntoIterator>, + modules: impl IntoIterator>, + ) -> Self { + Self { + features: features.into_iter().map(Into::into).collect(), + modules: modules.into_iter().map(Into::into).collect(), + } + } + + fn render(&self) -> String { + format!( + "FEATURES += {}\nMODULES += {}\n", + self.features.iter().cloned().collect::>().join(" "), + self.modules.iter().cloned().collect::>().join(" "), + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceName(String); + +impl ServiceName { + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if !name.ends_with(".service") + || name.contains('/') + || name.chars().any(char::is_whitespace) + || name == ".service" + { + bail!("invalid systemd service name: {name}"); + } + Ok(Self(name)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RootfsFinalization { + copies: Vec, + initrd_oem: InitrdOem, + enabled_services: Vec, +} + +impl RootfsFinalization { + pub fn new( + copies: Vec, + initrd_oem: InitrdOem, + enabled_services: Vec, + ) -> Self { + Self { + copies, + initrd_oem, + enabled_services, + } + } + + pub fn apply(&self, rootfs: &Path) -> Result { + if !rootfs.is_dir() { + bail!("rootfs is not a directory: {}", rootfs.display()); + } + + let mut manifest = MutationManifest::default(); + for copy in &self.copies { + copy_tree(rootfs, copy, &mut manifest)?; + } + write_file( + rootfs, + &RootfsPath::new("etc/initrd.mk.oem")?, + self.initrd_oem.render().as_bytes(), + &mut manifest, + )?; + for service in &self.enabled_services { + enable_service(rootfs, service, &mut manifest)?; + } + manifest.paths.sort(); + Ok(manifest) + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct MutationManifest { + paths: Vec, +} + +impl MutationManifest { + pub fn created_paths(&self) -> &[String] { + &self.paths + } + + fn record(&mut self, path: &Path) { + self.paths.push(path.display().to_string()); + } +} + +fn copy_tree(rootfs: &Path, copy: &CopyTree, manifest: &mut MutationManifest) -> Result<()> { + if !copy.source.is_dir() { + bail!("copy source is not a directory: {}", copy.source.display()); + } + copy_entry(rootfs, ©.source, copy.destination.as_path(), Path::new(""), manifest) +} + +fn copy_entry( + rootfs: &Path, + source: &Path, + destination: &Path, + source_relative: &Path, + manifest: &mut MutationManifest, +) -> Result<()> { + let metadata = fs::symlink_metadata(source) + .with_context(|| format!("inspect copy source {}", source.display()))?; + let output = rootfs.join(destination); + + if metadata.file_type().is_dir() { + fs::create_dir_all(&output).with_context(|| format!("create {}", output.display()))?; + manifest.record(destination); + let mut entries = fs::read_dir(source) + .with_context(|| format!("read copy source directory {}", source.display()))? + .collect::, _>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let name = entry.file_name(); + copy_entry( + rootfs, + &entry.path(), + &destination.join(&name), + &source_relative.join(&name), + manifest, + )?; + } + } else if metadata.file_type().is_symlink() { + let target = fs::read_link(source) + .with_context(|| format!("read source symlink {}", source.display()))?; + if !relative_symlink_is_safe(source_relative.parent().unwrap_or(Path::new("")), &target) { + bail!("source symlink escapes copied tree: {}", source.display()); + } + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + symlink(&target, &output).with_context(|| format!("create symlink {}", output.display()))?; + manifest.record(destination); + } else if metadata.file_type().is_file() { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(source, &output) + .with_context(|| format!("copy {} to {}", source.display(), output.display()))?; + fs::set_permissions(&output, metadata.permissions())?; + manifest.record(destination); + } else { + bail!("unsupported copy source type: {}", source.display()); + } + Ok(()) +} + +fn write_file( + rootfs: &Path, + destination: &RootfsPath, + contents: &[u8], + manifest: &mut MutationManifest, +) -> Result<()> { + let output = rootfs.join(destination.as_path()); + let parent = output.parent().expect("rootfs-relative path has a parent"); + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + fs::write(&output, contents).with_context(|| format!("write {}", output.display()))?; + manifest.record(destination.as_path()); + Ok(()) +} + +fn enable_service(rootfs: &Path, service: &ServiceName, manifest: &mut MutationManifest) -> Result<()> { + let destination = RootfsPath::new(format!( + "etc/systemd/system/multi-user.target.wants/{}", + service.0 + ))?; + let output = rootfs.join(destination.as_path()); + fs::create_dir_all(output.parent().expect("service path has a parent"))?; + symlink(format!("/usr/lib/systemd/system/{}", service.0), &output) + .with_context(|| format!("enable service {}", service.0))?; + manifest.record(destination.as_path()); + Ok(()) +} diff --git a/tests/rootfs_finalization.rs b/tests/rootfs_finalization.rs new file mode 100644 index 0000000..fa0cf3b --- /dev/null +++ b/tests/rootfs_finalization.rs @@ -0,0 +1,81 @@ +use std::fs; +use std::os::unix::fs::symlink; +use std::path::Path; + +use alt_controller_image::rootfs::{CopyTree, InitrdOem, RootfsFinalization, ServiceName}; +use tempfile::tempdir; + +#[test] +fn finalization_copies_trees_generates_oem_recipe_and_enables_services() { + let fixture = tempdir().expect("fixture directory"); + let source = fixture.path().join("overlay"); + fs::create_dir_all(source.join("nested")).expect("create source tree"); + fs::write(source.join("nested/controller.conf"), "mode = controller\n").expect("write source file"); + symlink("nested/controller.conf", source.join("controller.conf")) + .expect("create relative source symlink"); + + let rootfs = tempdir().expect("rootfs directory"); + let finalization = RootfsFinalization::new( + vec![CopyTree::new(&source, "etc/controller").expect("valid copy destination")], + InitrdOem::new(["rootfs", "qemu"], ["ext4", "virtio_blk.ko"]), + vec![ServiceName::new("chronyd.service").expect("valid service")], + ); + + let manifest = finalization.apply(rootfs.path()).expect("finalize rootfs"); + + assert_eq!( + fs::read_to_string(rootfs.path().join("etc/controller/nested/controller.conf")) + .expect("copied file"), + "mode = controller\n" + ); + assert_eq!( + fs::read_link(rootfs.path().join("etc/controller/controller.conf")).expect("copied symlink"), + Path::new("nested/controller.conf") + ); + assert_eq!( + fs::read_to_string(rootfs.path().join("etc/initrd.mk.oem")).expect("OEM recipe"), + "FEATURES += qemu rootfs\nMODULES += ext4 virtio_blk.ko\n" + ); + assert_eq!( + fs::read_link( + rootfs + .path() + .join("etc/systemd/system/multi-user.target.wants/chronyd.service") + ) + .expect("enabled service"), + Path::new("/usr/lib/systemd/system/chronyd.service") + ); + assert_eq!( + manifest.created_paths(), + [ + "etc/controller", + "etc/controller/controller.conf", + "etc/controller/nested", + "etc/controller/nested/controller.conf", + "etc/initrd.mk.oem", + "etc/systemd/system/multi-user.target.wants/chronyd.service", + ] + ); +} + +#[test] +fn finalization_rejects_rootfs_escaping_destinations_and_symlinks() { + assert!(CopyTree::new("fixtures/overlay", "/etc/controller").is_err()); + assert!(CopyTree::new("fixtures/overlay", "../etc/controller").is_err()); + + let fixture = tempdir().expect("fixture directory"); + let source = fixture.path().join("overlay"); + fs::create_dir_all(&source).expect("create source tree"); + symlink("../../outside", source.join("unsafe-link")).expect("create unsafe symlink"); + let rootfs = tempdir().expect("rootfs directory"); + let finalization = RootfsFinalization::new( + vec![CopyTree::new(&source, "etc/controller").expect("valid destination")], + InitrdOem::new(std::iter::empty::<&str>(), std::iter::empty::<&str>()), + vec![], + ); + + let error = finalization + .apply(rootfs.path()) + .expect_err("unsafe symlink must be rejected"); + assert!(error.to_string().contains("escapes copied tree")); +} -- cgit v1.2.3