use std::fs;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
const MAX_LINKS: usize = 40;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteStep {
Wrote(PathBuf),
SyncedFile,
Renamed,
SyncedDirectory,
}
pub fn atomic_write(path: &Path, contents: &[u8]) -> io::Result<()> {
atomic_write_reporting(path, contents, |_| {})
}
pub fn atomic_write_reporting(path: &Path, contents: &[u8], mut on_step: impl FnMut(WriteStep)) -> io::Result<()> {
let target = link_target(path)?;
let path = target.as_path();
let directory = path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new("."));
let temporary = directory.join(temporary_name(path));
let written = (|| {
let mut file = fs::File::create(&temporary)?;
#[cfg(unix)]
if let Some(existing) = fs::metadata(path).ok().filter(fs::Metadata::is_file) {
file.set_permissions(existing.permissions())?;
}
file.write_all(contents)?;
on_step(WriteStep::Wrote(temporary.clone()));
file.sync_all()?;
on_step(WriteStep::SyncedFile);
fs::rename(&temporary, path)?;
on_step(WriteStep::Renamed);
if sync_directory(directory)? {
on_step(WriteStep::SyncedDirectory);
}
Ok(())
})();
if written.is_err() {
let _ = fs::remove_file(&temporary);
}
written
}
fn link_target(path: &Path) -> io::Result<PathBuf> {
let mut target = path.to_path_buf();
for _ in 0..MAX_LINKS {
let is_link = fs::symlink_metadata(&target).is_ok_and(|meta| meta.file_type().is_symlink());
if !is_link {
return Ok(target);
}
let next = fs::read_link(&target)?;
target = match target.parent() {
Some(parent) if next.is_relative() => parent.join(next),
_ => next,
};
}
Err(io::Error::other(format!("{}: too many levels of symbolic links", path.display())))
}
fn temporary_name(path: &Path) -> String {
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or("file");
let ticket = NEXT.fetch_add(1, Ordering::Relaxed);
format!("{name}.tmp-{}-{ticket}", std::process::id())
}
#[cfg(unix)]
fn sync_directory(directory: &Path) -> io::Result<bool> {
fs::File::open(directory)?.sync_all()?;
Ok(true)
}
#[cfg(not(unix))]
fn sync_directory(_directory: &Path) -> io::Result<bool> {
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("quvyta-atomic-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("test directory");
dir
}
#[test]
fn the_steps_happen_in_the_order_that_makes_the_write_safe() {
let dir = temp_dir("steps");
let path = dir.join("tree.toml");
let mut steps = Vec::new();
atomic_write(&path, b"first\n").expect("first write");
atomic_write_reporting(&path, b"second\n", |step| steps.push(step)).expect("second write");
let temporary = match steps.first() {
Some(WriteStep::Wrote(temporary)) => temporary.clone(),
other => panic!("the first step writes a temporary file, not {other:?}"),
};
assert_eq!(temporary.parent(), path.parent(), "the temporary file shares the directory");
assert_ne!(temporary, path);
let rest: Vec<WriteStep> = steps[1..].to_vec();
if cfg!(unix) {
assert_eq!(
rest,
[WriteStep::SyncedFile, WriteStep::Renamed, WriteStep::SyncedDirectory],
"the directory is flushed, and only after the rename"
);
} else {
assert_eq!(rest, [WriteStep::SyncedFile, WriteStep::Renamed]);
}
assert_eq!(fs::read_to_string(&path).expect("read"), "second\n");
let leftovers = fs::read_dir(&dir).expect("list").count();
assert_eq!(leftovers, 1, "nothing but the file itself stays behind");
fs::remove_dir_all(&dir).expect("clean");
}
#[test]
fn a_failed_write_keeps_the_old_file_and_cleans_up_after_itself() {
let dir = temp_dir("failure");
let path = dir.join("occupied");
fs::create_dir(&path).expect("directory in the way");
fs::write(path.join("inside.toml"), "kept\n").expect("content under it");
let mut steps = Vec::new();
let error = atomic_write_reporting(&path, b"new\n", |step| steps.push(step)).expect_err("rename fails");
assert!(matches!(steps.first(), Some(WriteStep::Wrote(_))), "{steps:?}");
assert!(!steps.contains(&WriteStep::Renamed), "{steps:?} after {error}");
assert_eq!(fs::read_to_string(path.join("inside.toml")).expect("read"), "kept\n", "the old state is intact");
let names: Vec<String> = fs::read_dir(&dir)
.expect("list")
.map(|entry| entry.expect("entry").file_name().display().to_string())
.collect();
assert_eq!(names, ["occupied"], "the temporary file was removed");
fs::remove_dir_all(&dir).expect("clean");
}
#[cfg(unix)]
#[test]
fn the_file_keeps_the_permissions_it_had() {
use std::os::unix::fs::PermissionsExt as _;
let dir = temp_dir("mode");
let path = dir.join("secrets.toml");
fs::write(&path, "token = \"old\"\n").expect("first version");
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("private");
atomic_write(&path, b"token = \"new\"\n").expect("replace");
let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "a private file stays private after it is replaced");
fs::remove_dir_all(&dir).expect("clean");
}
#[cfg(unix)]
#[test]
fn a_symbolic_link_is_written_through_and_stays_a_link() {
use std::os::unix::fs::{PermissionsExt as _, symlink};
let dir = temp_dir("link");
let dotfiles = dir.join("dotfiles");
let config = dir.join("config");
fs::create_dir_all(&dotfiles).expect("dotfiles");
fs::create_dir_all(&config).expect("config");
let target = dotfiles.join("settings.toml");
fs::write(&target, "theme = \"old\"\n").expect("the linked file");
fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("private");
let link = config.join("settings.toml");
symlink("../dotfiles/settings.toml", &link).expect("link");
let second = config.join("again.toml");
symlink(&link, &second).expect("link to the link");
let mut steps = Vec::new();
atomic_write_reporting(&second, b"theme = \"new\"\n", |step| steps.push(step)).expect("write");
assert!(fs::symlink_metadata(&link).expect("link").file_type().is_symlink(), "the link is still a link");
assert!(fs::symlink_metadata(&second).expect("link").file_type().is_symlink());
assert_eq!(fs::read_to_string(&target).expect("read"), "theme = \"new\"\n", "the file it points at changed");
let mode = fs::metadata(&target).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the linked file keeps its permissions");
let Some(WriteStep::Wrote(temporary)) = steps.first() else { panic!("{steps:?}") };
let folder = fs::canonicalize(temporary.parent().expect("a folder")).expect("folder");
assert_eq!(folder, fs::canonicalize(&dotfiles).expect("dotfiles"), "renamed within the linked file's folder");
assert_eq!(fs::read_dir(&dotfiles).expect("list").count(), 1, "nothing left next to the file");
assert_eq!(fs::read_dir(&config).expect("list").count(), 2, "only the two links");
fs::remove_dir_all(&dir).expect("clean");
}
#[cfg(unix)]
#[test]
fn a_link_to_a_missing_file_creates_it_and_a_loop_writes_nothing() {
use std::os::unix::fs::symlink;
let dir = temp_dir("dangling");
let link = dir.join("settings.toml");
symlink("real.toml", &link).expect("link");
atomic_write(&link, b"x = 1\n").expect("write");
assert!(fs::symlink_metadata(&link).expect("link").file_type().is_symlink());
assert_eq!(fs::read_to_string(dir.join("real.toml")).expect("created"), "x = 1\n");
let a = dir.join("a.toml");
let b = dir.join("b.toml");
symlink("b.toml", &a).expect("a");
symlink("a.toml", &b).expect("b");
let error = atomic_write(&a, b"y = 2\n").expect_err("a loop has no file at its end");
assert!(error.to_string().contains("symbolic links"), "{error}");
assert_eq!(fs::read_dir(&dir).expect("list").count(), 4, "nothing was written");
fs::remove_dir_all(&dir).expect("clean");
}
#[test]
fn a_missing_directory_is_an_error_and_writes_nothing() {
let dir = temp_dir("missing");
let path = dir.join("absent").join("tree.toml");
let error = atomic_write(&path, b"x\n").expect_err("no directory to write in");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
assert!(!path.exists());
fs::remove_dir_all(&dir).expect("clean");
}
#[test]
fn two_writes_at_once_use_two_temporary_files() {
let dir = temp_dir("parallel");
let path = dir.join("tree.toml");
let mut first = None;
atomic_write_reporting(&path, b"a\n", |step| {
if let WriteStep::Wrote(temporary) = step {
first = Some(temporary);
}
})
.expect("first");
let mut second = None;
atomic_write_reporting(&path, b"b\n", |step| {
if let WriteStep::Wrote(temporary) = step {
second = Some(temporary);
}
})
.expect("second");
assert_ne!(first.expect("first name"), second.expect("second name"));
fs::remove_dir_all(&dir).expect("clean");
}
}