Skip to main content

blockworx/
atomic.rs

1//! Crash-safe file writes.
2//!
3//! Every write of something a user would mind losing goes through
4//! [`write_atomically`]. `std::fs::write` truncates the target and then fills it,
5//! so a crash, a kill, or a power cut partway through leaves a half-written file
6//! where the previous good one used to be — and for the document save that is the
7//! drawing itself.
8
9use std::io::Write as _;
10use std::path::{Path, PathBuf};
11
12/// Write `contents` to `path` so that dying partway through leaves the previous
13/// contents intact: the bytes land in a sibling temporary file, are flushed to
14/// the storage device, and only then replace `path` with one atomic rename.
15///
16/// The target is never opened for writing, so there is no window in which it
17/// holds a partial document.
18pub fn write_atomically(path: &Path, contents: &[u8]) -> std::io::Result<()> {
19    let target = resolve(path);
20    let temp = temp_path(&target)?;
21    write_and_replace(&temp, &target, contents).inspect_err(|_| {
22        // A temp file left behind would be overwritten by the next attempt
23        // anyway; removing it just keeps the directory tidy.
24        let _ = std::fs::remove_file(&temp);
25    })
26}
27
28/// Follow symlinks, so writing a symlinked document replaces what it points at —
29/// the way `fs::write` does. Renaming onto the link would instead replace the
30/// link with a regular file and quietly detach it from its target.
31fn resolve(path: &Path) -> PathBuf {
32    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
33}
34
35/// A dot-prefixed sibling of `target`. It has to share the directory: `rename`
36/// is only atomic within one filesystem.
37fn temp_path(target: &Path) -> std::io::Result<PathBuf> {
38    let name = target.file_name().ok_or_else(|| {
39        std::io::Error::new(
40            std::io::ErrorKind::InvalidInput,
41            format!("{} names no file to write", target.display()),
42        )
43    })?;
44    let mut temp = std::ffi::OsString::from(".");
45    temp.push(name);
46    temp.push(".tmp");
47    Ok(target.with_file_name(temp))
48}
49
50fn write_and_replace(temp: &Path, target: &Path, contents: &[u8]) -> std::io::Result<()> {
51    let mut file = std::fs::File::create(temp)?;
52    file.write_all(contents)?;
53    // Without this the rename can be recorded while the bytes are still in the
54    // page cache, so a power cut yields an intact rename onto empty content.
55    file.sync_all()?;
56    drop(file);
57    std::fs::rename(temp, target)?;
58    sync_dir(parent_dir(target))?;
59    Ok(())
60}
61
62/// The directory holding `target`. A bare relative filename has an empty parent,
63/// which names nothing openable.
64fn parent_dir(target: &Path) -> &Path {
65    match target.parent() {
66        Some(dir) if !dir.as_os_str().is_empty() => dir,
67        _ => Path::new("."),
68    }
69}
70
71/// Flush the directory entry itself, so the rename survives a power cut and not
72/// just a process crash.
73#[cfg(unix)]
74fn sync_dir(dir: &Path) -> std::io::Result<()> {
75    std::fs::File::open(dir)?.sync_all()
76}
77
78/// Windows has no directory handle to sync; `MoveFileEx` metadata ordering is
79/// the filesystem's business there.
80#[cfg(not(unix))]
81fn sync_dir(_dir: &Path) -> std::io::Result<()> {
82    Ok(())
83}
84
85#[cfg(test)]
86pub mod tests {
87    use super::*;
88
89    /// A unique directory per test, removed on drop. Named for the test so a
90    /// failure leaves an identifiable directory behind. Shared with `app`'s
91    /// tests, which write real files for the courtesy load to open.
92    pub struct TempDir(PathBuf);
93
94    impl TempDir {
95        /// # Panics
96        ///
97        /// If the directory cannot be created — in a test that is the
98        /// assertion, not something to recover from.
99        pub fn new(name: &str) -> Self {
100            let dir = std::env::temp_dir().join(format!("blockworx-{}-{name}", std::process::id()));
101            let _ = std::fs::remove_dir_all(&dir);
102            std::fs::create_dir_all(&dir).unwrap();
103            Self(dir)
104        }
105
106        pub fn join(&self, name: &str) -> PathBuf {
107            self.0.join(name)
108        }
109
110        pub fn path(&self) -> &Path {
111            &self.0
112        }
113    }
114
115    impl Drop for TempDir {
116        fn drop(&mut self) {
117            let _ = std::fs::remove_dir_all(&self.0);
118        }
119    }
120
121    fn read(path: &Path) -> String {
122        std::fs::read_to_string(path).unwrap()
123    }
124
125    #[test]
126    fn it_creates_and_then_overwrites() {
127        let dir = TempDir::new("create-overwrite");
128        let doc = dir.join("doc.json");
129
130        write_atomically(&doc, b"first").unwrap();
131        assert_eq!(read(&doc), "first");
132
133        write_atomically(&doc, b"second").unwrap();
134        assert_eq!(read(&doc), "second");
135    }
136
137    #[test]
138    fn a_successful_write_leaves_no_temp_behind() {
139        let dir = TempDir::new("no-temp");
140        let doc = dir.join("doc.json");
141        write_atomically(&doc, b"content").unwrap();
142
143        let leftovers: Vec<_> = std::fs::read_dir(&dir.0)
144            .unwrap()
145            .map(|e| e.unwrap().file_name())
146            .filter(|name| name != "doc.json")
147            .collect();
148        assert!(leftovers.is_empty(), "left {leftovers:?} behind");
149    }
150
151    /// The property the whole module exists for: a write that fails partway
152    /// cannot have touched the target. The failure is forced by occupying the
153    /// temp path with a directory, so `File::create` fails after the target
154    /// already holds content worth keeping.
155    #[test]
156    fn a_failed_write_leaves_the_previous_contents_intact() {
157        let dir = TempDir::new("failed-write");
158        let doc = dir.join("doc.json");
159        write_atomically(&doc, b"the good version").unwrap();
160
161        let blocker = dir.join(".doc.json.tmp");
162        std::fs::create_dir(&blocker).unwrap();
163        // Proves the precondition: this is the path the write would have used.
164        // Canonicalized on both sides — the platform temp directory is a
165        // symlink on macOS, and `resolve` follows it.
166        assert_eq!(
167            temp_path(&resolve(&doc)).unwrap(),
168            std::fs::canonicalize(&blocker).unwrap(),
169        );
170
171        assert!(write_atomically(&doc, b"a doomed longer version").is_err());
172        assert_eq!(read(&doc), "the good version");
173    }
174
175    #[test]
176    fn a_path_naming_no_file_is_an_error() {
177        assert!(write_atomically(Path::new(".."), b"x").is_err());
178    }
179
180    /// A symlinked document is written *through*, not replaced by a regular
181    /// file — the behavior `fs::write` had and a bare rename would lose.
182    #[cfg(unix)]
183    #[test]
184    fn writing_a_symlink_updates_its_target() {
185        let dir = TempDir::new("symlink");
186        let target = dir.join("real.json");
187        let link = dir.join("link.json");
188        write_atomically(&target, b"before").unwrap();
189        std::os::unix::fs::symlink(&target, &link).unwrap();
190
191        write_atomically(&link, b"after").unwrap();
192
193        assert!(
194            std::fs::symlink_metadata(&link).unwrap().is_symlink(),
195            "the link itself was replaced"
196        );
197        assert_eq!(read(&target), "after");
198    }
199}