use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result};
static SEQ: AtomicU64 = AtomicU64::new(0);
pub(crate) fn staging_path(path: &Path) -> PathBuf {
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".{}.{seq}.tmp", std::process::id()));
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.join(name),
_ => PathBuf::from(name),
}
}
fn create_parent(path: &Path) -> std::io::Result<()> {
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => std::fs::create_dir_all(parent),
_ => Ok(()),
}
}
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
create_parent(path).with_context(|| {
format!(
"creating parent directory for {} before an atomic write",
path.display()
)
})?;
let tmp = staging_path(path);
std::fs::write(&tmp, bytes).with_context(|| format!("writing {}", tmp.display()))?;
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e).with_context(|| format!("renaming {} → {}", tmp.display(), path.display()));
}
Ok(())
}
pub(crate) async fn write_atomic_async(path: &Path, bytes: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("creating directory {}", parent.display()))?;
}
}
let tmp = staging_path(path);
tokio::fs::write(&tmp, bytes)
.await
.with_context(|| format!("writing {}", tmp.display()))?;
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(e).with_context(|| format!("renaming {} → {}", tmp.display(), path.display()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staging_paths_never_repeat_within_a_process() {
let target = Path::new("/tmp/does-not-matter/state.json");
let a = staging_path(target);
let b = staging_path(target);
assert_ne!(a, b, "two writers of one path must not share a staging file");
assert_eq!(a.parent(), target.parent(), "staging stays a sibling");
for p in [&a, &b] {
let name = p.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("state.json."), "{name}");
assert!(name.ends_with(".tmp"), "{name}");
}
}
#[test]
fn a_bare_filename_stages_beside_itself() {
let p = staging_path(Path::new("state.json"));
assert_eq!(p.parent(), Some(Path::new("")));
assert!(p.to_str().unwrap().starts_with("state.json."));
}
#[test]
fn write_atomic_creates_the_parent_and_leaves_no_staging_file() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("nested/deeper/state.json");
write_atomic(&target, b"{}").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"{}");
let strays: Vec<_> = std::fs::read_dir(target.parent().unwrap())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(strays.is_empty(), "leaked staging files: {strays:?}");
}
#[tokio::test]
async fn write_atomic_async_round_trips() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("ac/key.out");
write_atomic_async(&target, b"hash\n").await.unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"hash\n");
}
#[test]
fn a_failed_rename_removes_the_staging_file() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("occupied");
std::fs::create_dir(&target).unwrap();
std::fs::write(target.join("child"), b"x").unwrap();
assert!(write_atomic(&target, b"bytes").is_err());
let strays: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.ends_with(".tmp"))
.collect();
assert!(strays.is_empty(), "leaked staging files: {strays:?}");
}
}