From 434fc29ab867d630185b632a43e82513ac918fe5 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 03:27:18 +0000 Subject: Preserve hardlinks in native tar archives --- src/archive.rs | 36 +++++++++++++++++++++++++++++++----- tests/archive.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/archive.rs b/src/archive.rs index 0076364..70392fa 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -1,4 +1,6 @@ +use std::collections::BTreeMap; use std::fs::{self, File}; +use std::os::unix::fs::MetadataExt; use std::path::Path; use anyhow::{bail, Context, Result}; @@ -26,7 +28,7 @@ impl NativeTarWriter { let output = output.as_ref(); let file = File::create(output).with_context(|| format!("create archive {}", output.display()))?; let mut archive = Builder::new(file); - append_tree(&mut archive, rootfs, Path::new(""))?; + append_tree(&mut archive, rootfs, Path::new(""), &mut BTreeMap::new())?; archive .finish() .with_context(|| format!("finish archive {}", output.display()))?; @@ -34,7 +36,12 @@ impl NativeTarWriter { } } -fn append_tree(archive: &mut Builder, rootfs: &Path, relative: &Path) -> Result<()> { +fn append_tree( + archive: &mut Builder, + rootfs: &Path, + relative: &Path, + hardlink_targets: &mut BTreeMap<(u64, u64), std::path::PathBuf>, +) -> Result<()> { let directory = rootfs.join(relative); let mut entries = fs::read_dir(&directory) .with_context(|| format!("read rootfs directory {}", directory.display()))? @@ -54,9 +61,9 @@ fn append_tree(archive: &mut Builder, rootfs: &Path, relative: &Path) -> R if file_type.is_dir() { append_directory(archive, &archive_path, &metadata)?; - append_tree(archive, rootfs, &archive_path)?; + append_tree(archive, rootfs, &archive_path, hardlink_targets)?; } else if file_type.is_file() { - append_file(archive, &path, &archive_path, &metadata)?; + append_file(archive, &path, &archive_path, &metadata, hardlink_targets)?; } else if file_type.is_symlink() { append_symlink(archive, &path, &archive_path, &metadata)?; } else { @@ -91,12 +98,31 @@ fn append_file( source: &Path, path: &Path, metadata: &fs::Metadata, + hardlink_targets: &mut BTreeMap<(u64, u64), std::path::PathBuf>, ) -> Result<()> { + let key = (metadata.dev(), metadata.ino()); + if let Some(target) = hardlink_targets.get(&key) { + return append_hardlink(archive, path, target, metadata); + } let mut header = deterministic_header(metadata, EntryType::Regular, metadata.len()); let input = File::open(source).with_context(|| format!("open rootfs file {}", source.display()))?; archive .append_data(&mut header, path, input) - .with_context(|| format!("append file {}", path.display())) + .with_context(|| format!("append file {}", path.display()))?; + hardlink_targets.insert(key, path.to_path_buf()); + Ok(()) +} + +fn append_hardlink( + archive: &mut Builder, + path: &Path, + target: &Path, + metadata: &fs::Metadata, +) -> Result<()> { + let mut header = deterministic_header(metadata, EntryType::Link, 0); + archive + .append_link(&mut header, path, target) + .with_context(|| format!("append hardlink {}", path.display())) } fn append_symlink( diff --git a/tests/archive.rs b/tests/archive.rs index 3956849..e31b65a 100644 --- a/tests/archive.rs +++ b/tests/archive.rs @@ -47,6 +47,56 @@ fn writes_deterministic_tar_with_files_directories_and_symlinks() { ); } +#[test] +fn preserves_hardlinks_in_the_archive() { + use std::os::unix::fs::MetadataExt; + + let fixture = tempdir().expect("fixture directory"); + let rootfs = fixture.path().join("rootfs"); + fs::create_dir_all(rootfs.join("usr/bin")).expect("create rootfs tree"); + let original = rootfs.join("usr/bin/controller"); + let alias = rootfs.join("usr/bin/controller-link"); + fs::write(&original, "controller\n").expect("write controller"); + fs::hard_link(&original, &alias).expect("create hardlink"); + assert_eq!( + fs::metadata(&original).expect("stat original").ino(), + fs::metadata(&alias).expect("stat alias").ino() + ); + + let artifact = fixture.path().join("controller.tar"); + NativeTarWriter::new() + .write(&rootfs, &artifact) + .expect("write archive"); + + let mut archive = Archive::new(fs::File::open(artifact).expect("open archive")); + let entries = archive + .entries() + .expect("read archive entries") + .map(|entry| { + let entry = entry.expect("read entry"); + ( + entry.path().expect("entry path").into_owned(), + entry.header().entry_type(), + entry.link_name().expect("entry link name").map(|path| path.into_owned()), + ) + }) + .collect::>(); + + assert_eq!( + entries, + vec![ + ("usr".into(), tar::EntryType::Directory, None), + ("usr/bin".into(), tar::EntryType::Directory, None), + ("usr/bin/controller".into(), tar::EntryType::Regular, None), + ( + "usr/bin/controller-link".into(), + tar::EntryType::Link, + Some("usr/bin/controller".into()) + ), + ] + ); +} + #[test] fn rejects_a_missing_rootfs() { let fixture = tempdir().expect("fixture directory"); -- cgit v1.2.3