diff options
| author | Hermes Agent <hermes@localhost> | 2026-08-12 01:25:38 +0000 |
|---|---|---|
| committer | Hermes Agent <hermes@localhost> | 2026-08-12 01:25:38 +0000 |
| commit | 020fbf6a034b3c90de1bdd3ee5e3e5e15c783059 (patch) | |
| tree | e43a833f092357dc76ea664ee2d840ed6f706c55 | |
| parent | 9a07a0e20b88838d22bb04fae80b2353d41b2cd0 (diff) | |
Add native deterministic tar writer
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | src/archive.rs | 113 | ||||
| -rw-r--r-- | src/lib.rs | 1 | ||||
| -rw-r--r-- | tests/archive.rs | 57 |
4 files changed, 173 insertions, 2 deletions
@@ -4,11 +4,11 @@ An independent Rust proof of concept that produces an ALT Controller rootfs tarb ## Pipeline -1. Rust parses `profiles/alt-controller.profile`. +1. Rust parses the typed TOML image definition at `profiles/alt-controller.toml`. 2. The profile uses vendored package lists under `profiles/package-lists/`. 3. Rust initializes an isolated Hasher work root with `hsh --initroot-only`. 4. Rust installs the resolved package set with `hsh-install`. -5. Rust invokes `sudo tar` to archive `work/alt-controller/chroot` to `out/alt-controller-rootfs.tar`, excluding Hasher's `.host` helper directory. Root is needed only to read root-owned/setuid paths produced inside the isolated Hasher root. +5. Rust finalizes the rootfs and invokes its native deterministic tar writer to archive it, excluding Hasher's internal `.host` and `.fakedata` directories. The archive records normalized numeric ownership and timestamps; ACLs and xattrs are not represented by the portable initial format. Hasher is intentionally the only external build dependency in this slice. It supplies ALT package resolution and isolation; the image/profile orchestration belongs to this project. diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..0076364 --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,113 @@ +use std::fs::{self, File}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use tar::{Builder, EntryType, Header}; + +/// Writes a deterministic rootfs tar archive using numeric ownership metadata. +/// +/// Extended ACLs and xattrs are intentionally not represented by this initial +/// portable archive format. Callers that require them must use a future archive +/// policy which can verify their availability before packaging. +#[derive(Debug, Default, Clone, Copy)] +pub struct NativeTarWriter; + +impl NativeTarWriter { + pub fn new() -> Self { + Self + } + + pub fn write(&self, rootfs: impl AsRef<Path>, output: impl AsRef<Path>) -> Result<()> { + let rootfs = rootfs.as_ref(); + if !rootfs.is_dir() { + bail!("rootfs is not a directory: {}", rootfs.display()); + } + + 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(""))?; + archive + .finish() + .with_context(|| format!("finish archive {}", output.display()))?; + Ok(()) + } +} + +fn append_tree(archive: &mut Builder<File>, rootfs: &Path, relative: &Path) -> Result<()> { + let directory = rootfs.join(relative); + let mut entries = fs::read_dir(&directory) + .with_context(|| format!("read rootfs directory {}", directory.display()))? + .collect::<std::result::Result<Vec<_>, _>>()?; + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + let name = entry.file_name(); + if name == ".host" || name == ".fakedata" { + continue; + } + let path = entry.path(); + let archive_path = relative.join(&name); + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("inspect rootfs entry {}", path.display()))?; + let file_type = metadata.file_type(); + + if file_type.is_dir() { + append_directory(archive, &archive_path, &metadata)?; + append_tree(archive, rootfs, &archive_path)?; + } else if file_type.is_file() { + append_file(archive, &path, &archive_path, &metadata)?; + } else if file_type.is_symlink() { + append_symlink(archive, &path, &archive_path, &metadata)?; + } else { + bail!("unsupported rootfs entry type: {}", path.display()); + } + } + Ok(()) +} + +fn deterministic_header(metadata: &fs::Metadata, entry_type: EntryType, size: u64) -> Header { + use std::os::unix::fs::PermissionsExt; + + let mut header = Header::new_gnu(); + header.set_entry_type(entry_type); + header.set_mode(metadata.permissions().mode()); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_size(size); + header +} + +fn append_directory(archive: &mut Builder<File>, path: &Path, metadata: &fs::Metadata) -> Result<()> { + let mut header = deterministic_header(metadata, EntryType::Directory, 0); + archive + .append_data(&mut header, path, std::io::empty()) + .with_context(|| format!("append directory {}", path.display())) +} + +fn append_file( + archive: &mut Builder<File>, + source: &Path, + path: &Path, + metadata: &fs::Metadata, +) -> Result<()> { + 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())) +} + +fn append_symlink( + archive: &mut Builder<File>, + source: &Path, + path: &Path, + metadata: &fs::Metadata, +) -> Result<()> { + let target = fs::read_link(source).with_context(|| format!("read rootfs symlink {}", source.display()))?; + let mut header = deterministic_header(metadata, EntryType::Symlink, 0); + archive + .append_link(&mut header, path, target) + .with_context(|| format!("append symlink {}", path.display())) +} @@ -1,3 +1,4 @@ +pub mod archive; pub mod cli; pub mod files; pub mod hasher; diff --git a/tests/archive.rs b/tests/archive.rs new file mode 100644 index 0000000..3956849 --- /dev/null +++ b/tests/archive.rs @@ -0,0 +1,57 @@ +use std::fs; +use std::os::unix::fs::symlink; + +use alt_controller_image::archive::NativeTarWriter; +use tar::Archive; +use tempfile::tempdir; + +#[test] +fn writes_deterministic_tar_with_files_directories_and_symlinks() { + let fixture = tempdir().expect("fixture directory"); + let rootfs = fixture.path().join("rootfs"); + fs::create_dir_all(rootfs.join("etc/nested")).expect("create rootfs tree"); + fs::write(rootfs.join("etc/nested/config"), "controller=true\n").expect("write config"); + symlink("nested/config", rootfs.join("etc/config")).expect("create symlink"); + fs::create_dir_all(rootfs.join(".host/private")).expect("create host metadata"); + fs::write(rootfs.join(".host/private/key"), "excluded").expect("write host metadata"); + fs::create_dir_all(rootfs.join("var/.fakedata")).expect("create fake metadata"); + fs::write(rootfs.join("var/.fakedata/db"), "excluded").expect("write fake metadata"); + + let first = fixture.path().join("first.tar"); + let second = fixture.path().join("second.tar"); + NativeTarWriter::new().write(&rootfs, &first).expect("write first archive"); + NativeTarWriter::new().write(&rootfs, &second).expect("write second archive"); + + assert_eq!(fs::read(&first).expect("read first"), fs::read(&second).expect("read second")); + + let mut archive = Archive::new(fs::File::open(first).expect("open archive")); + let entries = archive + .entries() + .expect("read archive entries") + .map(|entry| { + let entry = entry.expect("read entry"); + let path = entry.path().expect("entry path").into_owned(); + (path, entry.header().entry_type(), entry.header().mtime().expect("mtime"), entry.header().uid().expect("uid"), entry.header().gid().expect("gid")) + }) + .collect::<Vec<_>>(); + + assert_eq!( + entries, + vec![ + ("etc".into(), tar::EntryType::Directory, 0, 0, 0), + ("etc/config".into(), tar::EntryType::Symlink, 0, 0, 0), + ("etc/nested".into(), tar::EntryType::Directory, 0, 0, 0), + ("etc/nested/config".into(), tar::EntryType::Regular, 0, 0, 0), + ("var".into(), tar::EntryType::Directory, 0, 0, 0), + ] + ); +} + +#[test] +fn rejects_a_missing_rootfs() { + let fixture = tempdir().expect("fixture directory"); + let error = NativeTarWriter::new() + .write(fixture.path().join("missing"), fixture.path().join("image.tar")) + .expect_err("missing rootfs must fail"); + assert!(error.to_string().contains("rootfs is not a directory")); +} |