diff options
| author | Hermes Agent <hermes@localhost> | 2026-08-12 03:00:45 +0000 |
|---|---|---|
| committer | Hermes Agent <hermes@localhost> | 2026-08-12 03:00:45 +0000 |
| commit | a60cf70253fb1e22dfcc480385c0499272b75477 (patch) | |
| tree | fc662e61c1c76109c3e23142c35422908f24397c | |
| parent | 5384232eec4dff669cd2bb68ee93c34b6b63c78c (diff) | |
Collect hardlink facts from archive artifacts
| -rw-r--r-- | src/manifest.rs | 39 | ||||
| -rw-r--r-- | tests/compare.rs | 37 |
2 files changed, 68 insertions, 8 deletions
diff --git a/src/manifest.rs b/src/manifest.rs index 3c34ade..5b7ae77 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -131,7 +131,13 @@ impl ArtifactManifest { } } EntryType::Directory => {} - EntryType::Link => {} + EntryType::Link => { + let target = entry + .link_name() + .with_context(|| format!("read archive hardlink target for {path}"))? + .ok_or_else(|| anyhow::anyhow!("archive hardlink has no target: {path}"))?; + files.push(FileRecord::hardlink(path, portable_path(&target)?)); + } _ => unreachable!("entry type was checked above"), } } @@ -247,16 +253,29 @@ fn portable_path(path: &Path) -> Result<String> { 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 == "." { + if value.is_empty() { 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()); + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::Normal(part) => normalized.push(part), + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + bail!("parent path is not valid in an artifact manifest: {}", path.display()); + } + std::path::Component::RootDir | std::path::Component::Prefix(_) => { + bail!("absolute path is not valid in an artifact manifest: {}", path.display()); + } + } } - Ok(value.to_owned()) + if normalized.as_os_str().is_empty() { + return Ok(".".into()); + } + let normalized = normalized + .to_str() + .ok_or_else(|| anyhow::anyhow!("non-UTF-8 path is not valid in an artifact manifest: {}", path.display()))?; + Ok(normalized.to_owned()) } fn is_enabled_service(path: &str) -> bool { @@ -326,6 +345,10 @@ impl FileRecord { pub fn symlink(path: impl Into<String>, target: impl Into<String>) -> Self { Self { path: path.into(), kind: "symlink".into(), digest: None, target: Some(target.into()) } } + + pub fn hardlink(path: impl Into<String>, target: impl Into<String>) -> Self { + Self { path: path.into(), kind: "hardlink".into(), digest: None, target: Some(target.into()) } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/tests/compare.rs b/tests/compare.rs index 37135a7..63e4a4f 100644 --- a/tests/compare.rs +++ b/tests/compare.rs @@ -217,6 +217,43 @@ fn normalizes_legacy_dot_prefixed_archive_paths_before_comparison() { } #[test] +fn collects_hardlink_targets_from_an_archive_for_semantic_comparison() { + let fixture = tempdir().expect("temporary directory"); + let artifact = fixture.path().join("legacy.tar"); + let file = fs::File::create(&artifact).expect("create legacy archive"); + let mut archive = tar::Builder::new(file); + let mut header = tar::Header::new_gnu(); + header.set_size(11); + header.set_mode(0o644); + header.set_cksum(); + archive + .append_data(&mut header, "./usr/bin/controller", Cursor::new(b"controller\n")) + .expect("append regular member"); + let mut link_header = tar::Header::new_gnu(); + link_header.set_entry_type(tar::EntryType::Link); + link_header.set_size(0); + link_header.set_cksum(); + archive + .append_link( + &mut link_header, + "./usr/bin/controller-link", + "./usr/bin/controller", + ) + .expect("append hardlink member"); + archive.finish().expect("finish legacy archive"); + + let manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts"); + + assert!( + manifest.files.iter().any(|record| { + record == &FileRecord::hardlink("usr/bin/controller-link", "usr/bin/controller") + }), + "collected files: {:?}", + manifest.files + ); +} + +#[test] fn normalizes_dot_prefixed_paths_from_a_legacy_tree_archive() { let fixture = tempdir().expect("temporary directory"); let rootfs = fixture.path().join("rootfs"); |