tzcompile/fs/atomic_write.rs
1//! Atomic file replacement: write to a temporary file in the destination directory, then
2//! `rename` it into place.
3//!
4//! Rename within a directory is atomic on POSIX, so a reader never observes a half-written
5//! TZif file, and an interrupted run leaves either the old file or nothing — never a
6//! truncated one. The temp file is created in the *same directory* as the target so the
7//! rename stays within one filesystem (cross-device renames fail).
8//!
9//! **The durability contract (T17.4), three layers — claimed exactly, not more:**
10//! 1. **content fsync** — the temp file's bytes are `sync_all`'d *before* the publish, so the
11//! published name never points at unflushed content.
12//! 2. **atomic publish** — `hard_link` (exclusive create, default) or `rename` (`--force`) makes the
13//! name appear (or replace) in one step; a reader/crash never sees a partial file.
14//! 3. **directory-entry fsync** — when `durable` is set (the *install* path, not ephemeral scratch),
15//! the **parent directory** is fsync'd after the publish, so the new directory entry itself
16//! survives a crash. Without this layer a crash after the rename can still lose the entry on some
17//! filesystems (the file content was durable, but the link to it was not).
18//!
19//! **What this does NOT claim (`RISK.INSTALL.1`):** a **whole-tree** crash-atomic install. Each file
20//! is durably published, but a crash *mid-run* can leave some files published and others not — there is
21//! no tree-level transaction. Directory-entry fsync is a **Unix** guarantee (opening a directory and
22//! `sync_all`-ing it); on non-Unix it is a documented no-op, so the durability claim is Unix-scoped.
23
24use std::fs::{File, OpenOptions};
25use std::io::{ErrorKind, Write};
26use std::path::Path;
27use std::sync::atomic::{AtomicU64, Ordering};
28
29use crate::error::{io_at, Error, Result};
30
31/// fsync a directory so a just-published entry within it is crash-durable (T17.4 layer 3). On Unix,
32/// opening the directory read-only and `sync_all`-ing it flushes the directory inode. On non-Unix this
33/// is a no-op (directory fsync is not portably available) — durability is therefore a Unix claim, stated
34/// honestly rather than faked.
35#[cfg(unix)]
36pub(crate) fn fsync_dir(dir: &Path) -> Result<()> {
37 io_at(dir, File::open(dir).and_then(|d| d.sync_all()))
38}
39#[cfg(not(unix))]
40pub(crate) fn fsync_dir(_dir: &Path) -> Result<()> {
41 Ok(())
42}
43
44/// Per-process counter so concurrent writes within one process get distinct temp names.
45static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
46
47/// Atomically write `bytes` to `target`.
48///
49/// We always write the content to a temp file in the *same directory* (so the final step
50/// stays within one filesystem) and then publish it atomically:
51///
52/// * `overwrite = false` (the default): publish with `hard_link(temp → target)`, which the
53/// kernel performs as an **atomic exclusive create** — it fails with `EEXIST` if `target`
54/// already exists. This closes the check-then-act (TOCTOU) race that a separate
55/// `exists()` test would leave open: there is no window between testing and creating.
56/// * `overwrite = true` (`--force`): publish with `rename`, which atomically replaces any
57/// existing file.
58///
59/// Either way a reader never observes a partially-written file. When `durable` is set, the parent
60/// directory is fsync'd after the publish (T17.4 layer 3) so the new directory *entry* is crash-durable
61/// — the install path sets it; ephemeral scratch writes (e.g. the release-diff zdump tree, the `compare`
62/// oracle tree) pass `false` to skip the (pointless, for soon-deleted files) directory fsync.
63pub fn write_atomic(target: &Path, bytes: &[u8], overwrite: bool, durable: bool) -> Result<()> {
64 let dir = target
65 .parent()
66 .ok_or_else(|| Error::config("target has no parent directory"))?;
67 let file_name = target
68 .file_name()
69 .and_then(|s| s.to_str())
70 .ok_or_else(|| Error::config("target has no file name"))?;
71
72 // Unique temp name: PID + a monotonic per-process counter. `create_new` guarantees we
73 // never clobber a stray temp file (a crash leftover surfaces as an error, not a silent
74 // truncate).
75 let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
76 let tmp = dir.join(format!(".{file_name}.tmp.{}.{seq}", std::process::id()));
77
78 let mut f: File = io_at(
79 &tmp,
80 OpenOptions::new().write(true).create_new(true).open(&tmp),
81 )?;
82 io_at(&tmp, f.write_all(bytes))?;
83 io_at(&tmp, f.sync_all())?;
84 drop(f);
85
86 let result = if overwrite {
87 std::fs::rename(&tmp, target).map_err(|e| Error::io(target, e))
88 } else {
89 // Atomic exclusive publish. `hard_link` fails if `target` exists, with no race.
90 match std::fs::hard_link(&tmp, target) {
91 Ok(()) => Ok(()),
92 Err(e) if e.kind() == ErrorKind::AlreadyExists => Err(Error::config(format!(
93 "{} already exists (use --force to overwrite)",
94 target.display()
95 ))),
96 Err(e) => Err(Error::io(target, e)),
97 }
98 };
99
100 // In the hard_link path the content now lives at both `tmp` and `target`; remove `tmp`.
101 // In the rename path `tmp` is already gone. Best-effort cleanup either way.
102 let _ = std::fs::remove_file(&tmp);
103 result?;
104
105 // Layer 3: make the directory ENTRY durable (the content was fsync'd pre-publish). Only on the
106 // install path — for ephemeral scratch this would be wasted fsyncs on files about to be deleted.
107 if durable {
108 fsync_dir(dir)?;
109 }
110 Ok(())
111}