Files
craft-libs/crates/atomic/src/lib.rs
T
aiongg 9d422a18b1 Retry Windows sharing and lock violations, with a capped backoff
retry_rename also retries ERROR_SHARING_VIOLATION (32) and
ERROR_LOCK_VIOLATION (33), which a rename can report while a scanner
holds the file, and caps each pause at 200 ms.
2026-10-12 20:23:17 +02:00

665 lines
27 KiB
Rust

#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Crash-safe file replacement: the unified atomic save engine for the Craft suite.
//!
//! [`atomic_write`] never truncates the destination file in place. It writes a temporary file
//! in the **same directory** as the destination, flushes it to non-volatile storage (`sync_all`),
//! renames it over the destination atomically, and on Unix, flushes the directory so the rename
//! survives a power loss. If any step fails, the destination retains its original bytes and
//! the temporary staging file is removed.
//!
//! ### Platform Characteristics
//! - **Windows**: Antivirus scanners, search indexers, and backup utilities briefly hold files
//! open with sharing violations. The rename is retried with exponential backoff (`RenameRetry`).
//! - **Cross-Device (`EXDEV`)**: Staging files are always created beside the destination in the
//! same folder, guaranteeing same-filesystem replace semantics.
//! - **Apple Network Shares (macOS)**: `sync_all` issues `F_FULLFSYNC`. Where SMB shares or
//! virtual filesystems reject this with `ENOTSUP` or `ENOTTY`, [`sync_file`] falls back to standard
//! `fsync`.
//! - **Web / WASM**: On `wasm32`, returns standard unsupported `io::Error` rather than panicking.
//! - **Failure Injection**: [`atomic_write_custom`] accepts an [`AtomicFs`] implementation so
//! callers and test suites can inject disk-full, lock-contention, or permission errors at each stage.
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// File-system operations performed by atomic writes.
/// [`RealFs`] provides the production implementation; test suites implement this to inject faults.
pub trait AtomicFs {
/// Create `path`, which must not exist, and open it for writing.
fn create_new(&self, path: &Path) -> io::Result<File>;
/// Flush a written file to durable storage.
fn sync(&self, file: &File) -> io::Result<()>;
/// Rename `from` over `to`, replacing it atomically.
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
/// Give the file `from` the second name `to`; fails with `AlreadyExists` when `to` exists.
fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()>;
/// Remove a file.
fn remove(&self, path: &Path) -> io::Result<()>;
/// Flush a directory entry change to durable storage (Unix); no-op elsewhere.
fn sync_dir(&self, dir: &Path) -> io::Result<()>;
/// Give the open temporary file the permissions of the file it replaces.
fn set_permissions(&self, file: &File, perms: std::fs::Permissions) -> io::Result<()>;
}
/// The production file system implementation.
#[derive(Debug, Clone, Copy, Default)]
pub struct RealFs;
impl AtomicFs for RealFs {
fn create_new(&self, path: &Path) -> io::Result<File> {
OpenOptions::new().write(true).create_new(true).open(path)
}
fn sync(&self, file: &File) -> io::Result<()> {
sync_file(file)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
std::fs::rename(from, to)
}
fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()> {
std::fs::hard_link(from, to)
}
fn remove(&self, path: &Path) -> io::Result<()> {
std::fs::remove_file(path)
}
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
#[cfg(unix)]
{
sync_file(&File::open(dir)?)
}
#[cfg(not(unix))]
{
let _ = dir;
Ok(())
}
}
fn set_permissions(&self, file: &File, perms: std::fs::Permissions) -> io::Result<()> {
file.set_permissions(perms)
}
}
/// Flush `file` to stable storage (`sync_all`).
/// On Apple platforms, this issues an `F_FULLFSYNC`. Where the file system rejects full sync
/// (such as SMB network shares or virtual file systems), it falls back to a plain `fsync`.
pub fn sync_file(file: &File) -> io::Result<()> {
#[cfg(target_vendor = "apple")]
{
or_plain_fsync(file.sync_all(), || rustix::fs::fsync(file).map_err(io::Error::from))
}
#[cfg(not(target_vendor = "apple"))]
{
file.sync_all()
}
}
/// Returns `full`, or calls `fsync()` if `full` failed only because `F_FULLFSYNC` is unsupported.
pub fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
match full {
Err(e) if full_flush_unsupported(&e) => fsync(),
other => other,
}
}
fn full_flush_unsupported(e: &io::Error) -> bool {
const ENOTTY: i32 = 25;
const ENOTSUP: i32 = 45;
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | ENOTTY))
}
/// Retry configuration for renames that encounter transient file locks (see [`retry_rename`]).
#[derive(Debug, Clone, Copy)]
pub struct RenameRetry {
/// Number of retries after the initial attempt (0 = no retry).
pub attempts: u32,
/// Delay before the first retry in milliseconds; doubles on each subsequent attempt, up to
/// 200 ms per attempt.
pub first_delay_ms: u64,
}
impl RenameRetry {
/// Platform default: retry on Windows (7 attempts with 10ms exponential backoff), no-op elsewhere.
pub fn platform() -> Self {
if cfg!(windows) { Self { attempts: 7, first_delay_ms: 10 } } else { Self { attempts: 0, first_delay_ms: 0 } }
}
}
/// Atomically writes `bytes` to `path`, replacing any existing content.
///
/// If write or rename fails, the target file is untouched and any temporary staging file is removed.
pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
atomic_write_custom(&RealFs, RenameRetry::platform(), path, bytes)
}
/// Atomically writes to `path` using a streaming writer callback `F`.
pub fn atomic_write_with<F>(path: &Path, write_fn: F) -> io::Result<()>
where
F: FnOnce(&mut File) -> io::Result<()>,
{
replace(&RealFs, RenameRetry::platform(), path, write_fn)
}
/// Atomically writes `bytes` to `path` through custom [`AtomicFs`] and [`RenameRetry`].
pub fn atomic_write_custom(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> {
replace(fs, retry, path, |f| f.write_all(bytes))
}
/// The replace path behind every `atomic_write*` function: stage the new content beside the
/// target, then rename it over the target.
fn replace(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let target = resolve_links(path)?;
let (dir, name) = split(&target, path)?;
let existing = std::fs::metadata(&target).ok();
let existed = existing.is_some();
if existing.as_ref().is_some_and(|m| m.is_file()) {
check_writable(&target).map_err(|e| read_only_error(e, path))?;
}
let tmp = stage(fs, &dir, &name, path, existing.as_ref(), fill)?;
if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) {
let _ = fs.remove(&tmp);
return Err(rename_error(e, path, existed));
}
let _ = fs.sync_dir(&dir);
Ok(())
}
/// A rename replaces a file the user can't write (Unix only needs the folder to be writable), so
/// refuse, as writing it in place would. Opening for writing without truncating changes nothing.
#[cfg(unix)]
fn check_writable(target: &Path) -> io::Result<()> {
OpenOptions::new().write(true).open(target).map(drop)
}
/// Windows refuses to rename over a read-only file by itself.
#[cfg(not(unix))]
fn check_writable(_: &Path) -> io::Result<()> {
Ok(())
}
/// The folder `target` is in and its file name. `path` is the caller's path, for the error.
fn split(target: &Path, path: &Path) -> io::Result<(PathBuf, String)> {
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
let dir = match target.parent() {
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
_ => PathBuf::from("."),
};
Ok((dir, name.to_string_lossy().into_owned()))
}
/// Create a temporary file in `dir`, fill it with `fill`, give it the permissions of the file it
/// replaces (`replaced`, Unix) and flush it. A leftover file with the same name is skipped. When
/// any step fails, the temporary file is removed.
fn stage(
fs: &dyn AtomicFs,
dir: &Path,
name: &str,
path: &Path,
replaced: Option<&std::fs::Metadata>,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<PathBuf> {
let existed = replaced.is_some();
// Windows files carry only a read-only flag, and a read-only file can't be replaced.
let perms = replaced.filter(|_| cfg!(unix)).map(|m| m.permissions());
for _ in 0..8 {
let tmp = dir.join(temp_name(name));
let mut file = match fs.create_new(&tmp) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(write_error(e, path, dir, existed)),
};
let written = fill(&mut file)
.map_err(|e| write_error(e, path, dir, existed))
.and_then(|()| perms.map_or(Ok(()), |p| fs.set_permissions(&file, p).map_err(|e| permissions_error(e, path))))
.and_then(|()| fs.sync(&file).map_err(|e| write_error(e, path, dir, existed)));
drop(file);
if written.is_err() {
let _ = fs.remove(&tmp);
}
return written.map(|()| tmp);
}
Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))
}
/// Exclusively creates and writes `path` without replacing existing files.
pub fn write_new(path: &Path, bytes: &[u8]) -> io::Result<()> {
write_new_with(path, |f| f.write_all(bytes))
}
/// Exclusively creates and writes `path` with a streaming writer callback `F`.
pub fn write_new_with<F>(path: &Path, write_fn: F) -> io::Result<()>
where
F: FnOnce(&mut File) -> io::Result<()>,
{
create(&RealFs, path, write_fn)
}
/// Exclusively creates and writes `bytes` to `path` through a custom [`AtomicFs`].
pub fn write_new_custom(fs: &dyn AtomicFs, path: &Path, bytes: &[u8]) -> io::Result<()> {
create(fs, path, |f| f.write_all(bytes))
}
/// The create path behind every `write_new*` function: stage the content beside the target,
/// then give it the target's name with a hard link, which fails rather than replace a file that
/// appeared in the meantime. Volumes without hard links get a copy into a newly created file.
fn create(fs: &dyn AtomicFs, path: &Path, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let target = resolve_links(path)?;
let (dir, name) = split(&target, path)?;
if target.exists() {
return Err(write_error(io::Error::from(io::ErrorKind::AlreadyExists), path, &dir, true));
}
let tmp = stage(fs, &dir, &name, path, None, fill)?;
let placed = match fs.hard_link(&tmp, &target) {
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => copy_new(fs, &tmp, &target),
linked => linked,
};
let _ = fs.remove(&tmp);
if let Err(e) = placed {
// A file that appeared in the meantime is someone else's and stays as it is.
let existed = e.kind() == io::ErrorKind::AlreadyExists;
return Err(write_error(e, path, &dir, existed));
}
let _ = fs.sync_dir(&dir);
Ok(())
}
/// Copy `tmp` into `target`, which must not exist yet, and flush it. A partly written `target` is
/// removed.
fn copy_new(fs: &dyn AtomicFs, tmp: &Path, target: &Path) -> io::Result<()> {
let mut to = fs.create_new(target)?;
let copied = File::open(tmp).and_then(|mut from| io::copy(&mut from, &mut to)).and_then(|_| fs.sync(&to));
drop(to);
if copied.is_err() {
let _ = fs.remove(target);
}
copied
}
/// The longest pause between two rename attempts.
const MAX_RETRY_DELAY_MS: u64 = 200;
/// Retries an operation with exponential backoff while it fails because another process holds
/// the file: `PermissionDenied`, or on Windows a sharing or lock violation.
pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
let mut delay = retry.first_delay_ms.min(MAX_RETRY_DELAY_MS);
let mut left = retry.attempts;
loop {
match rename() {
Err(e) if is_transient_lock(&e) && left > 0 => {
left -= 1;
sleep_ms(delay);
delay = delay.saturating_mul(2).min(MAX_RETRY_DELAY_MS);
}
r => return r,
}
}
}
fn is_transient_lock(e: &io::Error) -> bool {
const ERROR_SHARING_VIOLATION: i32 = 32;
const ERROR_LOCK_VIOLATION: i32 = 33;
e.kind() == io::ErrorKind::PermissionDenied || (cfg!(windows) && matches!(e.raw_os_error(), Some(ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)))
}
#[cfg(not(target_arch = "wasm32"))]
fn sleep_ms(ms: u64) {
if ms > 0 {
std::thread::sleep(std::time::Duration::from_millis(ms));
}
}
#[cfg(target_arch = "wasm32")]
fn sleep_ms(_ms: u64) {}
/// Symbolic links followed before giving up (a loop, or an absurd chain).
const MAX_LINKS: usize = 40;
/// The file a chain of symbolic links ends at (`path` itself when it is not a link), so the link
/// is kept and the file it names is written. A dangling link resolves to the missing file it
/// names, which is then created.
fn resolve_links(path: &Path) -> io::Result<PathBuf> {
let mut p = path.to_path_buf();
for _ in 0..MAX_LINKS {
match std::fs::symlink_metadata(&p) {
Ok(m) if m.file_type().is_symlink() => {
let to = std::fs::read_link(&p).map_err(|e| io::Error::new(e.kind(), format!("cannot save {}: {e}", path.display())))?;
p = match p.parent() {
Some(dir) if to.is_relative() => dir.join(to),
_ => to,
};
}
_ => return Ok(p),
}
}
Err(io::Error::new(io::ErrorKind::InvalidInput, format!("cannot save {}: too many levels of symbolic links", path.display())))
}
/// Generates a unique, hidden temporary file name beside `file`.
pub fn temp_name(file: &str) -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let stem: String = file.chars().take(64).collect();
format!(".{stem}.{}-{n}.craft_tmp", std::process::id())
}
/// The end of an error message: whether an original file was left as it was.
fn unchanged(existed: bool) -> &'static str {
if existed { "; the original file was not changed" } else { "" }
}
fn write_error(e: io::Error, path: &Path, dir: &Path, existed: bool) -> io::Error {
let kept = unchanged(existed);
let msg = match e.kind() {
io::ErrorKind::AlreadyExists => format!("cannot save {}: the file already exists{kept}", path.display()),
io::ErrorKind::NotFound => format!("cannot save {}: the folder {} does not exist ({e}){kept}", path.display(), dir.display()),
io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => {
format!("cannot save {}: the folder {} is not writable ({e}){kept}", path.display(), dir.display())
}
io::ErrorKind::StorageFull => format!("cannot save {}: the disk is full ({e}){kept}", path.display()),
_ => format!("cannot save {}: {e}{kept}", path.display()),
};
io::Error::new(e.kind(), msg)
}
fn permissions_error(e: io::Error, path: &Path) -> io::Error {
let msg = format!("cannot save {}: the new file cannot be given the original's permissions ({e}){}", path.display(), unchanged(true));
io::Error::new(e.kind(), msg)
}
fn read_only_error(e: io::Error, path: &Path) -> io::Error {
let kept = unchanged(true);
let msg = match e.kind() {
io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => {
format!("cannot save {}: the file is read-only ({e}){kept}", path.display())
}
_ => format!("cannot save {}: {e}{kept}", path.display()),
};
io::Error::new(e.kind(), msg)
}
fn rename_error(e: io::Error, path: &Path, existed: bool) -> io::Error {
let kept = unchanged(existed);
let msg = if is_cross_device(&e) {
format!("cannot save {}: the temporary file is on a different volume than the destination ({e}){kept}", path.display())
} else if e.kind() == io::ErrorKind::PermissionDenied {
format!("cannot save {}: the file is locked or read-only ({e}){kept}", path.display())
} else {
format!("cannot save {}: {e}{kept}", path.display())
};
io::Error::new(e.kind(), msg)
}
fn is_cross_device(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::CrossesDevices || (cfg!(unix) && e.raw_os_error() == Some(18)) || (cfg!(windows) && e.raw_os_error() == Some(17))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let d = std::env::temp_dir().join(format!("craft-atomic-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).ok();
Self(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
}
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn assert_no_temp(dir: &Path) {
let leftovers: Vec<_> = std::fs::read_dir(dir)
.ok()
.map(|entries| entries.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".craft_tmp")).collect())
.unwrap_or_default();
assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}");
}
const ORIGINAL: &[u8] = b"the user's precious original document";
#[derive(Default)]
struct Faulty {
fail_sync: bool,
/// Syncs fail once this many have succeeded.
syncs_before_failure: Option<u32>,
syncs: Cell<u32>,
fail_rename: Option<io::ErrorKind>,
locked_renames: Cell<u32>,
renames: Cell<u32>,
no_hard_links: bool,
fail_permissions: bool,
/// Another process creates the target just before the hard link.
created_meanwhile: Option<&'static [u8]>,
}
impl AtomicFs for Faulty {
fn create_new(&self, path: &Path) -> io::Result<File> {
RealFs.create_new(path)
}
fn sync(&self, file: &File) -> io::Result<()> {
let done = self.syncs.replace(self.syncs.get() + 1);
if self.fail_sync || self.syncs_before_failure.is_some_and(|n| done >= n) {
return Err(io::Error::new(io::ErrorKind::StorageFull, "injected: no space left on device"));
}
RealFs.sync(file)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
self.renames.set(self.renames.get() + 1);
if let Some(kind) = self.fail_rename {
return Err(io::Error::new(kind, "injected rename failure"));
}
if self.locked_renames.get() > 0 {
self.locked_renames.set(self.locked_renames.get() - 1);
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: sharing violation"));
}
RealFs.rename(from, to)
}
fn hard_link(&self, from: &Path, to: &Path) -> io::Result<()> {
if let Some(bytes) = self.created_meanwhile {
std::fs::write(to, bytes)?;
}
if self.no_hard_links {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: hard links not supported"));
}
RealFs.hard_link(from, to)
}
fn remove(&self, path: &Path) -> io::Result<()> {
RealFs.remove(path)
}
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
RealFs.sync_dir(dir)
}
fn set_permissions(&self, file: &File, perms: std::fs::Permissions) -> io::Result<()> {
if self.fail_permissions {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: operation not permitted"));
}
RealFs.set_permissions(file, perms)
}
}
const NO_RETRY: RenameRetry = RenameRetry { attempts: 0, first_delay_ms: 0 };
#[test]
fn replaces_and_creates() {
let d = TempDir::new("ok");
let p = d.0.join("doc.craft");
atomic_write(&p, b"first").ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
atomic_write(&p, b"second, longer").ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"second, longer".to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn a_missing_folder_is_an_error_naming_it() {
let d = TempDir::new("parent");
let folder = d.0.join("a");
let p = folder.join("b/out.png");
for r in [atomic_write(&p, b"x"), write_new(&p, b"x")] {
let msg = r.err().map(|e| e.to_string()).unwrap_or_default();
assert!(msg.contains(&folder.join("b").display().to_string()), "{msg}");
}
assert!(!folder.exists(), "no folder is created");
}
#[test]
fn failed_flush_keeps_original() {
let d = TempDir::new("write");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty { fail_sync: true, ..Default::default() };
let e = atomic_write_custom(&fs, NO_RETRY, &p, &[7u8; 4096]);
assert_eq!(e.map_err(|e| e.kind()), Err(io::ErrorKind::StorageFull));
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_eq!(fs.renames.get(), 0);
assert_no_temp(&d.0);
}
#[test]
fn streaming_writes_go_through_the_injected_fs() {
let d = TempDir::new("stream");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let chunks = |f: &mut File| {
f.write_all(b"chunk1 ")?;
f.write_all(b"chunk2")
};
let fs = Faulty { fail_sync: true, ..Default::default() };
assert!(replace(&fs, NO_RETRY, &p, chunks).is_err());
let fs = Faulty { fail_rename: Some(io::ErrorKind::Other), ..Default::default() };
assert!(replace(&fs, NO_RETRY, &p, chunks).is_err());
assert_eq!(fs.renames.get(), 1);
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_no_temp(&d.0);
let fs = Faulty::default();
assert!(replace(&fs, NO_RETRY, &p, chunks).is_ok());
assert_eq!(std::fs::read(&p).ok(), Some(b"chunk1 chunk2".to_vec()));
}
#[test]
fn rename_failure_keeps_original() {
let d = TempDir::new("rename");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty { fail_rename: Some(io::ErrorKind::Other), ..Default::default() };
assert!(atomic_write_custom(&fs, NO_RETRY, &p, b"new").is_err());
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn locked_target_rename_is_retried() {
let d = TempDir::new("locked");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty { locked_renames: Cell::new(2), ..Default::default() };
atomic_write_custom(&fs, RenameRetry { attempts: 3, first_delay_ms: 1 }, &p, b"new").ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"new".to_vec()));
assert_eq!(fs.renames.get(), 3);
assert_no_temp(&d.0);
}
#[test]
fn write_new_never_overwrites_existing() {
let d = TempDir::new("new");
let p = d.0.join("file.craft");
write_new(&p, b"first").ok();
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
let res = write_new(&p, b"second");
assert!(res.is_err());
assert_eq!(std::fs::read(&p).ok(), Some(b"first".to_vec()));
assert_no_temp(&d.0);
}
#[test]
fn write_new_copies_where_hard_links_fail_and_never_replaces() {
let d = TempDir::new("new-copy");
let p = d.0.join("export.png");
let fs = Faulty { no_hard_links: true, ..Default::default() };
assert!(create(&fs, &p, |f| f.write_all(b"copied")).is_ok());
assert_eq!(std::fs::read(&p).ok(), Some(b"copied".to_vec()));
assert_no_temp(&d.0);
// A file that appears after the existence check is kept, whether the copy or the hard
// link finds it.
for no_hard_links in [true, false] {
let p = d.0.join(format!("raced-{no_hard_links}.png"));
let fs = Faulty { no_hard_links, created_meanwhile: Some(ORIGINAL), ..Default::default() };
let e = create(&fs, &p, |f| f.write_all(b"mine"));
assert_eq!(e.map_err(|e| e.kind()), Err(io::ErrorKind::AlreadyExists));
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_eq!(fs.renames.get(), 0);
}
assert_no_temp(&d.0);
// A failed new file has no original to speak of.
let p = d.0.join("full.png");
let fs = Faulty { fail_sync: true, ..Default::default() };
let msg = create(&fs, &p, |f| f.write_all(b"x")).err().map(|e| e.to_string()).unwrap_or_default();
assert!(msg.contains("disk is full") && !msg.contains("original"), "{msg}");
assert!(!p.exists());
assert_no_temp(&d.0);
}
#[test]
fn a_failed_copy_leaves_no_partial_file() {
let d = TempDir::new("new-copy-fails");
let p = d.0.join("export.png");
// The temporary file flushes; the copy made in its place does not.
let fs = Faulty { no_hard_links: true, syncs_before_failure: Some(1), ..Default::default() };
let e = write_new_custom(&fs, &p, b"copied");
assert_eq!(e.map_err(|e| e.kind()), Err(io::ErrorKind::StorageFull));
assert_eq!(fs.syncs.get(), 2);
assert!(!p.exists());
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn failing_to_keep_permissions_fails_the_save() {
let d = TempDir::new("perms");
let p = d.0.join("doc.craft");
std::fs::write(&p, ORIGINAL).ok();
let fs = Faulty { fail_permissions: true, ..Default::default() };
let msg = atomic_write_custom(&fs, NO_RETRY, &p, b"new").err().map(|e| e.to_string()).unwrap_or_default();
assert!(msg.contains("permissions"), "{msg}");
assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec()));
assert_eq!(fs.renames.get(), 0);
assert_no_temp(&d.0);
}
#[test]
fn retry_delays_are_capped() {
let d = TempDir::new("cap");
let p = d.0.join("doc.craft");
let fs = Faulty { locked_renames: Cell::new(2), ..Default::default() };
let started = std::time::Instant::now();
assert!(atomic_write_custom(&fs, RenameRetry { attempts: 2, first_delay_ms: 60_000 }, &p, b"new").is_ok());
assert!(started.elapsed() < std::time::Duration::from_secs(5));
assert_eq!(fs.renames.get(), 3);
}
}