From 04c6c5af37d9eac00a2bba2eaba1ec0836834633 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 01:44:59 +0000 Subject: Collect semantic facts from native artifacts --- src/manifest.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) (limited to 'src/manifest.rs') diff --git a/src/manifest.rs b/src/manifest.rs index 1a5829b..98dc42d 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -1,9 +1,13 @@ use std::collections::HashSet; use std::fs; +use std::io::Read; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tar::{Archive, EntryType}; +use walkdir::WalkDir; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ArtifactManifest { @@ -48,6 +52,117 @@ impl ArtifactManifest { fs::write(&path, text).with_context(|| format!("write artifact manifest {}", path.display()))?; Ok(path) } + + /// Collect native filesystem and archive facts from a completed build. + /// Package records come from the typed package-installation boundary because + /// the RPM database is not a portable filesystem format. + pub fn collect( + rootfs: impl AsRef, + packages: Vec, + initrd: Option, + archive: impl AsRef, + ) -> Result { + let rootfs = rootfs.as_ref(); + if !rootfs.is_dir() { + bail!("rootfs is not a directory: {}", rootfs.display()); + } + Self::new( + packages, + collect_files(rootfs)?, + initrd, + collect_services(rootfs)?, + collect_archive_members(archive.as_ref())?, + ) + } +} + +fn collect_files(rootfs: &Path) -> Result> { + let mut files = Vec::new(); + for entry in WalkDir::new(rootfs).follow_links(false).min_depth(1) { + let entry = entry.with_context(|| format!("walk rootfs {}", rootfs.display()))?; + let relative = entry.path().strip_prefix(rootfs).expect("walk entry is below rootfs"); + if relative.components().next().is_some_and(|part| part.as_os_str() == ".host" || part.as_os_str() == ".fakedata") { + continue; + } + let path = portable_path(relative)?; + let file_type = entry.file_type(); + if file_type.is_file() { + files.push(FileRecord::file(path, sha256_file(entry.path())?)); + } else if file_type.is_symlink() { + let target = fs::read_link(entry.path()) + .with_context(|| format!("read rootfs symlink {}", entry.path().display()))?; + files.push(FileRecord::symlink(path, link_target(&target)?)); + } else if !file_type.is_dir() { + bail!("unsupported rootfs entry type: {}", entry.path().display()); + } + } + Ok(files) +} + +fn collect_services(rootfs: &Path) -> Result> { + let wants = rootfs.join("etc/systemd/system/multi-user.target.wants"); + if !wants.exists() { + return Ok(Vec::new()); + } + let mut services = Vec::new(); + for entry in fs::read_dir(&wants).with_context(|| format!("read service state directory {}", wants.display()))? { + let entry = entry?; + let name = entry.file_name().into_string().map_err(|_| anyhow::anyhow!("non-UTF-8 service name in {}", wants.display()))?; + if entry.file_type()?.is_symlink() && name.ends_with(".service") { + services.push(ServiceRecord::new(name, true)); + } + } + Ok(services) +} + +fn collect_archive_members(archive_path: &Path) -> Result> { + let file = fs::File::open(archive_path).with_context(|| format!("open archive {}", archive_path.display()))?; + let mut archive = Archive::new(file); + let mut members = Vec::new(); + for entry in archive.entries().with_context(|| format!("read archive {}", archive_path.display()))? { + let entry = entry.with_context(|| format!("read archive member from {}", archive_path.display()))?; + let kind = match entry.header().entry_type() { + EntryType::Regular => "file", + EntryType::Directory => "directory", + EntryType::Symlink => "symlink", + other => bail!("unsupported archive member type {other:?} in {}", archive_path.display()), + }; + members.push(ArchiveMemberRecord::new(portable_path(&entry.path()?)?, kind)); + } + Ok(members) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path).with_context(|| format!("open rootfs file {}", path.display()))?; + 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()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn portable_path(path: &Path) -> Result { + if path.is_absolute() { + bail!("absolute path is not valid in an artifact manifest: {}", path.display()); + } + let value = path.to_str().ok_or_else(|| anyhow::anyhow!("non-UTF-8 path is not valid in an artifact manifest: {}", path.display()))?; + if value.is_empty() || value == "." { + bail!("empty path is not valid in an artifact manifest"); + } + Ok(value.to_owned()) +} + +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() { + bail!("empty symlink target is not valid in an artifact manifest"); + } + Ok(value.to_owned()) } fn ensure_unique<'a>(kind: &str, keys: impl IntoIterator) -> Result<()> { -- cgit v1.2.3