From 251509d734a635f8e6448cef0925d881d4f5f184 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 05:03:36 +0000 Subject: Publish archives atomically --- src/archive.rs | 44 +++++++++++++++++++++++++++++++++++--------- tests/archive.rs | 17 +++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/archive.rs b/src/archive.rs index 70392fa..9a899bf 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; -use std::fs::{self, File}; +use std::fs::{self, File, OpenOptions}; use std::os::unix::fs::MetadataExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use tar::{Builder, EntryType, Header}; @@ -26,16 +26,42 @@ 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(""), &mut BTreeMap::new())?; - archive - .finish() - .with_context(|| format!("finish archive {}", output.display()))?; - Ok(()) + 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()))?; + fs::rename(&temporary, output).with_context(|| { + format!( + "publish completed archive {} as {}", + temporary.display(), + output.display() + ) + }) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result } } +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, diff --git a/tests/archive.rs b/tests/archive.rs index e31b65a..da3e66a 100644 --- a/tests/archive.rs +++ b/tests/archive.rs @@ -1,5 +1,6 @@ use std::fs; use std::os::unix::fs::symlink; +use std::os::unix::net::UnixListener; use alt_controller_image::archive::NativeTarWriter; use tar::Archive; @@ -105,3 +106,19 @@ fn rejects_a_missing_rootfs() { .expect_err("missing rootfs must fail"); assert!(error.to_string().contains("rootfs is not a directory")); } + +#[test] +fn removes_partial_output_when_an_unsupported_entry_stops_packaging() { + let fixture = tempdir().expect("fixture directory"); + let rootfs = fixture.path().join("rootfs"); + fs::create_dir(&rootfs).expect("create rootfs"); + let _socket = UnixListener::bind(rootfs.join("image-builder.sock")).expect("create socket"); + let artifact = fixture.path().join("image.tar"); + + let error = NativeTarWriter::new() + .write(&rootfs, &artifact) + .expect_err("socket entries must be rejected"); + + assert!(error.to_string().contains("unsupported rootfs entry type")); + assert!(!artifact.exists(), "failed packaging must not publish a partial archive"); +} -- cgit v1.2.3