From 8df3d87d8b9d2ff1a714f89e01bf5b75d3ee0bd6 Mon Sep 17 00:00:00 2001 From: Zach Shallbetter Date: Sun, 11 Oct 2026 02:41:47 -0700 Subject: [PATCH] Add craft-atomic: crash-safe atomic write and document replacement (refs #2, fixes #14) --- Cargo.lock | 180 ++++++++++- Cargo.toml | 1 + README.md | 1 + crates/atomic/Cargo.toml | 17 + crates/atomic/README.md | 77 +++++ crates/atomic/src/lib.rs | 481 ++++++++++++++++++++++++++++ crates/atomic/tests/atomic_tests.rs | 127 ++++++++ 7 files changed, 878 insertions(+), 6 deletions(-) create mode 100644 crates/atomic/Cargo.toml create mode 100644 crates/atomic/README.md create mode 100644 crates/atomic/src/lib.rs create mode 100644 crates/atomic/tests/atomic_tests.rs diff --git a/Cargo.lock b/Cargo.lock index c98fc400..e1789dc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,7 +144,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", ] [[package]] @@ -153,6 +162,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -183,6 +202,30 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -283,6 +326,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -371,6 +425,29 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "craft-atomic" +version = "0.1.0" +dependencies = [ + "proptest", + "rustix 1.1.5", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -772,6 +849,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -857,6 +940,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", ] [[package]] @@ -1307,7 +1391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "cfg-if", "cfg_aliases", @@ -1927,12 +2011,38 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set 0.11.1", + "bit-vec 0.10.1", + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -1963,6 +2073,31 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core", +] + [[package]] name = "range-alloc" version = "0.1.5" @@ -2025,6 +2160,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -2113,6 +2254,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2446,7 +2599,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -2555,6 +2708,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -2640,6 +2799,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2891,8 +3059,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.10.0", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -2964,7 +3132,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index f17a3a25..e02d853d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] # internal: add each crate here as `craft- = { path = "crates/" }` +craft-atomic = { path = "crates/atomic" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } craft-ui = { path = "crates/ui" } diff --git a/README.md b/README.md index c6050691..0137cfe9 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " io::Result<()>; + /// Rename `from` over `to`, replacing it atomically. + fn rename(&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<()>; + /// Copy the destination's permissions onto the temporary file before replacement. + fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()>; +} + +/// The production file system implementation. +#[derive(Debug, Clone, Copy, Default)] +pub struct RealFs; + +impl AtomicFs for RealFs { + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> { + let mut f = OpenOptions::new().write(true).create_new(true).open(tmp)?; + f.write_all(bytes)?; + sync_file(&f) + } + + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + std::fs::rename(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 copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { + let perms = std::fs::metadata(from)?.permissions(); + std::fs::set_permissions(to, 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 (`PermissionDenied`). +#[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. + 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(path: &Path, write_fn: F) -> io::Result<()> +where + F: FnOnce(&mut File) -> io::Result<()>, +{ + let target = resolve_symlink(path); + 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("."), + }; + std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?; + + let mut tmp = None; + for _ in 0..8 { + let candidate = dir.join(temp_name(&name.to_string_lossy())); + match OpenOptions::new().write(true).create_new(true).open(&candidate) { + Ok(mut f) => { + let res = write_fn(&mut f).and_then(|()| sync_file(&f)); + drop(f); + if let Err(e) = res { + let _ = std::fs::remove_file(&candidate); + return Err(write_error(e, path, &dir)); + } + tmp = Some(candidate); + break; + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(write_error(e, path, &dir)), + } + } + + let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?; + + if target.exists() { + let _ = RealFs.copy_permissions(&target, &tmp); + } + + if let Err(e) = retry_rename(RenameRetry::platform(), || std::fs::rename(&tmp, &target)) { + let _ = std::fs::remove_file(&tmp); + return Err(rename_error(e, path)); + } + + let _ = RealFs.sync_dir(&dir); + Ok(()) +} + +/// 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<()> { + let target = resolve_symlink(path); + 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("."), + }; + std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?; + + let mut tmp = None; + for _ in 0..8 { + let candidate = dir.join(temp_name(&name.to_string_lossy())); + match fs.write_new(&candidate, bytes) { + Ok(()) => { + tmp = Some(candidate); + break; + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => { + let _ = fs.remove(&candidate); + return Err(write_error(e, path, &dir)); + } + } + } + let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?; + + if target.exists() { + let _ = fs.copy_permissions(&target, &tmp); + } + + if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) { + let _ = fs.remove(&tmp); + return Err(rename_error(e, path)); + } + + let _ = fs.sync_dir(&dir); + Ok(()) +} + +/// 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(path: &Path, write_fn: F) -> io::Result<()> +where + F: FnOnce(&mut File) -> io::Result<()>, +{ + let target = resolve_symlink(path); + if target.exists() { + return Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: file already exists", path.display()))); + } + + 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("."), + }; + std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?; + + let tmp = dir.join(temp_name(&name.to_string_lossy())); + let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?; + let filled = write_fn(&mut f).and_then(|()| sync_file(&f)); + drop(f); + + if let Err(e) = filled { + let _ = std::fs::remove_file(&tmp); + return Err(write_error(e, path, &dir)); + } + + #[cfg(unix)] + let placed = std::fs::hard_link(&tmp, &target).or_else(|e| { + if e.kind() == io::ErrorKind::AlreadyExists { + Err(e) + } else { + // Hard link unsupported; fall back to atomic rename if target still missing + std::fs::rename(&tmp, &target) + } + }); + #[cfg(not(unix))] + let placed = std::fs::rename(&tmp, &target); + + let _ = std::fs::remove_file(&tmp); + placed +} + +/// Retries an operation with exponential backoff while it fails with `PermissionDenied`. +pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> { + let mut delay = retry.first_delay_ms; + let mut left = retry.attempts; + loop { + match rename() { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => { + left -= 1; + sleep_ms(delay); + delay = delay.saturating_mul(2); + } + r => return r, + } + } +} + +#[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) {} + +fn resolve_symlink(path: &Path) -> PathBuf { + match std::fs::symlink_metadata(path) { + Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()), + _ => path.to_path_buf(), + } +} + +/// 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()) +} + +fn context(e: io::Error, msg: String) -> io::Error { + io::Error::new(e.kind(), format!("{msg}: {e}")) +} + +const UNCHANGED: &str = "the original file was not changed"; + +fn write_error(e: io::Error, path: &Path, dir: &Path) -> io::Error { + let msg = match e.kind() { + io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => { + format!("cannot save {}: the folder {} is not writable ({e}); {UNCHANGED}", path.display(), dir.display()) + } + io::ErrorKind::StorageFull => format!("cannot save {}: the disk is full ({e}); {UNCHANGED}", path.display()), + _ => format!("cannot save {}: {e}; {UNCHANGED}", path.display()), + }; + io::Error::new(e.kind(), msg) +} + +fn rename_error(e: io::Error, path: &Path) -> io::Error { + let msg = if is_cross_device(&e) { + format!("cannot save {}: the temporary file is on a different volume than the destination ({e}); {UNCHANGED}", path.display()) + } else if e.kind() == io::ErrorKind::PermissionDenied { + format!("cannot save {}: the file is locked or read-only ({e}); {UNCHANGED}", path.display()) + } else { + format!("cannot save {}: {e}; {UNCHANGED}", 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_write_after: Option, + fail_rename: Option, + locked_renames: Cell, + renames: Cell, + } + + impl AtomicFs for Faulty { + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> { + if let Some(n) = self.fail_write_after { + let mut f = OpenOptions::new().write(true).create_new(true).open(tmp)?; + f.write_all(&bytes[..n.min(bytes.len())])?; + return Err(io::Error::new(io::ErrorKind::StorageFull, "injected: no space left on device")); + } + RealFs.write_new(tmp, bytes) + } + 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 remove(&self, path: &Path) -> io::Result<()> { + RealFs.remove(path) + } + fn sync_dir(&self, dir: &Path) -> io::Result<()> { + RealFs.sync_dir(dir) + } + fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_permissions(from, to) + } + } + + 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 creates_missing_parent_folders() { + let d = TempDir::new("parent"); + let p = d.0.join("a/b/out.png"); + atomic_write(&p, b"x").ok(); + assert_eq!(std::fs::read(&p).ok(), Some(b"x".to_vec())); + } + + #[test] + fn write_failure_halfway_keeps_original() { + let d = TempDir::new("write"); + let p = d.0.join("doc.craft"); + std::fs::write(&p, ORIGINAL).ok(); + let fs = Faulty { fail_write_after: Some(5), ..Default::default() }; + let e = atomic_write_custom(&fs, NO_RETRY, &p, &[7u8; 4096]); + assert!(e.is_err()); + assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec())); + assert_eq!(fs.renames.get(), 0); + assert_no_temp(&d.0); + } + + #[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); + } +} diff --git a/crates/atomic/tests/atomic_tests.rs b/crates/atomic/tests/atomic_tests.rs new file mode 100644 index 00000000..0fcdf17e --- /dev/null +++ b/crates/atomic/tests/atomic_tests.rs @@ -0,0 +1,127 @@ +use craft_atomic::{atomic_write, atomic_write_with, or_plain_fsync, write_new_with}; +use std::cell::Cell; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +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-int-{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"original precious document"; + +#[test] +fn atomic_write_with_streaming_writer() { + let d = TempDir::new("stream"); + let p = d.0.join("doc.dat"); + std::fs::write(&p, ORIGINAL).ok(); + + atomic_write_with(&p, |f| { + f.write_all(b"chunk1 ")?; + f.write_all(b"chunk2") + }) + .ok(); + + assert_eq!(std::fs::read(&p).ok(), Some(b"chunk1 chunk2".to_vec())); + assert_no_temp(&d.0); +} + +#[test] +fn write_new_with_streaming_writer() { + let d = TempDir::new("write_new_stream"); + let p = d.0.join("new.dat"); + + write_new_with(&p, |f| f.write_all(b"new content")).ok(); + + assert_eq!(std::fs::read(&p).ok(), Some(b"new content".to_vec())); + assert_no_temp(&d.0); + + // Second write to existing file must fail with AlreadyExists + let res = write_new_with(&p, |f| f.write_all(b"overwritten?")); + assert!(res.is_err()); + assert_eq!(std::fs::read(&p).ok(), Some(b"new content".to_vec())); +} + +#[test] +fn streaming_failure_aborts_and_keeps_original() { + let d = TempDir::new("stream_fail"); + let p = d.0.join("doc.dat"); + std::fs::write(&p, ORIGINAL).ok(); + + let res = atomic_write_with(&p, |f| { + f.write_all(b"half a file")?; + Err(io::Error::new(io::ErrorKind::StorageFull, "disk full")) + }); + + assert!(res.is_err()); + assert_eq!(std::fs::read(&p).ok(), Some(ORIGINAL.to_vec())); + assert_no_temp(&d.0); +} + +#[test] +fn apple_full_fsync_fallback_behavior() { + let refused = |errno| Err(io::Error::from_raw_os_error(errno)); + for (errno, name) in [(45, "ENOTSUP (SMB)"), (25, "ENOTTY")] { + let fsynced = Cell::new(false); + let r = or_plain_fsync(refused(errno), || { + fsynced.set(true); + Ok(()) + }); + let apple = cfg!(target_vendor = "apple"); + assert_eq!(r.is_ok(), apple, "{name}"); + assert_eq!(fsynced.get(), apple, "{name}: fsync fallback only runs on Apple"); + } + + // Unrelated errors (e.g. EIO = 5) must not fallback + let r = or_plain_fsync(refused(5), || panic!("should not run for EIO")); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5))); +} + +#[cfg(unix)] +#[test] +fn mode_and_symlink_preservation() { + use std::os::unix::fs::PermissionsExt; + let d = TempDir::new("mode_symlink"); + + // Permissions + let p = d.0.join("secret.key"); + std::fs::write(&p, ORIGINAL).ok(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).ok(); + atomic_write(&p, b"updated secret").ok(); + let mode = std::fs::metadata(&p).map(|m| m.permissions().mode() & 0o777).ok(); + assert_eq!(mode, Some(0o600)); + + // Symlink + let real = d.0.join("real.txt"); + let link = d.0.join("link.txt"); + std::fs::write(&real, ORIGINAL).ok(); + std::os::unix::fs::symlink(&real, &link).ok(); + atomic_write(&link, b"through symlink").ok(); + assert!(std::fs::symlink_metadata(&link).map(|m| m.file_type().is_symlink()).unwrap_or(false)); + assert_eq!(std::fs::read(&real).ok(), Some(b"through symlink".to_vec())); +}