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 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/compare.rs | 49 ++++++++++++++++++++++++ 2 files changed, 164 insertions(+) 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<()> { diff --git a/tests/compare.rs b/tests/compare.rs index a4f0a32..b11d288 100644 --- a/tests/compare.rs +++ b/tests/compare.rs @@ -1,7 +1,10 @@ use alt_controller_image::compare::{Change, SemanticDifference, compare}; +use alt_controller_image::archive::NativeTarWriter; use alt_controller_image::manifest::{ ArchiveMemberRecord, ArtifactManifest, FileRecord, InitrdRecord, PackageRecord, ServiceRecord, }; +use std::fs; +use std::os::unix::fs::symlink; use tempfile::tempdir; fn baseline() -> ArtifactManifest { @@ -71,3 +74,49 @@ fn rejects_duplicate_semantic_keys() { assert!(error.to_string().contains("duplicate package record")); } + +#[test] +fn collects_semantic_facts_from_native_rootfs_and_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::write(rootfs.join("etc/controller.conf"), "controller\n").expect("write file"); + symlink("controller.conf", rootfs.join("etc/controller-link")).expect("write symlink"); + 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( + &rootfs, + vec![PackageRecord::new("controller", "1.0")], + Some(InitrdRecord::new("boot/initrd-rt.img", "digest")), + &artifact, + ) + .expect("collect semantic facts"); + + assert_eq!(manifest.packages, vec![PackageRecord::new("controller", "1.0")]); + assert!(manifest.files.iter().any(|record| record == &FileRecord::file( + "etc/controller.conf", + "2d5c759b2b539229e09d362e8dbe0ae410ff8c9ece6038624458724520683f5b", + ))); + assert!(manifest + .files + .iter() + .any(|record| record == &FileRecord::symlink("etc/controller-link", "controller.conf"))); + assert_eq!(manifest.services, vec![ServiceRecord::new("controller.service", true)]); + assert!(manifest + .archive_members + .iter() + .any(|record| record == &ArchiveMemberRecord::new("etc/controller.conf", "file"))); + assert!(manifest + .archive_members + .iter() + .any(|record| record == &ArchiveMemberRecord::new("etc/controller-link", "symlink"))); +} -- cgit v1.2.3