use std::io::{self, Write as _};
use std::path::Path;
use tempfile::NamedTempFile;
pub fn atomic_write_bytes(target_path: &Path, bytes: &[u8]) -> io::Result<()> {
if let Ok(meta) = std::fs::symlink_metadata(target_path)
&& meta.file_type().is_symlink()
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"atomic_write_bytes: target path is a symlink and will not be followed: {}",
target_path.display()
),
));
}
let parent = target_path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"atomic_write_bytes: target path has no parent directory: {}",
target_path.display()
),
)
})?;
let raw_parent_meta = std::fs::symlink_metadata(parent).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"atomic_write_bytes: cannot stat parent directory '{}': {e}",
parent.display()
),
)
})?;
if raw_parent_meta.file_type().is_symlink() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"atomic_write_bytes: parent directory is a symlink and will not be followed: {}",
parent.display()
),
));
}
let canonical_parent = parent.canonicalize().map_err(|e| {
io::Error::new(
e.kind(),
format!(
"atomic_write_bytes: cannot canonicalize parent directory '{}': {e}",
parent.display()
),
)
})?;
let canon_meta = std::fs::symlink_metadata(&canonical_parent).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"atomic_write_bytes: cannot stat canonical parent '{}': {e}",
canonical_parent.display()
),
)
})?;
if !canon_meta.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotADirectory,
format!(
"atomic_write_bytes: canonical parent path is not a directory: {}",
canonical_parent.display()
),
));
}
let mut tmp = NamedTempFile::new_in(parent).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"atomic_write_bytes: failed to create tempfile in '{}': {e}",
parent.display()
),
)
})?;
if let Err(write_err) = tmp.write_all(bytes) {
let _ = tmp.close();
return Err(io::Error::new(
write_err.kind(),
format!("atomic_write_bytes: write failed: {write_err}"),
));
}
if let Err(sync_err) = tmp.as_file().sync_all() {
let _ = tmp.close();
return Err(io::Error::new(
sync_err.kind(),
format!("atomic_write_bytes: fsync(file) failed: {sync_err}"),
));
}
tmp.persist(target_path).map_err(|persist_err| {
let _ = persist_err.file.close();
io::Error::new(
persist_err.error.kind(),
format!(
"atomic_write_bytes: rename to '{}' failed: {}",
target_path.display(),
persist_err.error
),
)
})?;
fsync_parent_dir(&canonical_parent)?;
Ok(())
}
#[cfg(unix)]
fn fsync_parent_dir(canonical_parent: &Path) -> io::Result<()> {
use std::fs::OpenOptions;
let dir_file = OpenOptions::new()
.read(true)
.open(canonical_parent)
.map_err(|e| {
io::Error::new(
e.kind(),
format!(
"atomic_write_bytes: cannot open parent dir for fsync '{}': {e}",
canonical_parent.display()
),
)
})?;
dir_file.sync_all().map_err(|e| {
io::Error::new(
e.kind(),
format!(
"atomic_write_bytes: fsync(parent_dir) failed for '{}': {e}",
canonical_parent.display()
),
)
})
}
#[cfg(not(unix))]
#[allow(clippy::unnecessary_wraps)]
fn fsync_parent_dir(_canonical_parent: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn tmp_dir() -> TempDir {
TempDir::new().expect("TempDir::new failed")
}
#[test]
fn atomic_write_happy_path() {
let dir = tmp_dir();
let target = dir.path().join("output.bin");
let content = b"hello atomic world";
assert!(!target.exists(), "pre-condition: target must not exist");
atomic_write_bytes(&target, content).expect("atomic_write_bytes failed");
let read_back = fs::read(&target).expect("read back failed");
assert_eq!(read_back, content, "content mismatch after atomic write");
let entries: Vec<_> = fs::read_dir(dir.path())
.expect("read_dir failed")
.filter_map(|e| e.ok())
.collect();
assert_eq!(
entries.len(),
1,
"unexpected files left in parent dir: {entries:?}"
);
assert_eq!(
entries[0].path(),
target,
"the only file in parent should be the target"
);
}
#[test]
fn atomic_write_overwrites_existing_regular_file() {
let dir = tmp_dir();
let target = dir.path().join("existing.txt");
let old_content = b"old content";
let new_content = b"new content -- replaced atomically";
fs::write(&target, old_content).expect("pre-write failed");
assert!(target.is_file(), "pre-condition: target is a regular file");
atomic_write_bytes(&target, new_content).expect("atomic_write_bytes failed on overwrite");
let read_back = fs::read(&target).expect("read back failed");
assert_eq!(read_back, new_content, "content should have been replaced");
}
#[cfg(unix)]
#[test]
fn atomic_write_rejects_symlink_target() {
let dir = tmp_dir();
let real_file = dir.path().join("real.txt");
let symlink_target = dir.path().join("link.txt");
fs::write(&real_file, b"original").expect("pre-write failed");
std::os::unix::fs::symlink(&real_file, &symlink_target).expect("symlink creation failed");
assert!(
symlink_target
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false),
"pre-condition: symlink_target must be a symlink"
);
let result = atomic_write_bytes(&symlink_target, b"new bytes");
assert!(result.is_err(), "expected Err for symlink target, got Ok");
let real_content = fs::read(&real_file).expect("read real_file failed");
assert_eq!(real_content, b"original", "real file must not be modified");
let lmeta = symlink_target
.symlink_metadata()
.expect("symlink should still exist");
assert!(
lmeta.file_type().is_symlink(),
"symlink must remain a symlink"
);
}
#[cfg(unix)]
#[test]
fn atomic_write_rejects_symlink_parent() {
let dir = tmp_dir();
let real_subdir = dir.path().join("real_subdir");
let link_subdir = dir.path().join("link_subdir");
fs::create_dir(&real_subdir).expect("create real_subdir failed");
std::os::unix::fs::symlink(&real_subdir, &link_subdir)
.expect("symlink to directory failed");
let target = link_subdir.join("output.txt");
let result = atomic_write_bytes(&target, b"should not be written");
assert!(result.is_err(), "expected Err for symlink parent, got Ok");
assert!(
!real_subdir.join("output.txt").exists(),
"file must not be created in real_subdir"
);
}
#[cfg(unix)]
#[test]
fn atomic_write_temp_cleanup_on_failure() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tmp_dir();
let readonly_dir = dir.path().join("readonly");
fs::create_dir(&readonly_dir).expect("create readonly_dir failed");
let mut perms = fs::metadata(&readonly_dir)
.expect("stat readonly_dir")
.permissions();
perms.set_mode(0o500); fs::set_permissions(&readonly_dir, perms).expect("chmod failed");
let target = readonly_dir.join("output.txt");
let result = atomic_write_bytes(&target, b"data");
assert!(
result.is_err(),
"expected Err when rename into read-only dir"
);
let mut perms = fs::metadata(&readonly_dir)
.expect("stat readonly_dir")
.permissions();
perms.set_mode(0o700);
fs::set_permissions(&readonly_dir, perms).ok();
let remaining: Vec<_> = fs::read_dir(&readonly_dir)
.expect("read_dir readonly_dir")
.filter_map(|e| e.ok())
.collect();
assert!(
remaining.is_empty(),
"no tempfile should remain after failure: {remaining:?}"
);
}
}