diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 20 | ||||
| -rw-r--r-- | src/compare.rs | 103 | ||||
| -rw-r--r-- | src/lib.rs | 2 | ||||
| -rw-r--r-- | src/manifest.rs | 127 |
4 files changed, 250 insertions, 2 deletions
@@ -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)), + _ => {} + } + } +} @@ -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() } + } +} |