From fc4e46c41bfb769193159143f6567341cd3e3a51 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 04:10:14 +0000 Subject: Validate semantic file record invariants --- src/manifest.rs | 26 ++++++++++++++++++++++++++ tests/compare.rs | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/manifest.rs b/src/manifest.rs index 3a34af7..bc09073 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -31,6 +31,7 @@ impl ArtifactManifest { files.sort_by(|left, right| left.path.cmp(&right.path)); services.sort_by(|left, right| left.name.cmp(&right.name)); archive_members.sort_by(|left, right| left.path.cmp(&right.path)); + validate_file_records(&files)?; ensure_unique("package", packages.iter().map(|record| record.name.as_str()))?; ensure_unique("file", files.iter().map(|record| record.path.as_str()))?; ensure_unique("service", services.iter().map(|record| record.name.as_str()))?; @@ -355,6 +356,31 @@ fn link_target(path: &Path) -> Result { Ok(value.to_owned()) } +fn validate_file_records(records: &[FileRecord]) -> Result<()> { + for record in records { + match record.kind.as_str() { + "file" => { + if record.digest.as_deref().is_none_or(str::is_empty) { + bail!("regular file record requires a digest: {}", record.path); + } + if record.target.is_some() { + bail!("regular file record cannot carry a target: {}", record.path); + } + } + "symlink" | "hardlink" => { + if record.digest.is_some() { + bail!("{} record cannot carry a digest: {}", record.kind, record.path); + } + if record.target.as_deref().is_none_or(str::is_empty) { + bail!("{} record requires a target: {}", record.kind, record.path); + } + } + _ => bail!("unsupported file record kind {}: {}", record.kind, record.path), + } + } + Ok(()) +} + fn ensure_unique<'a>(kind: &str, keys: impl IntoIterator) -> Result<()> { let mut seen = HashSet::new(); for key in keys { diff --git a/tests/compare.rs b/tests/compare.rs index 43c91e0..45522d1 100644 --- a/tests/compare.rs +++ b/tests/compare.rs @@ -172,6 +172,39 @@ fn rejects_duplicate_semantic_keys() { assert!(error.to_string().contains("duplicate package record")); } +#[test] +fn rejects_malformed_file_records_before_comparison() { + let error = ArtifactManifest::new( + vec![], + vec![FileRecord { + path: "etc/controller.conf".into(), + kind: "file".into(), + digest: None, + target: None, + }], + None, + vec![], + vec![], + ) + .expect_err("regular files require a digest"); + assert!(error.to_string().contains("regular file record requires a digest")); + + let error = ArtifactManifest::new( + vec![], + vec![FileRecord { + path: "etc/controller-link".into(), + kind: "symlink".into(), + digest: Some("not-applicable".into()), + target: Some("controller.conf".into()), + }], + None, + vec![], + vec![], + ) + .expect_err("symlinks cannot carry a digest"); + assert!(error.to_string().contains("symlink record cannot carry a digest")); +} + #[test] fn collects_semantic_facts_from_native_rootfs_and_tar_artifact() { let fixture = tempdir().expect("temporary directory"); -- cgit v1.2.3