diff options
| author | Hermes Agent <hermes@localhost> | 2026-08-12 01:59:47 +0000 |
|---|---|---|
| committer | Hermes Agent <hermes@localhost> | 2026-08-12 01:59:47 +0000 |
| commit | 271ccc76ad22436b7e0d5291168a5d18c57bb77b (patch) | |
| tree | f5404caa27fc4e6e8470847d74ec8001c2c84948 | |
| parent | 04c6c5af37d9eac00a2bba2eaba1ec0836834633 (diff) | |
Compare semantic facts directly from tar artifacts
| -rw-r--r-- | src/cli.rs | 13 | ||||
| -rw-r--r-- | src/manifest.rs | 87 | ||||
| -rw-r--r-- | tests/compare.rs | 36 |
3 files changed, 132 insertions, 4 deletions
@@ -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)]); +} |