Skip to main content

qframe/storage/
atomic.rs

1//! Writing a file so that a crash or a power cut never leaves half of it on disk.
2//!
3//! The order is the whole point: write a temporary file next to the real one, flush it to the
4//! disk, rename it over the real name, then flush the directory itself. Without the last step
5//! the rename can still be lost after a power cut, and then both names are gone: the old one was
6//! replaced and the new one never reached the disk.
7
8use std::fs;
9use std::io::{self, Write as _};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13/// Tells two temporary files apart when two threads write the same path at the same time; the
14/// process id alone would give them the same name.
15static NEXT: AtomicU64 = AtomicU64::new(0);
16
17/// How many symbolic links are followed before a path is taken for a loop, as the kernel does.
18const MAX_LINKS: usize = 40;
19
20/// A step [`atomic_write`] has finished, in the order they happen.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum WriteStep {
23    /// The contents are in a temporary file next to the real one, which the path names.
24    Wrote(PathBuf),
25    /// The temporary file is on the disk, not only in the operating system's cache.
26    SyncedFile,
27    /// The temporary file now carries the real name, replacing what was there.
28    Renamed,
29    /// The directory entry is on the disk, so the rename survives a power cut. Not reported on
30    /// platforms where a directory cannot be flushed; see [`atomic_write`].
31    SyncedDirectory,
32}
33
34/// Writes `contents` to `path` so that a crash or a power cut leaves either the file that was
35/// there before or the new one, never half of either.
36///
37/// The parent directory has to exist; it is not created. The temporary file is written in that
38/// same directory, because a rename cannot cross a file system. On Unix systems the new file
39/// keeps the permissions of the file it replaces, so a private file stays private.
40///
41/// A `path` that is a symbolic link is written through: the file it points at is replaced and
42/// the link stays a link, so a settings file linked from a dotfiles repository keeps being
43/// that repository's file. The temporary file is then written next to the file the link points
44/// at, and the new file keeps that file's permissions. A link to a file that does not exist
45/// yet creates that file. Links pointing at links are followed to the end.
46///
47/// On Unix systems the directory is flushed after the rename, which is what makes the new name
48/// survive a power cut. On other platforms, Windows among them, the standard library cannot open
49/// a directory to flush it, so that step is skipped: the file's own contents are still flushed
50/// before the rename, so no half-written file appears, but a power cut in the moment after the
51/// rename can leave the old file. Doing better needs calls the framework cannot make without
52/// `unsafe`, which it forbids.
53///
54/// # Errors
55///
56/// Returns the I/O error of the first step that failed. The temporary file is removed when a
57/// step after it fails, so a failed write leaves nothing behind. Links that point at each
58/// other in a loop are an error, and nothing is written.
59pub fn atomic_write(path: &Path, contents: &[u8]) -> io::Result<()> {
60    atomic_write_reporting(path, contents, |_| {})
61}
62
63/// [`atomic_write`], reporting each step to `on_step` as it finishes, for a log or a screen that
64/// shows what writing a file safely actually does.
65///
66/// # Errors
67///
68/// The same as [`atomic_write`]: the error of the first step that failed. `on_step` is called
69/// only for the steps that succeeded.
70pub fn atomic_write_reporting(path: &Path, contents: &[u8], mut on_step: impl FnMut(WriteStep)) -> io::Result<()> {
71    // Renaming over a link would replace the link itself with a plain file.
72    let target = link_target(path)?;
73    let path = target.as_path();
74    let directory = path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new("."));
75    let temporary = directory.join(temporary_name(path));
76    let written = (|| {
77        let mut file = fs::File::create(&temporary)?;
78        // The new file replaces the old one, so it keeps the old one's permissions: a file the
79        // user made private stays private. Set before anything is written into it.
80        #[cfg(unix)]
81        if let Some(existing) = fs::metadata(path).ok().filter(fs::Metadata::is_file) {
82            file.set_permissions(existing.permissions())?;
83        }
84        file.write_all(contents)?;
85        on_step(WriteStep::Wrote(temporary.clone()));
86        file.sync_all()?;
87        on_step(WriteStep::SyncedFile);
88        fs::rename(&temporary, path)?;
89        on_step(WriteStep::Renamed);
90        if sync_directory(directory)? {
91            on_step(WriteStep::SyncedDirectory);
92        }
93        Ok(())
94    })();
95    if written.is_err() {
96        // The temporary file is ours alone, so removing it can only fail because it is already
97        // gone, which is the state we want.
98        let _ = fs::remove_file(&temporary);
99    }
100    written
101}
102
103/// The file `path` names once every symbolic link on its last component is followed; `path`
104/// itself when it is no link. A relative link is read from the directory the link stands in.
105fn link_target(path: &Path) -> io::Result<PathBuf> {
106    let mut target = path.to_path_buf();
107    for _ in 0..MAX_LINKS {
108        let is_link = fs::symlink_metadata(&target).is_ok_and(|meta| meta.file_type().is_symlink());
109        if !is_link {
110            return Ok(target);
111        }
112        let next = fs::read_link(&target)?;
113        target = match target.parent() {
114            Some(parent) if next.is_relative() => parent.join(next),
115            _ => next,
116        };
117    }
118    Err(io::Error::other(format!("{}: too many levels of symbolic links", path.display())))
119}
120
121/// A name no other writer of the same file uses, in the same directory as the real file.
122fn temporary_name(path: &Path) -> String {
123    let name = path.file_name().and_then(|name| name.to_str()).unwrap_or("file");
124    let ticket = NEXT.fetch_add(1, Ordering::Relaxed);
125    format!("{name}.tmp-{}-{ticket}", std::process::id())
126}
127
128/// Flushes the directory entry itself, so a rename in it survives a power cut. `Ok(false)` on a
129/// platform where the standard library cannot open a directory; see [`atomic_write`].
130#[cfg(unix)]
131fn sync_directory(directory: &Path) -> io::Result<bool> {
132    // Opening a directory read-only is enough to flush it; no write handle exists for one.
133    fs::File::open(directory)?.sync_all()?;
134    Ok(true)
135}
136
137/// Flushes the directory entry itself. Always `Ok(false)` here: the standard library cannot open
138/// a directory on this platform.
139#[cfg(not(unix))]
140fn sync_directory(_directory: &Path) -> io::Result<bool> {
141    Ok(false)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    /// An empty directory of this test's own.
149    fn temp_dir(name: &str) -> PathBuf {
150        let dir = std::env::temp_dir().join(format!("quvyta-atomic-{name}-{}", std::process::id()));
151        let _ = fs::remove_dir_all(&dir);
152        fs::create_dir_all(&dir).expect("test directory");
153        dir
154    }
155
156    #[test]
157    fn the_steps_happen_in_the_order_that_makes_the_write_safe() {
158        let dir = temp_dir("steps");
159        let path = dir.join("tree.toml");
160        let mut steps = Vec::new();
161        atomic_write(&path, b"first\n").expect("first write");
162        atomic_write_reporting(&path, b"second\n", |step| steps.push(step)).expect("second write");
163
164        let temporary = match steps.first() {
165            Some(WriteStep::Wrote(temporary)) => temporary.clone(),
166            other => panic!("the first step writes a temporary file, not {other:?}"),
167        };
168        assert_eq!(temporary.parent(), path.parent(), "the temporary file shares the directory");
169        assert_ne!(temporary, path);
170        let rest: Vec<WriteStep> = steps[1..].to_vec();
171        if cfg!(unix) {
172            assert_eq!(
173                rest,
174                [WriteStep::SyncedFile, WriteStep::Renamed, WriteStep::SyncedDirectory],
175                "the directory is flushed, and only after the rename"
176            );
177        } else {
178            assert_eq!(rest, [WriteStep::SyncedFile, WriteStep::Renamed]);
179        }
180        assert_eq!(fs::read_to_string(&path).expect("read"), "second\n");
181        let leftovers = fs::read_dir(&dir).expect("list").count();
182        assert_eq!(leftovers, 1, "nothing but the file itself stays behind");
183        fs::remove_dir_all(&dir).expect("clean");
184    }
185
186    #[test]
187    fn a_failed_write_keeps_the_old_file_and_cleans_up_after_itself() {
188        let dir = temp_dir("failure");
189        // A directory cannot be renamed over, so the rename step fails with the temporary file
190        // already written: exactly the moment the clean-up is for.
191        let path = dir.join("occupied");
192        fs::create_dir(&path).expect("directory in the way");
193        fs::write(path.join("inside.toml"), "kept\n").expect("content under it");
194
195        let mut steps = Vec::new();
196        let error = atomic_write_reporting(&path, b"new\n", |step| steps.push(step)).expect_err("rename fails");
197        assert!(matches!(steps.first(), Some(WriteStep::Wrote(_))), "{steps:?}");
198        assert!(!steps.contains(&WriteStep::Renamed), "{steps:?} after {error}");
199        assert_eq!(fs::read_to_string(path.join("inside.toml")).expect("read"), "kept\n", "the old state is intact");
200        let names: Vec<String> = fs::read_dir(&dir)
201            .expect("list")
202            .map(|entry| entry.expect("entry").file_name().display().to_string())
203            .collect();
204        assert_eq!(names, ["occupied"], "the temporary file was removed");
205        fs::remove_dir_all(&dir).expect("clean");
206    }
207
208    #[cfg(unix)]
209    #[test]
210    fn the_file_keeps_the_permissions_it_had() {
211        use std::os::unix::fs::PermissionsExt as _;
212
213        let dir = temp_dir("mode");
214        let path = dir.join("secrets.toml");
215        fs::write(&path, "token = \"old\"\n").expect("first version");
216        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("private");
217        atomic_write(&path, b"token = \"new\"\n").expect("replace");
218        let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777;
219        assert_eq!(mode, 0o600, "a private file stays private after it is replaced");
220        fs::remove_dir_all(&dir).expect("clean");
221    }
222
223    #[cfg(unix)]
224    #[test]
225    fn a_symbolic_link_is_written_through_and_stays_a_link() {
226        use std::os::unix::fs::{PermissionsExt as _, symlink};
227
228        let dir = temp_dir("link");
229        let dotfiles = dir.join("dotfiles");
230        let config = dir.join("config");
231        fs::create_dir_all(&dotfiles).expect("dotfiles");
232        fs::create_dir_all(&config).expect("config");
233        let target = dotfiles.join("settings.toml");
234        fs::write(&target, "theme = \"old\"\n").expect("the linked file");
235        fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("private");
236        // A relative link, as a dotfiles manager makes them, and a link to that link.
237        let link = config.join("settings.toml");
238        symlink("../dotfiles/settings.toml", &link).expect("link");
239        let second = config.join("again.toml");
240        symlink(&link, &second).expect("link to the link");
241
242        let mut steps = Vec::new();
243        atomic_write_reporting(&second, b"theme = \"new\"\n", |step| steps.push(step)).expect("write");
244        assert!(fs::symlink_metadata(&link).expect("link").file_type().is_symlink(), "the link is still a link");
245        assert!(fs::symlink_metadata(&second).expect("link").file_type().is_symlink());
246        assert_eq!(fs::read_to_string(&target).expect("read"), "theme = \"new\"\n", "the file it points at changed");
247        let mode = fs::metadata(&target).expect("metadata").permissions().mode() & 0o777;
248        assert_eq!(mode, 0o600, "the linked file keeps its permissions");
249        let Some(WriteStep::Wrote(temporary)) = steps.first() else { panic!("{steps:?}") };
250        let folder = fs::canonicalize(temporary.parent().expect("a folder")).expect("folder");
251        assert_eq!(folder, fs::canonicalize(&dotfiles).expect("dotfiles"), "renamed within the linked file's folder");
252        assert_eq!(fs::read_dir(&dotfiles).expect("list").count(), 1, "nothing left next to the file");
253        assert_eq!(fs::read_dir(&config).expect("list").count(), 2, "only the two links");
254        fs::remove_dir_all(&dir).expect("clean");
255    }
256
257    #[cfg(unix)]
258    #[test]
259    fn a_link_to_a_missing_file_creates_it_and_a_loop_writes_nothing() {
260        use std::os::unix::fs::symlink;
261
262        let dir = temp_dir("dangling");
263        let link = dir.join("settings.toml");
264        symlink("real.toml", &link).expect("link");
265        atomic_write(&link, b"x = 1\n").expect("write");
266        assert!(fs::symlink_metadata(&link).expect("link").file_type().is_symlink());
267        assert_eq!(fs::read_to_string(dir.join("real.toml")).expect("created"), "x = 1\n");
268
269        let a = dir.join("a.toml");
270        let b = dir.join("b.toml");
271        symlink("b.toml", &a).expect("a");
272        symlink("a.toml", &b).expect("b");
273        let error = atomic_write(&a, b"y = 2\n").expect_err("a loop has no file at its end");
274        assert!(error.to_string().contains("symbolic links"), "{error}");
275        assert_eq!(fs::read_dir(&dir).expect("list").count(), 4, "nothing was written");
276        fs::remove_dir_all(&dir).expect("clean");
277    }
278
279    #[test]
280    fn a_missing_directory_is_an_error_and_writes_nothing() {
281        let dir = temp_dir("missing");
282        let path = dir.join("absent").join("tree.toml");
283        let error = atomic_write(&path, b"x\n").expect_err("no directory to write in");
284        assert_eq!(error.kind(), io::ErrorKind::NotFound);
285        assert!(!path.exists());
286        fs::remove_dir_all(&dir).expect("clean");
287    }
288
289    #[test]
290    fn two_writes_at_once_use_two_temporary_files() {
291        let dir = temp_dir("parallel");
292        let path = dir.join("tree.toml");
293        let mut first = None;
294        atomic_write_reporting(&path, b"a\n", |step| {
295            if let WriteStep::Wrote(temporary) = step {
296                first = Some(temporary);
297            }
298        })
299        .expect("first");
300        let mut second = None;
301        atomic_write_reporting(&path, b"b\n", |step| {
302            if let WriteStep::Wrote(temporary) = step {
303                second = Some(temporary);
304            }
305        })
306        .expect("second");
307        assert_ne!(first.expect("first name"), second.expect("second name"));
308        fs::remove_dir_all(&dir).expect("clean");
309    }
310}