//! Durable file updates (spec ยง13.6): every write that must survive a //! crash goes through here. //! //! A file is replaced by writing a uniquely named temporary file in the //! same directory, syncing it, renaming it over the target, and syncing //! the directory so the rename itself is on disk. A crash at any point //! leaves either the old file or the new one, never a torn one; at worst //! a stray temporary file, which nothing reads. //! //! In tests, `crash_after(n)` makes the n-th durable step on the current //! thread fail as if the process had died there, so a test can stop an //! operation at every step and check what is left behind. use rand::RngCore; use std::fs; use std::io::{self, Write}; use std::path::Path; /// Replace `path` with `data`, atomically and durably. pub fn write_atomic(path: &Path, data: &[u8]) -> io::Result<()> { let dir = path.parent().unwrap_or(Path::new(".")); let name = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); let mut nonce = [0u8; 6]; rand::rngs::OsRng.fill_bytes(&mut nonce); let tmp = dir.join(format!(".{name}.tmp-{}", hex::encode(nonce))); step("create temporary file")?; let written = (|| { let mut f = fs::File::create(&tmp)?; f.write_all(data)?; f.sync_all()?; step("rename into place")?; fs::rename(&tmp, path) })(); if let Err(e) = written { if !is_injected(&e) { let _ = fs::remove_file(&tmp); } return Err(at("write", path, e)); } sync_dir(dir).map_err(|e| at("sync the directory of", path, e)) } /// `e`, naming what failed and on which file, so a failure deep in a /// command says where it happened (an injected test crash stays as is). /// The kind is kept for callers that test it. fn at(what: &str, path: &Path, e: io::Error) -> io::Error { if is_injected(&e) { return e; } io::Error::new(e.kind(), format!("{what} {}: {e}", path.display())) } /// Rename `from` to `to` and make the rename durable. pub fn rename(from: &Path, to: &Path) -> io::Result<()> { step("rename")?; fs::rename(from, to).map_err(|e| at(&format!("rename {} to", from.display()), to, e))?; sync_dir(to.parent().unwrap_or(Path::new("."))).map_err(|e| at("sync the directory of", to, e)) } /// Flush a directory's entries (new, renamed or removed files) to disk. /// A no-op where directories cannot be opened for syncing. pub fn sync_dir(dir: &Path) -> io::Result<()> { #[cfg(unix)] { fs::File::open(dir)?.sync_all() } #[cfg(not(unix))] { let _ = dir; Ok(()) } } /// A point where a crash may happen. Outside tests it does nothing. pub fn step(what: &str) -> io::Result<()> { #[cfg(test)] { failpoint::hit(what) } #[cfg(not(test))] { let _ = what; Ok(()) } } const INJECTED: &str = "injected crash"; fn is_injected(e: &io::Error) -> bool { e.to_string().starts_with(INJECTED) } #[cfg(test)] pub(crate) mod failpoint { use std::cell::Cell; use std::io; thread_local! { static COUNTDOWN: Cell> = const { Cell::new(None) }; static FIRED: Cell = const { Cell::new(false) }; } /// Fail the `n`-th durable step from now (1-based) on this thread, /// and every step after it, as a dead process would. pub fn crash_after(n: usize) { COUNTDOWN.with(|c| c.set(Some(n))); FIRED.with(|f| f.set(false)); } /// Stop injecting; returns whether a crash was injected. pub fn disarm() -> bool { COUNTDOWN.with(|c| c.set(None)); FIRED.with(|f| f.replace(false)) } pub(super) fn hit(what: &str) -> io::Result<()> { if FIRED.with(|f| f.get()) { return Err(io::Error::other(format!( "{} (after) at {what}", super::INJECTED ))); } COUNTDOWN.with(|c| match c.get() { Some(1) => { c.set(None); FIRED.with(|f| f.set(true)); Err(io::Error::other(format!("{} at {what}", super::INJECTED))) } Some(n) => { c.set(Some(n - 1)); Ok(()) } None => Ok(()), }) } } #[cfg(test)] mod tests { use super::*; #[test] fn a_failed_write_names_the_file_and_keeps_its_kind() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("attest").join("feature").join("x"); let e = write_atomic(&path, b"state").unwrap_err(); assert_eq!(e.kind(), io::ErrorKind::NotFound); let msg = e.to_string(); assert!( msg.starts_with("write ") && msg.contains("attest/feature/x"), "{msg}" ); write_atomic(&dir.path().join("ok"), b"state").unwrap(); let e = rename(&dir.path().join("missing"), &dir.path().join("to")).unwrap_err(); assert!( e.to_string().contains("missing") && e.to_string().contains("to"), "{e}" ); } }