summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorHermes Agent <hermes@localhost>2026-08-12 01:34:51 +0000
committerHermes Agent <hermes@localhost>2026-08-12 01:34:51 +0000
commit9acf451473989fa868e21d2d6ff59cf3fb445562 (patch)
treec58417401e1ecb7304c21c2ebf6f2eaa032ad907 /src
parent020fbf6a034b3c90de1bdd3ee5e3e5e15c783059 (diff)
Add semantic artifact manifest comparison
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs20
-rw-r--r--src/compare.rs103
-rw-r--r--src/lib.rs2
-rw-r--r--src/manifest.rs127
4 files changed, 250 insertions, 2 deletions
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<SemanticDifference>,
+}
+
+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<T: PartialEq>(
+ left: &[T],
+ right: &[T],
+ key: impl Fn(&T) -> &str,
+ difference: impl Fn(&str, Change) -> SemanticDifference,
+ output: &mut Vec<SemanticDifference>,
+) {
+ let left = left.iter().map(|record| (key(record), record)).collect::<BTreeMap<_, _>>();
+ let right = right.iter().map(|record| (key(record), record)).collect::<BTreeMap<_, _>>();
+ for name in left.keys().chain(right.keys()).copied().collect::<std::collections::BTreeSet<_>>() {
+ 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<PackageRecord>,
+ pub files: Vec<FileRecord>,
+ pub initrd: Option<InitrdRecord>,
+ pub services: Vec<ServiceRecord>,
+ pub archive_members: Vec<ArchiveMemberRecord>,
+}
+
+impl ArtifactManifest {
+ pub fn new(
+ mut packages: Vec<PackageRecord>,
+ mut files: Vec<FileRecord>,
+ initrd: Option<InitrdRecord>,
+ mut services: Vec<ServiceRecord>,
+ mut archive_members: Vec<ArchiveMemberRecord>,
+ ) -> Result<Self> {
+ 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<Path>) -> Result<Self> {
+ 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<Path>) -> Result<PathBuf> {
+ 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<Item = &'a str>) -> 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<String>, version: impl Into<String>) -> 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<String>,
+ pub target: Option<String>,
+}
+
+impl FileRecord {
+ pub fn file(path: impl Into<String>, digest: impl Into<String>) -> Self {
+ Self { path: path.into(), kind: "file".into(), digest: Some(digest.into()), target: None }
+ }
+
+ pub fn symlink(path: impl Into<String>, target: impl Into<String>) -> 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<String>, sha256: impl Into<String>) -> 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<String>, 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<String>, kind: impl Into<String>) -> Self {
+ Self { path: path.into(), kind: kind.into() }
+ }
+}