diff options
| author | Hermes Agent <hermes@localhost> | 2026-08-12 03:27:18 +0000 |
|---|---|---|
| committer | Hermes Agent <hermes@localhost> | 2026-08-12 03:27:18 +0000 |
| commit | 434fc29ab867d630185b632a43e82513ac918fe5 (patch) | |
| tree | d6fd932fa082b6eebe5701e46fd9fee42b675f0b | |
| parent | 47f461b35fe77c9454488b64fddc2576ff2561be (diff) | |
Preserve hardlinks in native tar archives
| -rw-r--r-- | src/archive.rs | 36 | ||||
| -rw-r--r-- | 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<File>, rootfs: &Path, relative: &Path) -> Result<()> { +fn append_tree( + archive: &mut Builder<File>, + 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<File>, 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<File>, + 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 @@ -48,6 +48,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::<Vec<_>>(); + + 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"); let error = NativeTarWriter::new() |