From 793c277890fe98ab06d631f1892d4561801c402d Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Sat, 11 Apr 2026 09:43:50 -0700 Subject: [PATCH 1/2] fix: use atomic write-then-rename for state files to prevent corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State file writes using fs::write are non-atomic (truncate then write), risking corruption if the process is interrupted mid-write. Replace with a write-then-rename pattern using tempfile::NamedTempFile, which writes to a temporary file in the same directory then atomically renames it into place. Closes #237 🤖 Generated with [Nori](https://noriagentic.com) Co-Authored-By: Nori --- Cargo.toml | 2 +- src/state.rs | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e26a969..6ccd38b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,12 +64,12 @@ similar = "2.7.0" indicatif = "0.18.4" ctrlc = "3.5.2" glob = "0.3.3" +tempfile = "3" [build-dependencies] vergen = { version = "9", features = ["build"] } [dev-dependencies] -tempfile = "3" assert_cmd = "2.2" insta = "1" predicates = "3" diff --git a/src/state.rs b/src/state.rs index 91722e8..b056814 100644 --- a/src/state.rs +++ b/src/state.rs @@ -11,7 +11,9 @@ use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; use std::fs; use std::hash::{Hash, Hasher}; +use std::io::Write; use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; use crate::overlay_name::OverlayName; @@ -559,6 +561,22 @@ pub(crate) fn external_state_dir_for_target(target: &Path) -> Result { Ok(base.join(target_hash)) } +/// Write content to a file atomically using write-then-rename. +/// +/// Creates a temporary file in the same directory as the target path, +/// writes the content, then atomically renames it into place. This +/// prevents corruption if the process is interrupted mid-write. +fn atomic_write(path: &Path, content: &str) -> Result<()> { + let dir = path + .parent() + .context("State file has no parent directory")?; + let mut tmp = NamedTempFile::new_in(dir)?; + tmp.write_all(content.as_bytes())?; + tmp.persist(path) + .context("Failed to atomically persist state file")?; + Ok(()) +} + /// Save overlay state to the external backup location. pub(crate) fn save_external_state( target: &Path, @@ -577,7 +595,7 @@ pub(crate) fn save_external_state( let state_file = dir.join(format!("{overlay_name}.ccl")); let content = sickle::to_string(state).context("Failed to serialize state to CCL")?; - fs::write(&state_file, content)?; + atomic_write(&state_file, &content)?; Ok(()) } @@ -597,7 +615,7 @@ pub(crate) fn remove_external_state(target: &Path, overlay_name: &str) -> Result if let Ok(mut state) = sickle::from_str::(&content) { state.removed_at = Some(Utc::now()); let updated_content = sickle::to_string(&state).context("Failed to serialize state")?; - fs::write(&state_file, updated_content)?; + atomic_write(&state_file, &updated_content)?; } else { // If we can't parse it, just delete it fs::remove_file(&state_file)?; @@ -776,7 +794,7 @@ pub(crate) fn save_overlay_state(target: &Path, state: &OverlayState) -> Result< let state_file = overlays_dir.join(format!("{normalized_name}.ccl")); let content = sickle::to_string(state).context("Failed to serialize overlay state")?; - fs::write(&state_file, content)?; + atomic_write(&state_file, &content)?; Ok(()) } From ab2af57d5f5a92f4e8ad67b6c5d675702e299516 Mon Sep 17 00:00:00 2001 From: Tyler Butler Date: Mon, 13 Apr 2026 11:35:20 -0700 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20add=20changelog=20entry=20for=20ato?= =?UTF-8?q?mic=20state=20file=20writes=20=F0=9F=A4=96=20Generated=20with?= =?UTF-8?q?=20[Nori](https://noriagentic.com)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Nori --- .changes/unreleased/library-Fixed-20260413-112959.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changes/unreleased/library-Fixed-20260413-112959.yaml diff --git a/.changes/unreleased/library-Fixed-20260413-112959.yaml b/.changes/unreleased/library-Fixed-20260413-112959.yaml new file mode 100644 index 0000000..41cd04a --- /dev/null +++ b/.changes/unreleased/library-Fixed-20260413-112959.yaml @@ -0,0 +1,7 @@ +component: library +kind: Fixed +body: |- + Use atomic write-then-rename for state files to prevent corruption + + State file writes previously used a non-atomic truncate-then-write pattern, which could leave corrupted or empty files if the process was interrupted mid-write. State files are now written to a temporary file in the same directory and atomically renamed into place using tempfile::NamedTempFile. +time: 2026-04-13T11:29:59.721696000-07:00