use std::path::Path;
struct TempGuard<'a> {
path: &'a Path,
active: bool,
}
impl<'a> TempGuard<'a> {
fn new(path: &'a Path) -> Self {
Self { path, active: true }
}
fn defuse(&mut self) {
self.active = false;
}
}
impl Drop for TempGuard<'_> {
fn drop(&mut self) {
if self.active {
let _ = std::fs::remove_file(self.path);
}
}
}
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path has no parent directory",
)
})?;
let file_name = path
.file_name()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name")
})?
.to_string_lossy();
let tmp_path = parent.join(format!(".{file_name}.tmp"));
let mut guard = TempGuard::new(&tmp_path);
write_tmp(&tmp_path, data)?;
std::fs::rename(&tmp_path, path)?;
guard.defuse();
if let Err(e) = fsync_dir(parent) {
tracing::warn!("fsync of parent directory failed (data is written): {e}");
}
Ok(())
}
fn write_tmp(tmp_path: &Path, data: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let mut f = opts.open(tmp_path)?;
f.write_all(data)?;
f.sync_all()?;
Ok(())
}
#[cfg(unix)]
fn fsync_dir(dir: &Path) -> std::io::Result<()> {
let d = std::fs::File::open(dir)?;
d.sync_all()?;
Ok(())
}
#[cfg(not(unix))]
fn fsync_dir(_dir: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn atomic_write_creates_file() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test.txt");
atomic_write(&target, b"hello world").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "hello world");
assert!(!dir.path().join(".test.txt.tmp").exists());
}
#[test]
fn atomic_write_overwrites_existing() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("test.txt");
fs::write(&target, "old content").unwrap();
atomic_write(&target, b"new content").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new content");
}
#[cfg(unix)]
#[test]
fn atomic_write_sets_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("secret.txt");
atomic_write(&target, b"secret").unwrap();
let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "file should be 0600, got {mode:o}");
}
#[test]
fn temp_file_cleaned_on_missing_parent() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("nonexistent_dir").join("file.txt");
assert!(atomic_write(&target, b"data").is_err());
}
#[cfg(unix)]
#[test]
fn temp_file_cleaned_on_rename_failure() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("file.txt");
let tmp = dir.path().join(".file.txt.tmp");
fs::create_dir(&target).unwrap();
let result = atomic_write(&target, b"data");
assert!(result.is_err(), "expected rename to fail with EISDIR");
assert!(!tmp.exists(), "temp file should be cleaned up by TempGuard");
assert!(target.is_dir(), "target directory should be untouched");
}
#[cfg(unix)]
#[test]
fn atomic_write_tightens_permissions_on_overwrite() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("wide.txt");
fs::write(&target, "old").unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
atomic_write(&target, b"new").unwrap();
let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "overwritten file should be 0600, got {mode:o}");
}
#[cfg(unix)]
#[test]
fn atomic_write_rejects_symlink_temp_path() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("file.txt");
let tmp = dir.path().join(".file.txt.tmp");
let decoy = dir.path().join("decoy.txt");
std::os::unix::fs::symlink(&decoy, &tmp).unwrap();
let result = atomic_write(&target, b"secret");
assert!(result.is_err(), "should reject symlink at temp path");
assert!(!decoy.exists(), "symlink target should be untouched");
}
}