use std::fs;
use std::io;
use std::path::Path;
use super::{RenameFn, create_dir, os_rename, remove_entry, staging_path};
use crate::error::{Error, Result};
pub fn write_atomic(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Result<()> {
write_atomic_with(path.as_ref(), contents.as_ref(), &os_rename)
}
pub(super) fn write_atomic_with(path: &Path, contents: &[u8], rename: RenameFn<'_>) -> Result<()> {
let refuse = |reason| {
Err(Error::Refused {
path: path.to_path_buf(),
reason,
})
};
if path.file_name().is_none() {
return refuse("the destination has no file name");
}
let existing = match fs::symlink_metadata(path) {
Ok(meta) => Some(meta),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(Error::io("inspect", path, e)),
};
let target = match &existing {
Some(meta) if meta.file_type().is_symlink() => match fs::canonicalize(path) {
Ok(real) => return write_atomic_with(&real, contents, rename),
Err(_) => return refuse("it is a broken symbolic link"),
},
Some(meta) if meta.is_dir() => return refuse("it is a directory"),
_ => path,
};
if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) {
create_dir(parent, None)?;
}
let staging = staging_path(target);
let written = write_staged(&staging, contents, existing.as_ref())
.and_then(|()| rename(&staging, target).map_err(|e| Error::io("replace", target, e)));
if let Err(err) = written {
if let Ok(m) = fs::symlink_metadata(&staging) {
let _cleanup = remove_entry(&staging, &m);
}
return Err(err);
}
sync_parent(target)
}
pub(super) fn write_staged(
staging: &Path,
contents: &[u8],
existing: Option<&fs::Metadata>,
) -> Result<()> {
use std::io::Write as _;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(staging)
.map_err(|e| Error::io("create file", staging, e))?;
file.write_all(contents)
.map_err(|e| Error::io("write", staging, e))?;
copy_permissions(staging, existing)?;
file.sync_all().map_err(|e| Error::io("flush", staging, e))
}
#[cfg(unix)]
pub(super) fn copy_permissions(staging: &Path, existing: Option<&fs::Metadata>) -> Result<()> {
match existing {
Some(meta) => fs::set_permissions(staging, meta.permissions())
.map_err(|e| Error::io("set permissions", staging, e)),
None => Ok(()),
}
}
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)] pub(super) fn copy_permissions(_staging: &Path, _existing: Option<&fs::Metadata>) -> Result<()> {
Ok(())
}
#[cfg(unix)]
pub(super) fn sync_parent(path: &Path) -> Result<()> {
let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
return Ok(());
};
fs::File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(|e| Error::io("flush directory", parent, e))
}
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)] pub(super) fn sync_parent(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ops::test_support::*;
#[test]
fn test_write_atomic_failed_rename_keeps_original() {
let dir = scratch("wa-fail");
let file = dir.join("conf");
fs::write(&file, b"old").unwrap_or_default();
let r = write_atomic_with(&file, b"new", &denied);
assert!(matches!(r, Err(Error::Io { op: "replace", .. })), "{r:?}");
assert_eq!(fs::read(&file).ok(), Some(b"old".to_vec()));
let count = fs::read_dir(&dir).map_or(0, Iterator::count);
assert_eq!(count, 1, "staging removed");
let _ = fs::remove_dir_all(&dir);
}
}