use std::{
fs, io,
path::{Path, PathBuf},
};
const MAX_RETRIES: u32 = 3;
pub(crate) struct SafeWriter {
temp_path: Option<PathBuf>,
final_path: PathBuf,
file: fs::File,
}
impl SafeWriter {
pub(crate) fn new(final_path: impl AsRef<Path>) -> io::Result<Self> {
let final_path = final_path.as_ref().to_path_buf();
let parent = final_path.parent().unwrap_or(Path::new("."));
for _ in 0..MAX_RETRIES {
let random = rand::random::<u64>();
let temp_name = format!(".pna.{:016x}", random);
let temp_path = parent.join(temp_name);
match fs::File::create_new(&temp_path) {
Ok(file) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
return Ok(Self {
temp_path: Some(temp_path),
final_path,
file,
});
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("failed to create unique temp file after {MAX_RETRIES} attempts"),
))
}
#[inline]
pub(crate) fn as_file_mut(&mut self) -> &mut fs::File {
&mut self.file
}
pub(crate) fn persist(mut self) -> io::Result<()> {
self.file.sync_all()?;
self.prepare_destination()?;
let temp_path = self
.temp_path
.as_deref()
.expect("persist called on already-persisted SafeWriter");
fs::rename(temp_path, &self.final_path)?;
self.temp_path = None;
Ok(())
}
fn prepare_destination(&self) -> io::Result<()> {
#[cfg(windows)]
use std::os::windows::fs::FileTypeExt;
match fs::symlink_metadata(&self.final_path) {
Ok(meta) if meta.file_type().is_dir() => fs::remove_dir(&self.final_path),
#[cfg(windows)]
Ok(meta) if meta.file_type().is_symlink_dir() => fs::remove_dir(&self.final_path),
Ok(_) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
}
impl io::Write for SafeWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.file.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
impl Drop for SafeWriter {
fn drop(&mut self) {
let Some(ref path) = self.temp_path else {
return;
};
if let Err(e) = fs::remove_file(path)
&& e.kind() != io::ErrorKind::NotFound
{
log::warn!("Failed to clean up temp file '{}': {}", path.display(), e);
}
}
}
#[cfg(test)]
#[cfg(not(target_family = "wasm"))]
mod tests {
use super::*;
use std::io::Write;
fn test_dir() -> PathBuf {
let dir = std::env::temp_dir().join("pna_safe_writer_test");
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn safe_writer_creates_temp_in_same_directory() {
let dir = test_dir();
let target = dir.join("target.txt");
let writer = SafeWriter::new(&target).unwrap();
let temp_path = writer.temp_path.as_ref().unwrap();
assert_eq!(temp_path.parent(), target.parent());
assert!(temp_path.exists());
assert!(
temp_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with(".pna.")
);
drop(writer);
let _ = fs::remove_file(&target);
}
#[test]
fn safe_writer_persist_renames_atomically() {
let dir = test_dir();
let target = dir.join("persist_test.txt");
let _ = fs::remove_file(&target);
let mut writer = SafeWriter::new(&target).unwrap();
let temp_path = writer.temp_path.as_ref().unwrap().clone();
write!(writer.as_file_mut(), "test content").unwrap();
writer.persist().unwrap();
assert!(target.exists());
assert_eq!(fs::read_to_string(&target).unwrap(), "test content");
assert!(!temp_path.exists());
let _ = fs::remove_file(&target);
}
#[test]
fn safe_writer_cleanup_on_drop() {
let dir = test_dir();
let target = dir.join("drop_test.txt");
let temp_path;
{
let writer = SafeWriter::new(&target).unwrap();
temp_path = writer.temp_path.as_ref().unwrap().clone();
assert!(temp_path.exists());
}
assert!(!temp_path.exists());
assert!(!target.exists());
}
#[test]
fn safe_writer_replaces_existing_file() {
let dir = test_dir();
let target = dir.join("replace_test.txt");
fs::write(&target, "old content").unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "old content");
let mut writer = SafeWriter::new(&target).unwrap();
write!(writer.as_file_mut(), "new content").unwrap();
writer.persist().unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "new content");
let _ = fs::remove_file(&target);
}
#[cfg(windows)]
#[test]
fn safe_writer_cleans_up_temp_when_rename_fails() {
use std::os::windows::fs::OpenOptionsExt;
use windows::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE};
let dir = test_dir();
let target = dir.join("rename_failure_test.txt");
fs::write(&target, "old content").unwrap();
let mut writer = SafeWriter::new(&target).unwrap();
let temp_path = writer.temp_path.as_ref().unwrap().clone();
write!(writer.as_file_mut(), "new content").unwrap();
let locked_target = fs::OpenOptions::new()
.read(true)
.share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE).0)
.open(&target)
.unwrap();
assert!(writer.persist().is_err());
assert!(!temp_path.exists());
drop(locked_target);
assert_eq!(fs::read_to_string(&target).unwrap(), "old content");
let _ = fs::remove_file(&target);
}
#[test]
fn safe_writer_replaces_empty_directory() {
let dir = test_dir();
let target = dir.join("replace_dir_test");
let _ = fs::remove_dir_all(&target);
fs::create_dir(&target).unwrap();
assert!(target.is_dir());
let mut writer = SafeWriter::new(&target).unwrap();
write!(writer.as_file_mut(), "file content").unwrap();
writer.persist().unwrap();
assert!(target.is_file());
assert_eq!(fs::read_to_string(&target).unwrap(), "file content");
let _ = fs::remove_file(&target);
}
#[test]
fn safe_writer_fails_on_non_empty_directory() {
let dir = test_dir();
let target = dir.join("non_empty_dir_test");
let _ = fs::remove_dir_all(&target);
fs::create_dir(&target).unwrap();
fs::write(target.join("inside.txt"), "content").unwrap();
assert!(target.is_dir());
let mut writer = SafeWriter::new(&target).unwrap();
write!(writer.as_file_mut(), "file content").unwrap();
let result = writer.persist();
assert!(result.is_err());
assert!(target.is_dir());
let _ = fs::remove_dir_all(&target);
}
}