summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHermes Agent <hermes@localhost>2026-08-12 01:59:47 +0000
committerHermes Agent <hermes@localhost>2026-08-12 01:59:47 +0000
commit271ccc76ad22436b7e0d5291168a5d18c57bb77b (patch)
treef5404caa27fc4e6e8470847d74ec8001c2c84948
parent04c6c5af37d9eac00a2bba2eaba1ec0836834633 (diff)
Compare semantic facts directly from tar artifacts
-rw-r--r--src/cli.rs13
-rw-r--r--src/manifest.rs87
-rw-r--r--tests/compare.rs36
3 files changed, 132 insertions, 4 deletions
diff --git a/src/cli.rs b/src/cli.rs
index 1b62cd9..4445f20 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -59,13 +59,13 @@ pub fn run(cli: Cli) -> Result<()> {
}
Command::Build { .. } => bail!("build execution is not available yet"),
Command::Inspect { artifact } => {
- let manifest = ArtifactManifest::load(&artifact)?;
+ let manifest = load_artifact(&artifact)?;
println!("{}", toml::to_string_pretty(&manifest)?);
Ok(())
}
Command::Compare { left, right } => {
- let left = ArtifactManifest::load(&left)?;
- let right = ArtifactManifest::load(&right)?;
+ let left = load_artifact(&left)?;
+ let right = load_artifact(&right)?;
let report = compare(&left, &right);
print!("{}", report.render());
if report.is_equivalent() {
@@ -76,3 +76,10 @@ pub fn run(cli: Cli) -> Result<()> {
}
}
}
+
+fn load_artifact(path: &std::path::Path) -> Result<ArtifactManifest> {
+ match path.extension().and_then(|extension| extension.to_str()) {
+ Some("toml") => ArtifactManifest::load(path),
+ _ => ArtifactManifest::collect_archive(path),
+ }
+}
diff --git a/src/manifest.rs b/src/manifest.rs
index 98dc42d..57e57c5 100644
--- a/src/manifest.rs
+++ b/src/manifest.rs
@@ -74,6 +74,67 @@ impl ArtifactManifest {
collect_archive_members(archive.as_ref())?,
)
}
+
+ /// Collect portable semantic facts from an already packaged rootfs archive.
+ /// This permits comparison with legacy artifacts when their build root is
+ /// unavailable. RPM package metadata remains unavailable in a plain tar
+ /// archive and is consequently represented by an empty package set.
+ pub fn collect_archive(archive_path: impl AsRef<Path>) -> Result<Self> {
+ let archive_path = archive_path.as_ref();
+ let file = fs::File::open(archive_path)
+ .with_context(|| format!("open archive {}", archive_path.display()))?;
+ let mut archive = Archive::new(file);
+ let mut files = Vec::new();
+ let mut services = Vec::new();
+ let mut archive_members = Vec::new();
+ let mut initrd = None;
+
+ for entry in archive
+ .entries()
+ .with_context(|| format!("read archive {}", archive_path.display()))?
+ {
+ let mut entry = entry
+ .with_context(|| format!("read archive member from {}", archive_path.display()))?;
+ let path = portable_path(&entry.path()?)?;
+ let entry_type = entry.header().entry_type();
+ let kind = match entry_type {
+ EntryType::Regular => "file",
+ EntryType::Directory => "directory",
+ EntryType::Symlink => "symlink",
+ EntryType::Link => "hardlink",
+ other => bail!("unsupported archive member type {other:?} in {}", archive_path.display()),
+ };
+ archive_members.push(ArchiveMemberRecord::new(path.clone(), kind));
+
+ match entry_type {
+ EntryType::Regular => {
+ let digest = sha256_reader(&mut entry, archive_path)?;
+ if path.starts_with("boot/initrd-") && path.ends_with(".img") {
+ let record = InitrdRecord::new(path.clone(), digest.clone());
+ if initrd.replace(record).is_some() {
+ bail!("multiple initrd images found in archive {}", archive_path.display());
+ }
+ }
+ files.push(FileRecord::file(path, digest));
+ }
+ EntryType::Symlink => {
+ let target = entry
+ .link_name()
+ .with_context(|| format!("read archive symlink target for {path}"))?
+ .ok_or_else(|| anyhow::anyhow!("archive symlink has no target: {path}"))?;
+ files.push(FileRecord::symlink(path.clone(), link_target(&target)?));
+ if is_enabled_service(&path) {
+ services.push(ServiceRecord::new(service_name(&path)?, true));
+ }
+ }
+ EntryType::Directory => {}
+ EntryType::Link => {}
+ _ => unreachable!("entry type was checked above"),
+ }
+ }
+
+ Self::new(Vec::new(), files, initrd, services, archive_members)
+ }
}
fn collect_files(rootfs: &Path) -> Result<Vec<FileRecord>> {
@@ -125,6 +186,7 @@ fn collect_archive_members(archive_path: &Path) -> Result<Vec<ArchiveMemberRecor
EntryType::Regular => "file",
EntryType::Directory => "directory",
EntryType::Symlink => "symlink",
+ EntryType::Link => "hardlink",
other => bail!("unsupported archive member type {other:?} in {}", archive_path.display()),
};
members.push(ArchiveMemberRecord::new(portable_path(&entry.path()?)?, kind));
@@ -134,10 +196,14 @@ fn collect_archive_members(archive_path: &Path) -> Result<Vec<ArchiveMemberRecor
fn sha256_file(path: &Path) -> Result<String> {
let mut file = fs::File::open(path).with_context(|| format!("open rootfs file {}", path.display()))?;
+ sha256_reader(&mut file, path)
+}
+
+fn sha256_reader(reader: &mut impl Read, source: &Path) -> Result<String> {
let mut hasher = Sha256::new();
let mut buffer = [0; 8192];
loop {
- let read = file.read(&mut buffer).with_context(|| format!("read rootfs file {}", path.display()))?;
+ let read = reader.read(&mut buffer).with_context(|| format!("read {}", source.display()))?;
if read == 0 {
break;
}
@@ -154,9 +220,28 @@ fn portable_path(path: &Path) -> Result<String> {
if value.is_empty() || value == "." {
bail!("empty path is not valid in an artifact manifest");
}
+ if path
+ .components()
+ .any(|component| matches!(component, std::path::Component::ParentDir))
+ {
+ bail!("parent path is not valid in an artifact manifest: {}", path.display());
+ }
Ok(value.to_owned())
}
+fn is_enabled_service(path: &str) -> bool {
+ path.starts_with("etc/systemd/system/")
+ && path.contains(".target.wants/")
+ && path.ends_with(".service")
+}
+
+fn service_name(path: &str) -> Result<&str> {
+ path.rsplit('/')
+ .next()
+ .filter(|name| !name.is_empty())
+ .ok_or_else(|| anyhow::anyhow!("invalid service path in archive: {path}"))
+}
+
fn link_target(path: &Path) -> Result<String> {
let value = path.to_str().ok_or_else(|| anyhow::anyhow!("non-UTF-8 symlink target is not valid in an artifact manifest: {}", path.display()))?;
if value.is_empty() {
diff --git a/tests/compare.rs b/tests/compare.rs
index b11d288..59b62cb 100644
--- a/tests/compare.rs
+++ b/tests/compare.rs
@@ -120,3 +120,39 @@ fn collects_semantic_facts_from_native_rootfs_and_tar_artifact() {
.iter()
.any(|record| record == &ArchiveMemberRecord::new("etc/controller-link", "symlink")));
}
+
+#[test]
+fn collects_comparable_semantic_facts_directly_from_a_tar_artifact() {
+ let fixture = tempdir().expect("temporary directory");
+ let rootfs = fixture.path().join("rootfs");
+ fs::create_dir_all(rootfs.join("etc/systemd/system/multi-user.target.wants"))
+ .expect("create service state directory");
+ fs::create_dir_all(rootfs.join("boot")).expect("create boot directory");
+ fs::write(rootfs.join("etc/controller.conf"), "controller\n").expect("write file");
+ fs::write(rootfs.join("boot/initrd-rt.img"), "initrd\n").expect("write initrd");
+ symlink(
+ "/usr/lib/systemd/system/controller.service",
+ rootfs.join("etc/systemd/system/multi-user.target.wants/controller.service"),
+ )
+ .expect("enable service");
+ let artifact = fixture.path().join("controller.tar");
+ NativeTarWriter::new()
+ .write(&rootfs, &artifact)
+ .expect("write native tar");
+
+ let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts");
+
+ assert_eq!(manifest.packages, Vec::<PackageRecord>::new());
+ assert!(manifest.files.iter().any(|record| record == &FileRecord::file(
+ "etc/controller.conf",
+ "2d5c759b2b539229e09d362e8dbe0ae410ff8c9ece6038624458724520683f5b",
+ )));
+ assert_eq!(
+ manifest.initrd,
+ Some(InitrdRecord::new(
+ "boot/initrd-rt.img",
+ "8f7ed204b9dfaa20aa484445f54233c4b407cb80ec0f8c07f1f0a59675fb44cf",
+ ))
+ );
+ assert_eq!(manifest.services, vec![ServiceRecord::new("controller.service", true)]);
+}