use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct AtomicTemporaryFileError {
pub path: PathBuf,
pub reason: String,
source: io::Error,
}
impl fmt::Display for AtomicTemporaryFileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "temporary file \"{}\" {}", self.path.display(), self.reason)
}
}
impl std::error::Error for AtomicTemporaryFileError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
impl AtomicTemporaryFileError {
pub fn downcast(error: &io::Error) -> Option<&AtomicTemporaryFileError> {
error.get_ref().and_then(|inner| inner.downcast_ref())
}
fn into_io_error(self) -> io::Error {
io::Error::new(io::ErrorKind::AlreadyExists, self)
}
}
#[cfg(unix)]
fn apply_unix_extras(options: &mut OpenOptions, mode: Option<u32>) {
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
if let Some(mode) = mode {
options.mode(mode);
}
}
#[cfg(not(unix))]
fn apply_unix_extras(_options: &mut OpenOptions, _mode: Option<u32>) {}
fn open_exclusive(path: &Path, mode: Option<u32>) -> io::Result<File> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
apply_unix_extras(&mut options, mode);
options.open(path)
}
#[cfg(target_os = "wasi")]
fn is_planted_symlink_refusal(error: &io::Error) -> bool {
error.raw_os_error() == Some(libc::ENOTCAPABLE)
}
#[cfg(not(target_os = "wasi"))]
fn is_planted_symlink_refusal(_error: &io::Error) -> bool {
false
}
pub fn open_temporary(path: &Path, mode: Option<u32>) -> io::Result<File> {
match open_exclusive(path, mode) {
Ok(file) => Ok(file),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists || is_planted_symlink_refusal(&error) => {
if let Err(cause) = fs::remove_file(path) {
return Err(AtomicTemporaryFileError {
path: path.to_path_buf(),
reason: "is in the way and could not be cleared, so nothing on disk was changed — remove whatever holds that path and retry".to_string(),
source: cause,
}
.into_io_error());
}
open_exclusive(path, mode).map_err(|cause| {
let reason = if cause.kind() == io::ErrorKind::AlreadyExists || is_planted_symlink_refusal(&cause) {
"was planted again in the instant after the residue in its place was cleared, so it was refused rather than written through, and nothing on disk was changed — another process is racing for that path"
} else {
"could not be created after the residue in its place was cleared, so nothing on disk was changed"
};
AtomicTemporaryFileError {
path: path.to_path_buf(),
reason: reason.to_string(),
source: cause,
}
.into_io_error()
})
}
Err(cause) => Err(cause),
}
}
fn temp_path_for(target: &Path) -> PathBuf {
let mut name = target.as_os_str().to_owned();
name.push(".tmp");
PathBuf::from(name)
}
pub fn write_atomic(target: &Path, contents: &[u8], mode: Option<u32>) -> io::Result<()> {
let temp_path = temp_path_for(target);
let mut file = open_temporary(&temp_path, mode)?;
file.write_all(contents)?;
file.sync_all()?;
drop(file);
fs::rename(&temp_path, target)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use std::os::unix::fs::{symlink, PermissionsExt};
fn temp_dir(prefix: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"{prefix}{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn creates_and_writes_a_fresh_temporary_file() {
let dir = temp_dir("hayson-atomic-fresh-");
let path = dir.join("payload.tmp");
let mut file = open_temporary(&path, None).unwrap();
file.write_all(b"fresh").unwrap();
drop(file);
let mut contents = String::new();
File::open(&path).unwrap().read_to_string(&mut contents).unwrap();
assert_eq!(contents, "fresh");
}
#[test]
fn replaces_plain_residue_left_by_a_killed_process() {
let dir = temp_dir("hayson-atomic-residue-");
let path = dir.join("payload.tmp");
fs::write(&path, "stale-residue-from-a-killed-process").unwrap();
let mut file = open_temporary(&path, None).unwrap();
file.write_all(b"fresh").unwrap();
drop(file);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents, "fresh");
}
#[test]
fn creates_with_the_given_mode_without_a_later_chmod() {
let dir = temp_dir("hayson-atomic-mode-");
let path = dir.join("payload.tmp");
open_temporary(&path, Some(0o600)).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn refuses_a_planted_symlink_and_leaves_the_victim_untouched() {
let dir = temp_dir("hayson-atomic-symlink-");
let victim_dir = temp_dir("hayson-atomic-victim-");
let victim = victim_dir.join("precious.txt");
fs::write(&victim, "PRECIOUS\n").unwrap();
let victim_mtime = fs::metadata(&victim).unwrap().modified().unwrap();
let path = dir.join("payload.tmp");
symlink(&victim, &path).unwrap();
let mut file = open_temporary(&path, None).unwrap();
file.write_all(b"written-through-nothing").unwrap();
drop(file);
assert_eq!(fs::read_to_string(&victim).unwrap(), "PRECIOUS\n");
assert_eq!(fs::metadata(&victim).unwrap().modified().unwrap(), victim_mtime);
assert!(!fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(&path).unwrap(), "written-through-nothing");
}
#[test]
fn a_residue_that_cannot_be_cleared_surfaces_as_the_domain_error() {
let dir = temp_dir("hayson-atomic-unclearable-");
let path = dir.join("payload.tmp");
fs::create_dir(&path).unwrap();
fs::write(path.join("occupant"), "x").unwrap();
let error = open_temporary(&path, None).unwrap_err();
let domain = AtomicTemporaryFileError::downcast(&error).expect("domain error");
assert_eq!(domain.path, path);
assert!(domain.reason.contains("could not be cleared"));
}
#[test]
fn write_atomic_creates_the_target_and_leaves_no_temporary_behind() {
let dir = temp_dir("hayson-atomic-write-");
let target = dir.join("manifest.json");
write_atomic(&target, b"{\"hayson\":1}", None).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), "{\"hayson\":1}");
assert!(!temp_path_for(&target).exists());
}
#[test]
fn write_atomic_is_not_written_through_a_planted_symlink_on_the_temporary_path() {
let dir = temp_dir("hayson-atomic-write-symlink-");
let victim_dir = temp_dir("hayson-atomic-write-victim-");
let victim = victim_dir.join("precious.txt");
fs::write(&victim, "PRECIOUS\n").unwrap();
let victim_mtime = fs::metadata(&victim).unwrap().modified().unwrap();
let target = dir.join("manifest.json");
symlink(&victim, temp_path_for(&target)).unwrap();
write_atomic(&target, b"{\"hayson\":1}", None).unwrap();
assert_eq!(fs::read_to_string(&victim).unwrap(), "PRECIOUS\n");
assert_eq!(fs::metadata(&victim).unwrap().modified().unwrap(), victim_mtime);
assert_eq!(fs::read_to_string(&target).unwrap(), "{\"hayson\":1}");
}
}