From 271ccc76ad22436b7e0d5291168a5d18c57bb77b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 01:59:47 +0000 Subject: Compare semantic facts directly from tar artifacts --- src/manifest.rs | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) (limited to 'src/manifest.rs') 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) -> Result { + 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> { @@ -125,6 +186,7 @@ fn collect_archive_members(archive_path: &Path) -> Result "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 Result { 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 { 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 { 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 { 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() { -- cgit v1.2.3