summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorHermes Agent <hermes@localhost>2026-08-12 01:44:59 +0000
committerHermes Agent <hermes@localhost>2026-08-12 01:44:59 +0000
commit04c6c5af37d9eac00a2bba2eaba1ec0836834633 (patch)
tree6c1f16cce9963dbd8bb7d1791e9c253309d37678 /src
parent9acf451473989fa868e21d2d6ff59cf3fb445562 (diff)
Collect semantic facts from native artifacts
Diffstat (limited to 'src')
-rw-r--r--src/manifest.rs115
1 files changed, 115 insertions, 0 deletions
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<Path>,
+ packages: Vec<PackageRecord>,
+ initrd: Option<InitrdRecord>,
+ archive: impl AsRef<Path>,
+ ) -> Result<Self> {
+ 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<Vec<FileRecord>> {
+ 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<Vec<ServiceRecord>> {
+ 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<Vec<ArchiveMemberRecord>> {
+ 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<String> {
+ 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<String> {
+ 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<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() {
+ bail!("empty symlink target is not valid in an artifact manifest");
+ }
+ Ok(value.to_owned())
}
fn ensure_unique<'a>(kind: &str, keys: impl IntoIterator<Item = &'a str>) -> Result<()> {