From 9acf451473989fa868e21d2d6ff59cf3fb445562 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 01:34:51 +0000 Subject: Add semantic artifact manifest comparison --- src/cli.rs | 20 ++++++++- src/compare.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/manifest.rs | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/compare.rs | 73 ++++++++++++++++++++++++++++++++ 5 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 src/compare.rs create mode 100644 src/manifest.rs create mode 100644 tests/compare.rs diff --git a/src/cli.rs b/src/cli.rs index c91d4fd..1b62cd9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; +use crate::compare::compare; +use crate::manifest::ArtifactManifest; use crate::{BuildPlan, ImageSpec}; #[derive(Debug, Parser)] @@ -56,7 +58,21 @@ pub fn run(cli: Cli) -> Result<()> { Ok(()) } Command::Build { .. } => bail!("build execution is not available yet"), - Command::Inspect { .. } => bail!("artifact inspection is not available yet"), - Command::Compare { .. } => bail!("artifact comparison is not available yet"), + Command::Inspect { artifact } => { + let manifest = ArtifactManifest::load(&artifact)?; + println!("{}", toml::to_string_pretty(&manifest)?); + Ok(()) + } + Command::Compare { left, right } => { + let left = ArtifactManifest::load(&left)?; + let right = ArtifactManifest::load(&right)?; + let report = compare(&left, &right); + print!("{}", report.render()); + if report.is_equivalent() { + Ok(()) + } else { + bail!("artifacts differ semantically") + } + } } } diff --git a/src/compare.rs b/src/compare.rs new file mode 100644 index 0000000..7088886 --- /dev/null +++ b/src/compare.rs @@ -0,0 +1,103 @@ +use std::collections::BTreeMap; +use std::fmt::Write; + +use crate::manifest::ArtifactManifest; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Change { + Added, + Removed, + Changed, +} + +impl Change { + fn verb(self) -> &'static str { + match self { + Self::Added => "added", + Self::Removed => "removed", + Self::Changed => "changed", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum SemanticDifference { + Package { name: String, change: Change }, + File { path: String, change: Change }, + Initrd { change: Change }, + Service { name: String, change: Change }, + ArchiveMember { path: String, change: Change }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ComparisonReport { + differences: Vec, +} + +impl ComparisonReport { + pub fn differences(&self) -> &[SemanticDifference] { + &self.differences + } + + pub fn is_equivalent(&self) -> bool { + self.differences.is_empty() + } + + pub fn render(&self) -> String { + if self.is_equivalent() { + return "artifacts are semantically equivalent\n".into(); + } + let mut output = String::new(); + for difference in &self.differences { + match difference { + SemanticDifference::Package { name, change } => writeln!(output, "{} package {name}", change.verb()), + SemanticDifference::File { path, change } => writeln!(output, "{} file {path}", change.verb()), + SemanticDifference::Initrd { change } => writeln!(output, "{} initrd", change.verb()), + SemanticDifference::Service { name, change } => writeln!(output, "{} service {name}", change.verb()), + SemanticDifference::ArchiveMember { path, change } => writeln!(output, "{} archive member {path}", change.verb()), + } + .expect("writing to String cannot fail"); + } + output + } +} + +pub fn compare(left: &ArtifactManifest, right: &ArtifactManifest) -> ComparisonReport { + let mut differences = Vec::new(); + compare_records(&left.packages, &right.packages, |record| &record.name, |name, change| { + SemanticDifference::Package { name: name.to_owned(), change } + }, &mut differences); + compare_records(&left.files, &right.files, |record| &record.path, |path, change| { + SemanticDifference::File { path: path.to_owned(), change } + }, &mut differences); + if left.initrd != right.initrd { + differences.push(SemanticDifference::Initrd { change: Change::Changed }); + } + compare_records(&left.services, &right.services, |record| &record.name, |name, change| { + SemanticDifference::Service { name: name.to_owned(), change } + }, &mut differences); + compare_records(&left.archive_members, &right.archive_members, |record| &record.path, |path, change| { + SemanticDifference::ArchiveMember { path: path.to_owned(), change } + }, &mut differences); + differences.sort(); + ComparisonReport { differences } +} + +fn compare_records( + left: &[T], + right: &[T], + key: impl Fn(&T) -> &str, + difference: impl Fn(&str, Change) -> SemanticDifference, + output: &mut Vec, +) { + let left = left.iter().map(|record| (key(record), record)).collect::>(); + let right = right.iter().map(|record| (key(record), record)).collect::>(); + for name in left.keys().chain(right.keys()).copied().collect::>() { + match (left.get(name), right.get(name)) { + (None, Some(_)) => output.push(difference(name, Change::Added)), + (Some(_), None) => output.push(difference(name, Change::Removed)), + (Some(left), Some(right)) if left != right => output.push(difference(name, Change::Changed)), + _ => {} + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 8375dfb..595d0e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,10 @@ pub mod archive; pub mod cli; +pub mod compare; pub mod files; pub mod hasher; pub mod initramfs; +pub mod manifest; pub mod model; pub mod package_installer; pub mod plan; diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..1a5829b --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,127 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactManifest { + pub packages: Vec, + pub files: Vec, + pub initrd: Option, + pub services: Vec, + pub archive_members: Vec, +} + +impl ArtifactManifest { + pub fn new( + mut packages: Vec, + mut files: Vec, + initrd: Option, + mut services: Vec, + mut archive_members: Vec, + ) -> Result { + packages.sort_by(|left, right| left.name.cmp(&right.name)); + files.sort_by(|left, right| left.path.cmp(&right.path)); + services.sort_by(|left, right| left.name.cmp(&right.name)); + archive_members.sort_by(|left, right| left.path.cmp(&right.path)); + ensure_unique("package", packages.iter().map(|record| record.name.as_str()))?; + ensure_unique("file", files.iter().map(|record| record.path.as_str()))?; + ensure_unique("service", services.iter().map(|record| record.name.as_str()))?; + ensure_unique("archive member", archive_members.iter().map(|record| record.path.as_str()))?; + Ok(Self { packages, files, initrd, services, archive_members }) + } + + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let text = fs::read_to_string(path).with_context(|| format!("read artifact manifest {}", path.display()))?; + let manifest: Self = toml::from_str(&text).with_context(|| format!("parse artifact manifest {}", path.display()))?; + Self::new(manifest.packages, manifest.files, manifest.initrd, manifest.services, manifest.archive_members) + } + + pub fn write_beside(&self, artifact: impl AsRef) -> Result { + let artifact = artifact.as_ref(); + let directory = artifact.parent().unwrap_or_else(|| Path::new(".")); + let path = directory.join("artifact.manifest.toml"); + let text = toml::to_string_pretty(self).context("serialize artifact manifest")?; + fs::write(&path, text).with_context(|| format!("write artifact manifest {}", path.display()))?; + Ok(path) + } +} + +fn ensure_unique<'a>(kind: &str, keys: impl IntoIterator) -> Result<()> { + let mut seen = HashSet::new(); + for key in keys { + if key.is_empty() || !seen.insert(key) { + bail!("duplicate {kind} record: {key}"); + } + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PackageRecord { + pub name: String, + pub version: String, +} + +impl PackageRecord { + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { name: name.into(), version: version.into() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileRecord { + pub path: String, + pub kind: String, + pub digest: Option, + pub target: Option, +} + +impl FileRecord { + pub fn file(path: impl Into, digest: impl Into) -> Self { + Self { path: path.into(), kind: "file".into(), digest: Some(digest.into()), target: None } + } + + pub fn symlink(path: impl Into, target: impl Into) -> Self { + Self { path: path.into(), kind: "symlink".into(), digest: None, target: Some(target.into()) } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InitrdRecord { + pub path: String, + pub sha256: String, +} + +impl InitrdRecord { + pub fn new(path: impl Into, sha256: impl Into) -> Self { + Self { path: path.into(), sha256: sha256.into() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceRecord { + pub name: String, + pub enabled: bool, +} + +impl ServiceRecord { + pub fn new(name: impl Into, enabled: bool) -> Self { + Self { name: name.into(), enabled } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArchiveMemberRecord { + pub path: String, + pub kind: String, +} + +impl ArchiveMemberRecord { + pub fn new(path: impl Into, kind: impl Into) -> Self { + Self { path: path.into(), kind: kind.into() } + } +} diff --git a/tests/compare.rs b/tests/compare.rs new file mode 100644 index 0000000..a4f0a32 --- /dev/null +++ b/tests/compare.rs @@ -0,0 +1,73 @@ +use alt_controller_image::compare::{Change, SemanticDifference, compare}; +use alt_controller_image::manifest::{ + ArchiveMemberRecord, ArtifactManifest, FileRecord, InitrdRecord, PackageRecord, ServiceRecord, +}; +use tempfile::tempdir; + +fn baseline() -> ArtifactManifest { + ArtifactManifest::new( + vec![PackageRecord::new("kernel-image-rt", "1.0")], + vec![FileRecord::file("etc/controller.conf", "aaa")], + Some(InitrdRecord::new("boot/initrd-rt.img", "old-initrd")), + vec![ServiceRecord::new("controller.service", true)], + vec![ArchiveMemberRecord::new("etc/controller.conf", "file")], + ) + .expect("valid baseline manifest") +} + +#[test] +fn compares_added_removed_and_changed_semantic_records() { + let left = baseline(); + let right = ArtifactManifest::new( + vec![ + PackageRecord::new("kernel-image-rt", "2.0"), + PackageRecord::new("controller-agent", "1.0"), + ], + vec![FileRecord::symlink("etc/controller.conf", "controller.conf.real")], + Some(InitrdRecord::new("boot/initrd-rt.img", "new-initrd")), + vec![ServiceRecord::new("controller.service", false)], + vec![ArchiveMemberRecord::new("usr/bin/controller", "file")], + ) + .expect("valid comparison manifest"); + + let report = compare(&left, &right); + + assert_eq!( + report.differences(), + &[ + SemanticDifference::Package { name: "controller-agent".into(), change: Change::Added }, + SemanticDifference::Package { name: "kernel-image-rt".into(), change: Change::Changed }, + SemanticDifference::File { path: "etc/controller.conf".into(), change: Change::Changed }, + SemanticDifference::Initrd { change: Change::Changed }, + SemanticDifference::Service { name: "controller.service".into(), change: Change::Changed }, + SemanticDifference::ArchiveMember { path: "etc/controller.conf".into(), change: Change::Removed }, + SemanticDifference::ArchiveMember { path: "usr/bin/controller".into(), change: Change::Added }, + ] + ); + assert!(report.render().contains("changed initrd")); +} + +#[test] +fn reads_and_writes_a_toml_manifest_beside_an_artifact() { + let fixture = tempdir().expect("temporary directory"); + let artifact = fixture.path().join("controller.tar"); + let manifest = baseline(); + + let manifest_path = manifest.write_beside(&artifact).expect("write manifest"); + assert_eq!(manifest_path, fixture.path().join("artifact.manifest.toml")); + assert_eq!(ArtifactManifest::load(&manifest_path).expect("load manifest"), manifest); +} + +#[test] +fn rejects_duplicate_semantic_keys() { + let error = ArtifactManifest::new( + vec![PackageRecord::new("controller", "1"), PackageRecord::new("controller", "2")], + vec![], + None, + vec![], + vec![], + ) + .expect_err("duplicate package names must fail"); + + assert!(error.to_string().contains("duplicate package record")); +} -- cgit v1.2.3