summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHermes Agent <hermes@localhost>2026-08-12 00:02:26 +0000
committerHermes Agent <hermes@localhost>2026-08-12 00:02:26 +0000
commit25f39c857da591c665865b77483b79495caf5e00 (patch)
tree1e7dd7118fcd1978b13c8bd1aa6cb202bb72a32a
parent1860cc5beb8df6e85d0f9690e5852709a534ebd7 (diff)
Replace legacy profile parser with typed image spec
-rw-r--r--Cargo.lock447
-rw-r--r--Cargo.toml15
-rw-r--r--profiles/alt-controller.toml20
-rw-r--r--src/lib.rs64
-rw-r--r--src/main.rs61
-rw-r--r--src/model.rs142
-rw-r--r--src/plan.rs19
-rw-r--r--tests/model_validation.rs85
-rw-r--r--tests/profile.rs16
9 files changed, 731 insertions, 138 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 261153e..aa4be7e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,4 +4,449 @@ version = 4
[[package]]
name = "alt-controller-image"
-version = "0.1.0"
+version = "0.2.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "serde",
+ "tar",
+ "tempfile",
+ "toml",
+ "walkdir",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.4+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 5284bb6..f5568c4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,8 +1,8 @@
[package]
name = "alt-controller-image"
-version = "0.1.0"
+version = "0.2.0"
edition = "2024"
-description = "Independent Rust/Hasher rootfs image builder for ALT Controller"
+description = "Native Rust image-builder core for ALT Controller"
[lib]
path = "src/lib.rs"
@@ -10,3 +10,14 @@ path = "src/lib.rs"
[[bin]]
name = "alt-controller-image"
path = "src/main.rs"
+
+[dependencies]
+anyhow = "1.0"
+clap = { version = "4.6", features = ["derive"] }
+serde = { version = "1.0", features = ["derive"] }
+toml = "1.0"
+tar = "0.4"
+walkdir = "2.5"
+
+[dev-dependencies]
+tempfile = "3.23"
diff --git a/profiles/alt-controller.toml b/profiles/alt-controller.toml
new file mode 100644
index 0000000..fa903fb
--- /dev/null
+++ b/profiles/alt-controller.toml
@@ -0,0 +1,20 @@
+[target]
+name = "alt-controller"
+architecture = "x86_64"
+format = "tar"
+
+[kernel]
+flavour = "rt"
+
+[boot]
+initrd_features = ["add-modules", "compress", "cleanup", "rdshell", "rootfs", "qemu", "usb"]
+initrd_modules = ["ext4", "ahci.ko", "ahci_platform.ko", "sd_mod.ko", "virtio_blk.ko", "virtio_net.ko", "virtio_pci.ko"]
+
+[packages]
+base = [
+ "basesystem", "fakeroot", "interactivesystem", "shadow-utils", "e2fsprogs",
+ "apt", "apt-https", "bootloader-utils", "make-initrd", "chrony",
+ "systemd-networkd", "iputils", "control", "openssh-server", "systemd-sysvinit",
+ "libiec61850"
+]
+selectors = ["^kernel-(image|modules-())-(rt)$"]
diff --git a/src/lib.rs b/src/lib.rs
index b7bf8c2..a62cbde 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,61 +1,5 @@
-use std::fs;
-use std::path::{Path, PathBuf};
+pub mod model;
+pub mod plan;
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct Profile {
- pub packages: Vec<String>,
- pub regex_packages: Vec<String>,
-}
-
-impl Profile {
- pub fn load(path: &Path) -> Result<Self, String> {
- let profile = fs::read_to_string(path).map_err(|error| error.to_string())?;
- let parent = path.parent().ok_or("profile path has no parent")?;
- let mut packages = Vec::new();
- let mut regex_packages = Vec::new();
-
- for line in profile.lines() {
- let line = line.trim();
- if line.is_empty() || line.starts_with('#') {
- continue;
- }
- if let Some(values) = line.strip_prefix("base:") {
- extend_words(&mut packages, values);
- } else if let Some(relative) = line.strip_prefix("include:") {
- let list = parent.join(relative.trim());
- let contents = fs::read_to_string(&list)
- .map_err(|error| format!("{}: {error}", list.display()))?;
- for entry in contents.lines().map(str::trim) {
- if entry.is_empty() || entry.starts_with('#') {
- continue;
- }
- let package = entry.split('@').next().unwrap_or(entry).trim();
- if !package.is_empty() {
- packages.push(package.to_owned());
- }
- }
- } else if let Some(value) = line.strip_prefix("regex:") {
- regex_packages.push(value.trim().to_owned());
- } else {
- return Err(format!("unsupported profile statement: {line}"));
- }
- }
- packages.sort();
- packages.dedup();
- regex_packages.sort();
- regex_packages.dedup();
- Ok(Self { packages, regex_packages })
- }
-
- pub fn install_arguments(&self) -> Vec<String> {
- self.regex_packages.iter().chain(self.packages.iter()).cloned().collect()
- }
-}
-
-fn extend_words(target: &mut Vec<String>, values: &str) {
- target.extend(values.split_whitespace().map(str::to_owned));
-}
-
-pub fn artifact_path(project: &Path) -> PathBuf {
- project.join("out/alt-controller-rootfs.tar")
-}
+pub use model::{Architecture, BootSpec, ImageSpec, OutputFormat, PackageSelector, PackageSpec, Target};
+pub use plan::BuildPlan;
diff --git a/src/main.rs b/src/main.rs
index f468124..1ca0cab 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,61 +1,4 @@
-use std::env;
-use std::fs;
-use std::path::PathBuf;
-use std::process::Command;
-
-use alt_controller_image::{artifact_path, Profile};
-
-fn run(command: &mut Command, description: &str) {
- let status = command.status().unwrap_or_else(|error| panic!("{description}: {error}"));
- assert!(status.success(), "{description} exited with {status}");
-}
-
fn main() {
- let project = env::current_dir().expect("current directory");
- let profile_path = project.join("profiles/alt-controller.profile");
- let profile = Profile::load(&profile_path).unwrap_or_else(|error| panic!("profile: {error}"));
- let workdir: PathBuf = project.join("work/alt-controller");
- let rootfs = workdir.join("chroot");
- let artifact = artifact_path(&project);
-
- let archive_existing = env::args().any(|argument| argument == "--archive-existing");
- if !archive_existing {
- if workdir.exists() {
- run(
- Command::new("sudo").args(["rm", "-rf"]).arg(&workdir),
- "remove previous Hasher workdir",
- );
- }
- fs::create_dir_all(&workdir).expect("create Hasher workdir");
- run(
- Command::new("hsh")
- .args(["--mountpoints=/proc", "--initroot-only", "--workdir"])
- .arg(&workdir),
- "initialize isolated Hasher root",
- );
-
- let mut install = Command::new("hsh-install");
- install.args(["--mountpoints=/proc", "--workdir"]);
- install.arg(&workdir);
- install.args(profile.install_arguments());
- run(&mut install, "install target rootfs packages");
- }
- assert!(rootfs.is_dir(), "isolated rootfs is missing: {}", rootfs.display());
- fs::create_dir_all(artifact.parent().expect("artifact parent")).expect("create output directory");
-
- if artifact.exists() {
- fs::remove_file(&artifact).expect("remove previous artifact");
- }
- run(
- Command::new("sudo")
- .arg("tar")
- .args(["--numeric-owner", "--exclude=./.host", "-C"])
- .arg(&rootfs)
- .args(["-cpf"])
- .arg(&artifact)
- .arg("."),
- "archive isolated rootfs",
- );
-
- println!("{}", artifact.display());
+ eprintln!("The command-line interface is not available yet. Use the library to load an ImageSpec.");
+ std::process::exit(2);
}
diff --git a/src/model.rs b/src/model.rs
new file mode 100644
index 0000000..13fa5e8
--- /dev/null
+++ b/src/model.rs
@@ -0,0 +1,142 @@
+use std::collections::HashSet;
+use std::fs;
+use std::path::Path;
+
+use anyhow::{bail, Context, Result};
+use serde::Deserialize;
+
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+pub struct ImageSpec {
+ pub target: Target,
+ pub kernel: Kernel,
+ pub boot: BootSpec,
+ pub packages: PackageSpec,
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+pub struct Target {
+ pub name: String,
+ pub architecture: Architecture,
+ pub format: OutputFormat,
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+pub struct Kernel {
+ pub flavour: String,
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+pub struct BootSpec {
+ pub initrd_features: Vec<String>,
+ pub initrd_modules: Vec<String>,
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+pub struct PackageSpec {
+ pub base: Vec<String>,
+ pub selectors: Vec<PackageSelector>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PackageSelector(String);
+
+impl PackageSelector {
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl<'de> Deserialize<'de> for PackageSelector {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ if value.trim().is_empty() {
+ return Err(serde::de::Error::custom("package selector cannot be empty"));
+ }
+ Ok(Self(value))
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Architecture {
+ X86_64,
+}
+
+impl Architecture {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::X86_64 => "x86_64",
+ }
+ }
+}
+
+impl<'de> Deserialize<'de> for Architecture {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ match String::deserialize(deserializer)?.as_str() {
+ "x86_64" => Ok(Self::X86_64),
+ value => Err(serde::de::Error::custom(format!(
+ "unsupported architecture: {value}"
+ ))),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum OutputFormat {
+ Tar,
+}
+
+impl<'de> Deserialize<'de> for OutputFormat {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ match String::deserialize(deserializer)?.as_str() {
+ "tar" => Ok(Self::Tar),
+ value => Err(serde::de::Error::custom(format!(
+ "unsupported output format: {value}"
+ ))),
+ }
+ }
+}
+
+impl ImageSpec {
+ pub fn load(path: &Path) -> Result<Self> {
+ let contents = fs::read_to_string(path)
+ .with_context(|| format!("read image specification {}", path.display()))?;
+ let spec: Self = toml::from_str(&contents)
+ .with_context(|| format!("parse image specification {}", path.display()))?;
+ spec.validate()?;
+ Ok(spec)
+ }
+
+ pub fn validate(&self) -> Result<()> {
+ if self.target.name.trim().is_empty() {
+ bail!("target name cannot be empty");
+ }
+
+ let mut selectors = HashSet::new();
+ for selector in &self.packages.selectors {
+ if !selectors.insert(selector.as_str()) {
+ bail!("duplicate package selector: {}", selector.as_str());
+ }
+ }
+
+ for module in &self.boot.initrd_modules {
+ if module.is_empty()
+ || module.contains('/')
+ || !module
+ .chars()
+ .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-'))
+ {
+ bail!("invalid initrd module: {module}");
+ }
+ }
+ Ok(())
+ }
+}
diff --git a/src/plan.rs b/src/plan.rs
new file mode 100644
index 0000000..16158e5
--- /dev/null
+++ b/src/plan.rs
@@ -0,0 +1,19 @@
+use anyhow::Result;
+
+use crate::ImageSpec;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct BuildPlan {
+ spec: ImageSpec,
+}
+
+impl BuildPlan {
+ pub fn compile(spec: ImageSpec) -> Result<Self> {
+ spec.validate()?;
+ Ok(Self { spec })
+ }
+
+ pub fn spec(&self) -> &ImageSpec {
+ &self.spec
+ }
+}
diff --git a/tests/model_validation.rs b/tests/model_validation.rs
new file mode 100644
index 0000000..5134a6f
--- /dev/null
+++ b/tests/model_validation.rs
@@ -0,0 +1,85 @@
+use std::fs;
+
+use alt_controller_image::{BuildPlan, ImageSpec, OutputFormat};
+
+fn write_spec(contents: &str) -> tempfile::TempDir {
+ let directory = tempfile::tempdir().expect("temporary spec directory");
+ fs::write(directory.path().join("image.toml"), contents).expect("write spec");
+ directory
+}
+
+fn load(contents: &str) -> anyhow::Result<ImageSpec> {
+ let directory = write_spec(contents);
+ ImageSpec::load(&directory.path().join("image.toml"))
+}
+
+const VALID_SPEC: &str = r#"
+[target]
+name = "alt-controller"
+architecture = "x86_64"
+format = "tar"
+
+[kernel]
+flavour = "rt"
+
+[boot]
+initrd_features = ["rootfs"]
+initrd_modules = ["ext4", "virtio_blk.ko"]
+
+[packages]
+base = ["basesystem"]
+selectors = ["^kernel-image-rt$"]
+"#;
+
+#[test]
+fn parses_a_typed_image_spec() {
+ let spec = load(VALID_SPEC).expect("valid spec");
+
+ assert_eq!(spec.target.name, "alt-controller");
+ assert_eq!(spec.target.architecture.as_str(), "x86_64");
+ assert_eq!(spec.target.format, OutputFormat::Tar);
+ assert_eq!(spec.packages.selectors[0].as_str(), "^kernel-image-rt$");
+}
+
+#[test]
+fn compiles_a_validated_spec_into_an_immutable_build_plan() {
+ let spec = load(VALID_SPEC).expect("valid spec");
+ let plan = BuildPlan::compile(spec).expect("plan compiles");
+
+ assert_eq!(plan.spec().target.name, "alt-controller");
+}
+
+#[test]
+fn rejects_an_unknown_target_format() {
+ let error = load(&VALID_SPEC.replace("format = \"tar\"", "format = \"iso\""))
+ .expect_err("unsupported format must fail");
+
+ assert!(format!("{error:#}").contains("unsupported output format"));
+}
+
+#[test]
+fn rejects_an_unsupported_architecture() {
+ let error = load(&VALID_SPEC.replace("x86_64", "aarch64"))
+ .expect_err("unsupported architecture must fail");
+
+ assert!(format!("{error:#}").contains("unsupported architecture"));
+}
+
+#[test]
+fn rejects_duplicate_package_selectors() {
+ let error = load(&VALID_SPEC.replace(
+ "selectors = [\"^kernel-image-rt$\"]",
+ "selectors = [\"^kernel-image-rt$\", \"^kernel-image-rt$\"]",
+ ))
+ .expect_err("duplicate selector must fail");
+
+ assert!(format!("{error:#}").contains("duplicate package selector"));
+}
+
+#[test]
+fn rejects_an_invalid_initrd_module_name() {
+ let error = load(&VALID_SPEC.replace("\"virtio_blk.ko\"", "\"../virtio_blk.ko\""))
+ .expect_err("unsafe module name must fail");
+
+ assert!(format!("{error:#}").contains("invalid initrd module"));
+}
diff --git a/tests/profile.rs b/tests/profile.rs
deleted file mode 100644
index 8fa0a24..0000000
--- a/tests/profile.rs
+++ /dev/null
@@ -1,16 +0,0 @@
-use std::path::Path;
-
-use alt_controller_image::Profile;
-
-#[test]
-fn profile_expands_vendored_package_lists_and_discards_comments() {
- let profile = Profile::load(Path::new("profiles/alt-controller.profile"))
- .expect("target profile must parse");
-
- assert!(profile.packages.contains(&"fakeroot".to_owned()));
- assert!(profile.packages.contains(&"openssh-server".to_owned()));
- assert!(profile.packages.contains(&"systemd-sysvinit".to_owned()));
- assert!(profile.packages.contains(&"libiec61850".to_owned()));
- assert!(!profile.packages.iter().any(|entry| entry.starts_with('#')));
- assert!(profile.regex_packages.contains(&"^kernel-(image|modules-())-(rt)$".to_owned()));
-}