summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHermes Agent <hermes@localhost>2026-08-12 01:25:38 +0000
committerHermes Agent <hermes@localhost>2026-08-12 01:25:38 +0000
commit020fbf6a034b3c90de1bdd3ee5e3e5e15c783059 (patch)
treee43a833f092357dc76ea664ee2d840ed6f706c55
parent9a07a0e20b88838d22bb04fae80b2353d41b2cd0 (diff)
Add native deterministic tar writer
-rw-r--r--README.md4
-rw-r--r--src/archive.rs113
-rw-r--r--src/lib.rs1
-rw-r--r--tests/archive.rs57
4 files changed, 173 insertions, 2 deletions
diff --git a/README.md b/README.md
index 9b8e9b6..67e1734 100644
--- a/README.md
+++ b/README.md
@@ -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()))
+}
diff --git a/src/lib.rs b/src/lib.rs
index 92ad4bc..8375dfb 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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"));
+}