use std::collections::BTreeMap; use std::fs::{self, File, OpenOptions}; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; 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, output: impl AsRef) -> Result<()> { let rootfs = rootfs.as_ref(); if !rootfs.is_dir() { bail!("rootfs is not a directory: {}", rootfs.display()); } let output = output.as_ref(); reject_output_inside_rootfs(rootfs, output)?; if output.exists() { bail!( "archive output already exists; refusing to overwrite it: {}", output.display() ); } let temporary = temporary_output_path(output)?; let result = (|| { let file = OpenOptions::new() .write(true) .create_new(true) .open(&temporary) .with_context(|| format!("create temporary archive {}", temporary.display()))?; let mut archive = Builder::new(file); append_tree(&mut archive, rootfs, Path::new(""), &mut BTreeMap::new())?; archive .finish() .with_context(|| format!("finish archive {}", temporary.display()))?; // `rename` replaces an existing destination on Unix. Publishing via // a hard link instead gives this same-directory temporary file an // atomic no-replace final name. fs::hard_link(&temporary, output).with_context(|| { format!( "publish completed archive {} as {}", temporary.display(), output.display() ) })?; fs::remove_file(&temporary) .with_context(|| format!("remove temporary archive {}", temporary.display())) })(); if result.is_err() { let _ = fs::remove_file(&temporary); } result } } fn reject_output_inside_rootfs(rootfs: &Path, output: &Path) -> Result<()> { let rootfs = rootfs .canonicalize() .with_context(|| format!("canonicalize rootfs {}", rootfs.display()))?; let output_parent = output.parent().unwrap_or_else(|| Path::new(".")); let output_parent = output_parent.canonicalize().with_context(|| { format!( "canonicalize archive output parent {}", output_parent.display() ) })?; if output_parent.starts_with(&rootfs) { bail!( "archive output must not be inside rootfs: {} is below {}", output.display(), rootfs.display() ); } Ok(()) } fn temporary_output_path(output: &Path) -> Result { let name = output.file_name().ok_or_else(|| { anyhow::anyhow!("archive output path has no filename: {}", output.display()) })?; let mut temporary_name = name.to_os_string(); temporary_name.push(".partial"); Ok(output.with_file_name(temporary_name)) } 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()))? .collect::, _>>()?; 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, hardlink_targets)?; } else if file_type.is_file() { append_file(archive, &path, &archive_path, &metadata, hardlink_targets)?; } 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, 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, 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()))?; 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( archive: &mut Builder, 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())) }