Skip to main content

sqry_core/persistence/
atomic_write.rs

1//! Atomic file-write helper for sqry's on-disk persistence paths.
2//!
3//! # Overview
4//!
5//! [`atomic_write_bytes`] implements the canonical "write to tempfile in the
6//! same directory, then rename" pattern. This gives callers a **best-effort
7//! atomic replace** on any POSIX-compliant filesystem: readers either see the
8//! old content or the new content, never a partial write.
9//!
10//! ## Protocol
11//!
12//! 1. Reject if `target_path` itself is an existing **symlink** — we refuse to
13//!    follow or replace symlinks silently.
14//! 2. Reject if `target_path`'s **parent directory** resolves to a symlink
15//!    (canonicalize + re-stat check) — avoids TOCTOU races.
16//! 3. Create a named tempfile **inside the same directory** as the target.
17//!    Same-directory placement is critical: `rename(2)` is only guaranteed
18//!    atomic within a single filesystem/device boundary.
19//! 4. Write all bytes, then `fsync` the file to flush kernel page-cache to
20//!    durable storage.
21//! 5. Close the tempfile handle (implicit on drop after `persist`).
22//! 6. `rename(temp, target)` — atomic on POSIX.
23//! 7. On **Unix only**: open the parent directory and call `fsync` on its file
24//!    descriptor to flush the directory entry pointing at the new inode.
25//!    This step ensures the rename itself survives a crash/power-loss event.
26//!    On **Windows** and other non-Unix targets, the parent-directory fsync is
27//!    a **no-op** (Windows rename semantics differ; the OS provides sufficient
28//!    durability guarantees for the scenarios sqry targets on that platform).
29//!
30//! ## Error semantics
31//!
32//! On any error the tempfile is removed before returning. The target path is
33//! never modified unless the rename succeeds.
34
35use std::io::{self, Write as _};
36use std::path::Path;
37
38use tempfile::NamedTempFile;
39
40/// Write `bytes` to `target_path` atomically.
41///
42/// # Errors
43///
44/// Returns `Err` if:
45/// - `target_path` exists and is a **symlink** (we will not follow or replace
46///   symlinks).
47/// - The **parent directory** of `target_path` is itself a symlink (detected
48///   after canonicalization).
49/// - The parent directory does not exist (caller's responsibility).
50/// - Any I/O error occurs during tempfile creation, writing, syncing, or
51///   renaming.
52///
53/// On error the target file is left unmodified. Any tempfile created during
54/// the operation is cleaned up before returning the error.
55///
56/// # Platform notes
57///
58/// - **Unix**: `fsync(2)` is called on both the tempfile and, after the rename,
59///   on the parent directory file descriptor. This makes the rename durable
60///   against power loss.
61/// - **Windows / other non-Unix**: Parent-directory fsync is a no-op. The
62///   tempfile is still written and renamed atomically via the OS rename call.
63pub fn atomic_write_bytes(target_path: &Path, bytes: &[u8]) -> io::Result<()> {
64    // ── Step 1: Reject if target itself is a symlink ──────────────────────
65    //
66    // Use `symlink_metadata` (lstat), which does NOT follow symlinks, so we
67    // can detect the symlink before any dereferencing takes place.
68    if let Ok(meta) = std::fs::symlink_metadata(target_path)
69        && meta.file_type().is_symlink()
70    {
71        return Err(io::Error::new(
72            io::ErrorKind::InvalidInput,
73            format!(
74                "atomic_write_bytes: target path is a symlink and will not be followed: {}",
75                target_path.display()
76            ),
77        ));
78    }
79
80    // ── Step 2: Resolve parent and reject if it is a symlink ─────────────
81    //
82    // We canonicalize the parent to resolve any `..` components, then
83    // re-check with `symlink_metadata` to detect the case where the final
84    // component of the canonical path is itself a symlink. On Linux/macOS,
85    // `canonicalize` follows symlinks at every component, so the result is
86    // always a real path — but `symlink_metadata` on the canonical result
87    // tells us whether the canonical path itself is a symlink (which would
88    // mean the whole directory chain was re-routed). In practice the most
89    // important case is: the raw parent as supplied by the caller is a
90    // symlink (e.g. `/tmp/link -> /real/dir`). Canonicalize resolves it and
91    // the re-check on the *raw* parent catches that.
92    let parent = target_path.parent().ok_or_else(|| {
93        io::Error::new(
94            io::ErrorKind::InvalidInput,
95            format!(
96                "atomic_write_bytes: target path has no parent directory: {}",
97                target_path.display()
98            ),
99        )
100    })?;
101
102    // Reject if the raw (non-canonicalized) parent is a symlink.
103    let raw_parent_meta = std::fs::symlink_metadata(parent).map_err(|e| {
104        io::Error::new(
105            e.kind(),
106            format!(
107                "atomic_write_bytes: cannot stat parent directory '{}': {e}",
108                parent.display()
109            ),
110        )
111    })?;
112    if raw_parent_meta.file_type().is_symlink() {
113        return Err(io::Error::new(
114            io::ErrorKind::InvalidInput,
115            format!(
116                "atomic_write_bytes: parent directory is a symlink and will not be followed: {}",
117                parent.display()
118            ),
119        ));
120    }
121
122    // Also canonicalize and verify the canonical parent is a real directory.
123    let canonical_parent = parent.canonicalize().map_err(|e| {
124        io::Error::new(
125            e.kind(),
126            format!(
127                "atomic_write_bytes: cannot canonicalize parent directory '{}': {e}",
128                parent.display()
129            ),
130        )
131    })?;
132    // After canonicalization the result must be a directory, not a symlink.
133    let canon_meta = std::fs::symlink_metadata(&canonical_parent).map_err(|e| {
134        io::Error::new(
135            e.kind(),
136            format!(
137                "atomic_write_bytes: cannot stat canonical parent '{}': {e}",
138                canonical_parent.display()
139            ),
140        )
141    })?;
142    if !canon_meta.is_dir() {
143        return Err(io::Error::new(
144            io::ErrorKind::NotADirectory,
145            format!(
146                "atomic_write_bytes: canonical parent path is not a directory: {}",
147                canonical_parent.display()
148            ),
149        ));
150    }
151
152    // ── Step 3: Create named tempfile in the same directory ───────────────
153    //
154    // `NamedTempFile::new_in` creates the temp file in the specified
155    // directory, guaranteeing same-device placement for the rename below.
156    let mut tmp = NamedTempFile::new_in(parent).map_err(|e| {
157        io::Error::new(
158            e.kind(),
159            format!(
160                "atomic_write_bytes: failed to create tempfile in '{}': {e}",
161                parent.display()
162            ),
163        )
164    })?;
165
166    // ── Step 4: Write bytes and fsync the file ────────────────────────────
167    if let Err(write_err) = tmp.write_all(bytes) {
168        // Explicit cleanup — NamedTempFile removes on drop, but be explicit
169        // about the error context.
170        let _ = tmp.close();
171        return Err(io::Error::new(
172            write_err.kind(),
173            format!("atomic_write_bytes: write failed: {write_err}"),
174        ));
175    }
176
177    if let Err(sync_err) = tmp.as_file().sync_all() {
178        let _ = tmp.close();
179        return Err(io::Error::new(
180            sync_err.kind(),
181            format!("atomic_write_bytes: fsync(file) failed: {sync_err}"),
182        ));
183    }
184
185    // ── Step 5: rename(temp, target) ──────────────────────────────────────
186    //
187    // `NamedTempFile::persist` calls `rename(2)` (or equivalent). On
188    // failure it returns the original `NamedTempFile` back so we can close
189    // (and thus delete) it cleanly.
190    tmp.persist(target_path).map_err(|persist_err| {
191        // `persist_err.file` is the `NamedTempFile` that was NOT renamed.
192        // Dropping it (via close) removes the tempfile.
193        let _ = persist_err.file.close();
194        io::Error::new(
195            persist_err.error.kind(),
196            format!(
197                "atomic_write_bytes: rename to '{}' failed: {}",
198                target_path.display(),
199                persist_err.error
200            ),
201        )
202    })?;
203
204    // ── Step 6: fsync the parent directory (Unix only) ────────────────────
205    //
206    // On Unix, fsyncing the parent directory ensures the rename (directory
207    // entry update) is also flushed to durable storage. Without this step a
208    // crash immediately after rename could leave the directory still pointing
209    // to the old inode on some filesystems (e.g., ext3 without journal).
210    //
211    // On Windows this is a no-op — the OS handles directory-entry durability
212    // differently and there is no straightforward equivalent with the same
213    // safety properties.
214    fsync_parent_dir(&canonical_parent)?;
215
216    Ok(())
217}
218
219/// Fsync the parent directory on Unix; no-op on other platforms.
220///
221/// Opens the directory read-only and calls `sync_all` on the resulting file
222/// descriptor. This flushes the directory block containing the updated entry
223/// created by the preceding `rename` call to durable storage.
224#[cfg(unix)]
225fn fsync_parent_dir(canonical_parent: &Path) -> io::Result<()> {
226    use std::fs::OpenOptions;
227    let dir_file = OpenOptions::new()
228        .read(true)
229        .open(canonical_parent)
230        .map_err(|e| {
231            io::Error::new(
232                e.kind(),
233                format!(
234                    "atomic_write_bytes: cannot open parent dir for fsync '{}': {e}",
235                    canonical_parent.display()
236                ),
237            )
238        })?;
239    dir_file.sync_all().map_err(|e| {
240        io::Error::new(
241            e.kind(),
242            format!(
243                "atomic_write_bytes: fsync(parent_dir) failed for '{}': {e}",
244                canonical_parent.display()
245            ),
246        )
247    })
248}
249
250/// No-op parent-directory fsync on non-Unix platforms.
251///
252/// On Windows the OS provides sufficient rename-durability guarantees for
253/// sqry's persistence use-cases. This function intentionally does nothing.
254#[cfg(not(unix))]
255#[allow(clippy::unnecessary_wraps)]
256fn fsync_parent_dir(_canonical_parent: &Path) -> io::Result<()> {
257    Ok(())
258}
259
260// ─────────────────────────────────────────────────────────────────────────────
261// Tests
262// ─────────────────────────────────────────────────────────────────────────────
263
264#[cfg(test)]
265mod tests {
266    use std::fs;
267
268    use tempfile::TempDir;
269
270    use super::*;
271
272    // ── Helper ────────────────────────────────────────────────────────────────
273
274    /// Create a temporary directory that is automatically removed on drop.
275    fn tmp_dir() -> TempDir {
276        TempDir::new().expect("TempDir::new failed")
277    }
278
279    // ── Test: happy path ─────────────────────────────────────────────────────
280
281    /// Write bytes to a non-existing target, verify content, verify no temp
282    /// files are left behind in the parent directory.
283    #[test]
284    fn atomic_write_happy_path() {
285        let dir = tmp_dir();
286        let target = dir.path().join("output.bin");
287        let content = b"hello atomic world";
288
289        // Target must not pre-exist.
290        assert!(!target.exists(), "pre-condition: target must not exist");
291
292        atomic_write_bytes(&target, content).expect("atomic_write_bytes failed");
293
294        // Content must match.
295        let read_back = fs::read(&target).expect("read back failed");
296        assert_eq!(read_back, content, "content mismatch after atomic write");
297
298        // No leftover tempfiles in parent.
299        let entries: Vec<_> = fs::read_dir(dir.path())
300            .expect("read_dir failed")
301            .filter_map(|e| e.ok())
302            .collect();
303        // Only the target file should exist.
304        assert_eq!(
305            entries.len(),
306            1,
307            "unexpected files left in parent dir: {entries:?}"
308        );
309        assert_eq!(
310            entries[0].path(),
311            target,
312            "the only file in parent should be the target"
313        );
314    }
315
316    // ── Test: overwrite existing regular file ────────────────────────────────
317
318    /// Verify that an existing regular file is replaced with new content.
319    #[test]
320    fn atomic_write_overwrites_existing_regular_file() {
321        let dir = tmp_dir();
322        let target = dir.path().join("existing.txt");
323        let old_content = b"old content";
324        let new_content = b"new content -- replaced atomically";
325
326        // Write old content directly (not through our helper).
327        fs::write(&target, old_content).expect("pre-write failed");
328        assert!(target.is_file(), "pre-condition: target is a regular file");
329
330        atomic_write_bytes(&target, new_content).expect("atomic_write_bytes failed on overwrite");
331
332        let read_back = fs::read(&target).expect("read back failed");
333        assert_eq!(read_back, new_content, "content should have been replaced");
334    }
335
336    // ── Test: symlink target rejection ───────────────────────────────────────
337
338    /// If the target path is itself a symlink, the call must return Err
339    /// without modifying the symlink or its destination.
340    #[cfg(unix)]
341    #[test]
342    fn atomic_write_rejects_symlink_target() {
343        let dir = tmp_dir();
344        let real_file = dir.path().join("real.txt");
345        let symlink_target = dir.path().join("link.txt");
346
347        // Create a real file and a symlink pointing to it.
348        fs::write(&real_file, b"original").expect("pre-write failed");
349        std::os::unix::fs::symlink(&real_file, &symlink_target).expect("symlink creation failed");
350
351        assert!(
352            symlink_target
353                .symlink_metadata()
354                .map(|m| m.file_type().is_symlink())
355                .unwrap_or(false),
356            "pre-condition: symlink_target must be a symlink"
357        );
358
359        let result = atomic_write_bytes(&symlink_target, b"new bytes");
360        assert!(result.is_err(), "expected Err for symlink target, got Ok");
361
362        // The real file behind the symlink must remain unchanged.
363        let real_content = fs::read(&real_file).expect("read real_file failed");
364        assert_eq!(real_content, b"original", "real file must not be modified");
365
366        // The symlink itself must still exist and still be a symlink.
367        let lmeta = symlink_target
368            .symlink_metadata()
369            .expect("symlink should still exist");
370        assert!(
371            lmeta.file_type().is_symlink(),
372            "symlink must remain a symlink"
373        );
374    }
375
376    // ── Test: symlink parent rejection ───────────────────────────────────────
377
378    /// If the parent directory of the target is a symlink, the call must
379    /// return Err. The target must not be created.
380    ///
381    /// Only compiled on Unix because creating directory symlinks on Windows
382    /// requires elevated privileges.
383    #[cfg(unix)]
384    #[test]
385    fn atomic_write_rejects_symlink_parent() {
386        let dir = tmp_dir();
387        // Create a real subdirectory and a symlink pointing to it.
388        let real_subdir = dir.path().join("real_subdir");
389        let link_subdir = dir.path().join("link_subdir");
390        fs::create_dir(&real_subdir).expect("create real_subdir failed");
391        std::os::unix::fs::symlink(&real_subdir, &link_subdir)
392            .expect("symlink to directory failed");
393
394        // The target's parent will be the symlinked directory.
395        let target = link_subdir.join("output.txt");
396
397        let result = atomic_write_bytes(&target, b"should not be written");
398        assert!(result.is_err(), "expected Err for symlink parent, got Ok");
399
400        // No file should have been created under real_subdir or link_subdir.
401        assert!(
402            !real_subdir.join("output.txt").exists(),
403            "file must not be created in real_subdir"
404        );
405    }
406
407    // ── Test: temp cleanup on rename failure ─────────────────────────────────
408
409    /// Induce a rename failure by pointing the target inside a read-only
410    /// directory (on Unix). Verify that:
411    ///   1. The call returns Err.
412    ///   2. No tempfile is left behind in the (writable) temp source dir.
413    ///
414    /// This test is Unix-only because chmod on directories behaves differently
415    /// on Windows.
416    #[cfg(unix)]
417    #[test]
418    fn atomic_write_temp_cleanup_on_failure() {
419        use std::os::unix::fs::PermissionsExt as _;
420
421        let dir = tmp_dir();
422
423        // Create a subdirectory that will be made read-only so rename into it
424        // fails. The tempfile is created in a *different* writable dir.
425        //
426        // Strategy: create the target inside a read-only dir; the tempfile
427        // will be created in `writable_dir` (which the target path resolves
428        // its parent from). We do this by having the target path *literally*
429        // inside a read-only dir so that `parent()` returns that dir.
430        let readonly_dir = dir.path().join("readonly");
431        fs::create_dir(&readonly_dir).expect("create readonly_dir failed");
432
433        // Make it read-only *before* we try to write so rename fails.
434        let mut perms = fs::metadata(&readonly_dir)
435            .expect("stat readonly_dir")
436            .permissions();
437        perms.set_mode(0o500); // r-x------
438        fs::set_permissions(&readonly_dir, perms).expect("chmod failed");
439
440        let target = readonly_dir.join("output.txt");
441        let result = atomic_write_bytes(&target, b"data");
442        assert!(
443            result.is_err(),
444            "expected Err when rename into read-only dir"
445        );
446
447        // Restore permissions so TempDir cleanup can remove the directory.
448        let mut perms = fs::metadata(&readonly_dir)
449            .expect("stat readonly_dir")
450            .permissions();
451        perms.set_mode(0o700);
452        fs::set_permissions(&readonly_dir, perms).ok();
453
454        // No tempfile left behind in readonly_dir.
455        let remaining: Vec<_> = fs::read_dir(&readonly_dir)
456            .expect("read_dir readonly_dir")
457            .filter_map(|e| e.ok())
458            .collect();
459        assert!(
460            remaining.is_empty(),
461            "no tempfile should remain after failure: {remaining:?}"
462        );
463    }
464}