use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static NONCE: AtomicU64 = AtomicU64::new(0);
fn next_temp(target: &Path) -> PathBuf {
let parent = target.parent().filter(|p| !p.as_os_str().is_empty());
let stem = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "atomic".to_string());
let nonce = format!(
"{}-{}-{}",
std::process::id(),
crate::time_util::now_unix_nanos(),
NONCE.fetch_add(1, Ordering::Relaxed),
);
let name = format!(".{}.{}.tmp", stem, nonce);
match parent {
Some(p) => p.join(name),
None => PathBuf::from(name),
}
}
#[cfg(unix)]
fn existing_mode(target: &Path) -> Option<u32> {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(target)
.ok()
.map(|m| m.permissions().mode())
}
#[cfg(not(unix))]
fn existing_mode(_target: &Path) -> Option<u32> {
None
}
pub fn atomic_write_sync(target: &Path, content: &[u8]) -> io::Result<()> {
atomic_write_inner(target, content, true)
}
fn atomic_write_inner(target: &Path, content: &[u8], private: bool) -> io::Result<()> {
let prev_mode = existing_mode(target);
let tmp = next_temp(target);
let result: io::Result<()> = (|| {
use std::io::Write;
let mut f = std::fs::File::create(&tmp)?;
#[cfg(unix)]
if private && prev_mode.is_none() {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
f.write_all(content)?;
let _ = f.sync_all();
#[cfg(unix)]
if let Some(mode) = prev_mode {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
let _ = (prev_mode, private);
std::fs::rename(&tmp, target)?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
pub async fn atomic_write(target: &Path, content: &[u8]) -> io::Result<()> {
let target = target.to_path_buf();
let bytes = content.to_vec();
tokio::task::spawn_blocking(move || {
atomic_write_inner(&target, &bytes, false)
})
.await
.map_err(|e| io::Error::other(format!("spawn_blocking join failed: {e}")))?
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
struct TestDir(std::path::PathBuf);
impl TestDir {
fn new(tag: &str) -> Self {
let p = std::env::temp_dir().join(format!(
"dirge_atomic_{}_{}_{}",
tag,
std::process::id(),
NONCE.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
Self(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn atomic_write_creates_new_file() {
let dir = TestDir::new("new");
let target = dir.path().join("new.txt");
atomic_write_sync(&target, b"hello").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"hello");
}
#[test]
fn atomic_write_overwrites_existing() {
let dir = TestDir::new("overwrite");
let target = dir.path().join("existing.txt");
std::fs::write(&target, b"old").unwrap();
atomic_write_sync(&target, b"new content").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"new content");
}
#[test]
fn temp_is_hidden_sibling() {
let dir = TestDir::new("sibling");
let target = dir.path().join("foo.txt");
let tmp = next_temp(&target);
assert_eq!(tmp.parent().unwrap(), dir.path());
let name = tmp.file_name().unwrap().to_string_lossy().into_owned();
assert!(name.starts_with(".foo.txt."), "got {name}");
assert!(name.ends_with(".tmp"));
}
#[test]
fn next_temp_is_unique() {
let target = std::path::Path::new("/tmp/sample.txt");
let mut seen = std::collections::HashSet::new();
for _ in 0..1000 {
let t = next_temp(target);
assert!(seen.insert(t), "collision in 1000 calls");
}
}
#[cfg(unix)]
#[test]
fn atomic_write_preserves_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = TestDir::new("mode");
let target = dir.path().join("script.sh");
std::fs::write(&target, b"#!/bin/sh\necho hi").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
atomic_write_sync(&target, b"#!/bin/sh\necho new").unwrap();
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "executable bit dropped");
}
#[cfg(unix)]
#[test]
fn private_write_new_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = TestDir::new("priv");
let target = dir.path().join("session.json");
atomic_write_sync(&target, b"secret").unwrap();
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "session/memory files must be owner-only");
}
#[cfg(unix)]
#[test]
fn project_write_matches_umask_like_plain_create() {
use std::os::unix::fs::PermissionsExt;
let dir = TestDir::new("proj");
let reference = dir.path().join("reference");
std::fs::File::create(&reference).unwrap();
let want = std::fs::metadata(&reference).unwrap().permissions().mode() & 0o777;
let target = dir.path().join("src.rs");
atomic_write_inner(&target, b"fn main() {}", false).unwrap();
let got = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(
got, want,
"project file mode should follow the umask (like a plain create), not be forced to 0600"
);
}
#[test]
fn target_untouched_on_failed_rename() {
let dir = TestDir::new("crash");
let target = dir.path().join("durable.txt");
std::fs::write(&target, b"original").unwrap();
let tmp = next_temp(&target);
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"corrupt half-write").unwrap();
drop(f);
assert_eq!(std::fs::read(&target).unwrap(), b"original");
}
}