use std::fs::{self, File};
use std::io::{self, Write};
use std::path::Path;
use tempfile::NamedTempFile;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
pub fn atomic_write(path: &Path, content: &[u8], mode: Option<u32>) -> io::Result<()> {
let parent = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "Path has no parent directory")
})?;
fs::create_dir_all(parent)?;
let mut temp_file = NamedTempFile::new_in(parent)?;
temp_file.write_all(content)?;
temp_file.as_file().sync_all()?;
#[cfg(unix)]
{
let m = mode.unwrap_or(0o644);
let perms = std::fs::Permissions::from_mode(m);
temp_file.as_file().set_permissions(perms)?;
}
#[cfg(not(unix))]
let _ = mode;
temp_file.persist(path).map_err(|e| e.error)?;
#[cfg(unix)]
{
if let Ok(dir) = File::open(parent) {
let _ = dir.sync_all();
}
}
Ok(())
}
pub fn atomic_write_secure(path: &Path, content: &[u8]) -> io::Result<()> {
atomic_write(path, content, Some(0o600))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_atomic_write_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
atomic_write(&path, b"hello world", None).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "hello world");
}
#[test]
fn test_atomic_write_overwrites_existing() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
atomic_write(&path, b"first", None).unwrap();
atomic_write(&path, b"second", None).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "second");
}
#[test]
fn test_atomic_write_creates_parent_dirs() {
let dir = tempdir().unwrap();
let path = dir.path().join("nested/dir/test.txt");
atomic_write(&path, b"nested content", None).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "nested content");
}
#[cfg(unix)]
#[test]
fn test_atomic_write_secure_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let path = dir.path().join("secret.txt");
atomic_write_secure(&path, b"secret").unwrap();
let perms = fs::metadata(&path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
}
#[test]
fn test_no_temp_file_left_on_success() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
atomic_write(&path, b"content", None).unwrap();
let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].as_ref().unwrap().file_name(), "test.txt");
}
#[test]
fn test_no_temp_file_left_on_write_error() {
let dir = tempdir().unwrap();
let temp = NamedTempFile::new_in(dir.path()).unwrap();
let temp_path = temp.path().to_path_buf();
assert!(temp_path.exists());
drop(temp);
assert!(!temp_path.exists());
}
#[test]
fn test_concurrent_writes_no_collision() {
use std::sync::Arc;
use std::thread;
let dir = tempdir().unwrap();
let dir_path = Arc::new(dir.path().to_path_buf());
let handles: Vec<_> = (0..10)
.map(|i| {
let dir = Arc::clone(&dir_path);
thread::spawn(move || {
let path = dir.join(format!("file_{}.txt", i));
atomic_write(&path, format!("content_{}", i).as_bytes(), None).unwrap();
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
for i in 0..10 {
let path = dir.path().join(format!("file_{}.txt", i));
assert_eq!(fs::read_to_string(&path).unwrap(), format!("content_{}", i));
}
let entries: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with('.'))
.collect();
assert!(entries.is_empty(), "Temp files left behind: {:?}", entries);
}
}