summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHermes Agent <hermes@localhost>2026-08-12 03:36:47 +0000
committerHermes Agent <hermes@localhost>2026-08-12 03:36:47 +0000
commit754972b95ba2fabc082aed387c246e632a8d7434 (patch)
tree9a86dc5c927ea6dfea5d381ad1d9ccdcf882c2bd
parent434fc29ab867d630185b632a43e82513ac918fe5 (diff)
Preserve rootfs hardlink facts in manifests
-rw-r--r--src/manifest.rs23
-rw-r--r--tests/compare.rs28
2 files changed, 46 insertions, 5 deletions
diff --git a/src/manifest.rs b/src/manifest.rs
index 307913e..6168706 100644
--- a/src/manifest.rs
+++ b/src/manifest.rs
@@ -1,6 +1,7 @@
-use std::collections::HashSet;
+use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::io::Read;
+use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
@@ -154,8 +155,15 @@ impl ArtifactManifest {
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 mut entries = WalkDir::new(rootfs)
+ .follow_links(false)
+ .min_depth(1)
+ .into_iter()
+ .collect::<std::result::Result<Vec<_>, _>>()
+ .with_context(|| format!("walk rootfs {}", rootfs.display()))?;
+ entries.sort_by_key(|entry| entry.path().to_path_buf());
+ let mut hardlink_targets: BTreeMap<(u64, u64), String> = BTreeMap::new();
+ for entry in entries {
let relative = entry.path().strip_prefix(rootfs).expect("walk entry is below rootfs");
if is_internal_metadata_path(&portable_path(relative)?) {
continue;
@@ -163,7 +171,14 @@ fn collect_files(rootfs: &Path) -> Result<Vec<FileRecord>> {
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())?));
+ let metadata = entry.metadata().with_context(|| format!("inspect rootfs file {}", entry.path().display()))?;
+ let key = (metadata.dev(), metadata.ino());
+ if let Some(target) = hardlink_targets.get(&key) {
+ files.push(FileRecord::hardlink(path, target.clone()));
+ } else {
+ hardlink_targets.insert(key, path.clone());
+ 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()))?;
diff --git a/tests/compare.rs b/tests/compare.rs
index 7dd44e2..a175195 100644
--- a/tests/compare.rs
+++ b/tests/compare.rs
@@ -3,7 +3,7 @@ use alt_controller_image::archive::NativeTarWriter;
use alt_controller_image::manifest::{
ArchiveMemberRecord, ArtifactManifest, FileRecord, InitrdRecord, PackageRecord, ServiceRecord,
};
-use std::fs;
+use std::fs::{self, hard_link};
use std::io::Cursor;
use std::os::unix::fs::symlink;
use tempfile::tempdir;
@@ -254,6 +254,32 @@ fn collects_hardlink_targets_from_an_archive_for_semantic_comparison() {
}
#[test]
+fn rootfs_collection_preserves_hardlink_facts_like_archive_collection() {
+ let fixture = tempdir().expect("temporary directory");
+ let rootfs = fixture.path().join("rootfs");
+ fs::create_dir_all(rootfs.join("usr/bin")).expect("create binary directory");
+ fs::write(rootfs.join("usr/bin/controller"), "controller\n").expect("write binary");
+ hard_link(
+ rootfs.join("usr/bin/controller"),
+ rootfs.join("usr/bin/controller-link"),
+ )
+ .expect("create hardlink");
+ let artifact = fixture.path().join("controller.tar");
+ NativeTarWriter::new()
+ .write(&rootfs, &artifact)
+ .expect("write native archive");
+
+ let rootfs_manifest = ArtifactManifest::collect(&rootfs, vec![], None, &artifact)
+ .expect("collect rootfs facts");
+ let archive_manifest = ArtifactManifest::collect_archive(&artifact).expect("collect archive facts");
+
+ assert_eq!(rootfs_manifest.files, archive_manifest.files);
+ assert!(rootfs_manifest.files.iter().any(|record| {
+ record == &FileRecord::hardlink("usr/bin/controller-link", "usr/bin/controller")
+ }));
+}
+
+#[test]
fn normalizes_dot_prefixed_paths_from_a_legacy_tree_archive() {
let fixture = tempdir().expect("temporary directory");
let rootfs = fixture.path().join("rootfs");