diff options
| author | Hermes Agent <hermes@localhost> | 2026-08-12 01:06:37 +0000 |
|---|---|---|
| committer | Hermes Agent <hermes@localhost> | 2026-08-12 01:06:37 +0000 |
| commit | 915fcc5efbf45d8adee870cfbe2a25e466454a2f (patch) | |
| tree | d5fdf2a745f905bd5fcce2ddac14d3e1c7a852e9 | |
| parent | facbf9629f45b95d34a4d0844e617f87a35be5ff (diff) | |
Isolate Hasher behind typed installer adapter
| -rw-r--r-- | profiles/apt.conf | 3 | ||||
| -rw-r--r-- | src/hasher.rs | 95 | ||||
| -rw-r--r-- | src/lib.rs | 2 | ||||
| -rw-r--r-- | src/package_installer.rs | 91 | ||||
| -rw-r--r-- | tests/package_installer.rs | 72 |
5 files changed, 263 insertions, 0 deletions
diff --git a/profiles/apt.conf b/profiles/apt.conf new file mode 100644 index 0000000..739e12f --- /dev/null +++ b/profiles/apt.conf @@ -0,0 +1,3 @@ +// Project-owned APT configuration passed to Hasher during initroot creation. +// Repository selection remains controlled by the host Hasher configuration. +APT::Get::Assume-Yes "true"; diff --git a/src/hasher.rs b/src/hasher.rs new file mode 100644 index 0000000..0be1808 --- /dev/null +++ b/src/hasher.rs @@ -0,0 +1,95 @@ +use std::ffi::OsString; +use std::process::Command; + +use anyhow::{bail, Context, Result}; + +use crate::package_installer::{PackageInstaller, PackageRequest}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invocation { + program: OsString, + arguments: Vec<OsString>, +} + +impl Invocation { + pub fn new( + program: impl Into<OsString>, + arguments: impl IntoIterator<Item = impl Into<OsString>>, + ) -> Self { + Self { + program: program.into(), + arguments: arguments.into_iter().map(Into::into).collect(), + } + } + + pub fn program(&self) -> &OsString { + &self.program + } + + pub fn arguments(&self) -> &[OsString] { + &self.arguments + } +} + +pub trait CommandRunner { + fn run(&self, invocation: Invocation) -> Result<()>; +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct ProcessRunner; + +impl CommandRunner for ProcessRunner { + fn run(&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(()) + } +} + +#[derive(Debug)] +pub struct HasherInstaller<R> { + runner: R, +} + +impl<R> HasherInstaller<R> { + pub fn new(runner: R) -> Self { + Self { runner } + } + + pub fn runner(&self) -> &R { + &self.runner + } +} + +impl<R: CommandRunner> PackageInstaller for HasherInstaller<R> { + fn install(&self, request: &PackageRequest) -> Result<()> { + self.runner.run(Invocation::new( + "hsh", + [ + "--mountpoints=/proc".into(), + "--initroot-only".into(), + "--apt-config".into(), + request.apt_config().as_path().as_os_str().to_owned(), + "--workdir".into(), + request.workdir().as_os_str().to_owned(), + ], + ))?; + + let mut arguments = vec![ + OsString::from("--mountpoints=/proc"), + OsString::from("--workdir"), + request.workdir().as_os_str().to_owned(), + ]; + arguments.extend(request.selectors().iter().map(OsString::from)); + arguments.extend(request.packages().iter().map(OsString::from)); + self.runner.run(Invocation::new("hsh-install", arguments)) + } +} @@ -1,6 +1,8 @@ pub mod cli; pub mod files; +pub mod hasher; pub mod model; +pub mod package_installer; pub mod plan; pub mod rootfs; pub mod stage; diff --git a/src/package_installer.rs b/src/package_installer.rs new file mode 100644 index 0000000..b08c469 --- /dev/null +++ b/src/package_installer.rs @@ -0,0 +1,91 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Result}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AptConfig(PathBuf); + +impl AptConfig { + pub fn new(path: impl AsRef<Path>) -> Result<Self> { + let path = path.as_ref(); + if path.as_os_str().is_empty() || path.is_dir() { + bail!("APT configuration must be a file path: {}", path.display()); + } + Ok(Self(path.to_path_buf())) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackageRequest { + workdir: PathBuf, + apt_config: AptConfig, + packages: Vec<String>, + selectors: Vec<String>, +} + +impl PackageRequest { + pub fn new( + workdir: impl AsRef<Path>, + apt_config: AptConfig, + packages: impl IntoIterator<Item = impl Into<String>>, + selectors: impl IntoIterator<Item = impl Into<String>>, + ) -> Result<Self> { + let workdir = workdir.as_ref(); + if workdir.as_os_str().is_empty() { + bail!("Hasher workdir cannot be empty"); + } + + let mut packages = normalize("package", packages)?; + let mut selectors = normalize("package selector", selectors)?; + packages.sort(); + packages.dedup(); + selectors.sort(); + selectors.dedup(); + Ok(Self { + workdir: workdir.to_path_buf(), + apt_config, + packages, + selectors, + }) + } + + pub fn workdir(&self) -> &Path { + &self.workdir + } + + pub fn apt_config(&self) -> &AptConfig { + &self.apt_config + } + + pub fn packages(&self) -> &[String] { + &self.packages + } + + pub fn selectors(&self) -> &[String] { + &self.selectors + } +} + +fn normalize( + kind: &str, + values: impl IntoIterator<Item = impl Into<String>>, +) -> Result<Vec<String>> { + values + .into_iter() + .map(Into::into) + .map(|value: String| { + if value.trim().is_empty() { + bail!("{kind} cannot be empty"); + } + Ok(value) + }) + .collect() +} + +pub trait PackageInstaller { + fn install(&self, request: &PackageRequest) -> Result<()>; +} diff --git a/tests/package_installer.rs b/tests/package_installer.rs new file mode 100644 index 0000000..622d378 --- /dev/null +++ b/tests/package_installer.rs @@ -0,0 +1,72 @@ +use std::path::PathBuf; + +use alt_controller_image::hasher::{CommandRunner, HasherInstaller, Invocation}; +use alt_controller_image::package_installer::{AptConfig, PackageInstaller, PackageRequest}; + +#[derive(Default)] +struct RecordingRunner { + invocations: std::cell::RefCell<Vec<Invocation>>, +} + +impl CommandRunner for RecordingRunner { + fn run(&self, invocation: Invocation) -> anyhow::Result<()> { + self.invocations.borrow_mut().push(invocation); + Ok(()) + } +} + +#[test] +fn hasher_installer_passes_typed_request_to_initroot_and_install_commands() { + let runner = RecordingRunner::default(); + let installer = HasherInstaller::new(runner); + let request = PackageRequest::new( + "/build/work", + AptConfig::new("profiles/apt.conf").expect("valid apt config path"), + ["basesystem", "make-initrd"], + ["^kernel-image-rt$"], + ) + .expect("valid package request"); + + installer.install(&request).expect("installation succeeds"); + let invocations = &installer.runner().invocations.borrow(); + + assert_eq!( + invocations.as_slice(), + [ + Invocation::new( + "hsh", + [ + "--mountpoints=/proc", + "--initroot-only", + "--apt-config", + "profiles/apt.conf", + "--workdir", + "/build/work", + ], + ), + Invocation::new( + "hsh-install", + [ + "--mountpoints=/proc", + "--workdir", + "/build/work", + "^kernel-image-rt$", + "basesystem", + "make-initrd", + ], + ), + ] + ); +} + +#[test] +fn package_request_rejects_empty_packages_and_unsafe_apt_config_paths() { + assert!(AptConfig::new("").is_err()); + assert!(PackageRequest::new( + PathBuf::from("work"), + AptConfig::new("profiles/apt.conf").expect("valid apt config"), + ["basesystem", ""], + std::iter::empty::<&str>(), + ) + .is_err()); +} |