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    let (parent, canonical_parent) = validate_atomic_write_target(target_path)?;
65    write_and_persist_tempfile(&parent, target_path, bytes)?;
66    fsync_parent_dir(&canonical_parent)?;
67
68    Ok(())
69}
70
71fn validate_atomic_write_target(
72    target_path: &Path,
73) -> io::Result<(std::path::PathBuf, std::path::PathBuf)> {
74    reject_target_symlink(target_path)?;
75
76    let parent = target_path.parent().ok_or_else(|| {
77        io::Error::new(
78            io::ErrorKind::InvalidInput,
79            format!(
80                "atomic_write_bytes: target path has no parent directory: {}",
81                target_path.display()
82            ),
83        )
84    })?;
85    reject_parent_symlink(parent)?;
86    let canonical_parent = canonical_parent_dir(parent)?;
87
88    Ok((parent.to_path_buf(), canonical_parent))
89}
90
91fn reject_target_symlink(target_path: &Path) -> io::Result<()> {
92    if let Ok(meta) = std::fs::symlink_metadata(target_path)
93        && meta.file_type().is_symlink()
94    {
95        return Err(io::Error::new(
96            io::ErrorKind::InvalidInput,
97            format!(
98                "atomic_write_bytes: target path is a symlink and will not be followed: {}",
99                target_path.display()
100            ),
101        ));
102    }
103    Ok(())
104}
105
106fn reject_parent_symlink(parent: &Path) -> io::Result<()> {
107    let raw_parent_meta = std::fs::symlink_metadata(parent).map_err(|e| {
108        io::Error::new(
109            e.kind(),
110            format!(
111                "atomic_write_bytes: cannot stat parent directory '{}': {e}",
112                parent.display()
113            ),
114        )
115    })?;
116    if raw_parent_meta.file_type().is_symlink() {
117        return Err(io::Error::new(
118            io::ErrorKind::InvalidInput,
119            format!(
120                "atomic_write_bytes: parent directory is a symlink and will not be followed: {}",
121                parent.display()
122            ),
123        ));
124    }
125    Ok(())
126}
127
128fn canonical_parent_dir(parent: &Path) -> io::Result<std::path::PathBuf> {
129    let canonical_parent = parent.canonicalize().map_err(|e| {
130        io::Error::new(
131            e.kind(),
132            format!(
133                "atomic_write_bytes: cannot canonicalize parent directory '{}': {e}",
134                parent.display()
135            ),
136        )
137    })?;
138    // After canonicalization the result must be a directory, not a symlink.
139    let canon_meta = std::fs::symlink_metadata(&canonical_parent).map_err(|e| {
140        io::Error::new(
141            e.kind(),
142            format!(
143                "atomic_write_bytes: cannot stat canonical parent '{}': {e}",
144                canonical_parent.display()
145            ),
146        )
147    })?;
148    if !canon_meta.is_dir() {
149        return Err(io::Error::new(
150            io::ErrorKind::NotADirectory,
151            format!(
152                "atomic_write_bytes: canonical parent path is not a directory: {}",
153                canonical_parent.display()
154            ),
155        ));
156    }
157    Ok(canonical_parent)
158}
159
160fn write_and_persist_tempfile(parent: &Path, target_path: &Path, bytes: &[u8]) -> io::Result<()> {
161    let mut tmp = NamedTempFile::new_in(parent).map_err(|e| {
162        io::Error::new(
163            e.kind(),
164            format!(
165                "atomic_write_bytes: failed to create tempfile in '{}': {e}",
166                parent.display()
167            ),
168        )
169    })?;
170
171    if let Err(write_err) = tmp.write_all(bytes) {
172        let _ = tmp.close();
173        return Err(io::Error::new(
174            write_err.kind(),
175            format!("atomic_write_bytes: write failed: {write_err}"),
176        ));
177    }
178
179    if let Err(sync_err) = tmp.as_file().sync_all() {
180        let _ = tmp.close();
181        return Err(io::Error::new(
182            sync_err.kind(),
183            format!("atomic_write_bytes: fsync(file) failed: {sync_err}"),
184        ));
185    }
186
187    // ── Step 5: rename(temp, target) ──────────────────────────────────────
188    //
189    // `NamedTempFile::persist` calls `rename(2)` (or equivalent). On
190    // failure it returns the original `NamedTempFile` back so we can close
191    // (and thus delete) it cleanly.
192    tmp.persist(target_path).map_err(|persist_err| {
193        // `persist_err.file` is the `NamedTempFile` that was NOT renamed.
194        // Dropping it (via close) removes the tempfile.
195        let _ = persist_err.file.close();
196        io::Error::new(
197            persist_err.error.kind(),
198            format!(
199                "atomic_write_bytes: rename to '{}' failed: {}",
200                target_path.display(),
201                persist_err.error
202            ),
203        )
204    })?;
205    Ok(())
206}
207
208/// Fsync the parent directory on Unix; no-op on other platforms.
209///
210/// Opens the directory read-only and calls `sync_all` on the resulting file
211/// descriptor. This flushes the directory block containing the updated entry
212/// created by the preceding `rename` call to durable storage.
213#[cfg(unix)]
214fn fsync_parent_dir(canonical_parent: &Path) -> io::Result<()> {
215    use std::fs::OpenOptions;
216    let dir_file = OpenOptions::new()
217        .read(true)
218        .open(canonical_parent)
219        .map_err(|e| {
220            io::Error::new(
221                e.kind(),
222                format!(
223                    "atomic_write_bytes: cannot open parent dir for fsync '{}': {e}",
224                    canonical_parent.display()
225                ),
226            )
227        })?;
228    dir_file.sync_all().map_err(|e| {
229        io::Error::new(
230            e.kind(),
231            format!(
232                "atomic_write_bytes: fsync(parent_dir) failed for '{}': {e}",
233                canonical_parent.display()
234            ),
235        )
236    })
237}
238
239/// No-op parent-directory fsync on non-Unix platforms.
240///
241/// On Windows the OS provides sufficient rename-durability guarantees for
242/// sqry's persistence use-cases. This function intentionally does nothing.
243#[cfg(not(unix))]
244#[allow(clippy::unnecessary_wraps)]
245fn fsync_parent_dir(_canonical_parent: &Path) -> io::Result<()> {
246    Ok(())
247}
248
249// ─────────────────────────────────────────────────────────────────────────────
250// Tests
251// ─────────────────────────────────────────────────────────────────────────────
252
253#[cfg(test)]
254mod tests {
255    use std::fs;
256
257    use tempfile::TempDir;
258
259    use super::*;
260
261    // ── Helper ────────────────────────────────────────────────────────────────
262
263    /// Create a temporary directory that is automatically removed on drop.
264    fn tmp_dir() -> TempDir {
265        TempDir::new().expect("TempDir::new failed")
266    }
267
268    // ── Test: happy path ─────────────────────────────────────────────────────
269
270    /// Write bytes to a non-existing target, verify content, verify no temp
271    /// files are left behind in the parent directory.
272    #[test]
273    fn atomic_write_happy_path() {
274        let dir = tmp_dir();
275        let target = dir.path().join("output.bin");
276        let content = b"hello atomic world";
277
278        // Target must not pre-exist.
279        assert!(!target.exists(), "pre-condition: target must not exist");
280
281        atomic_write_bytes(&target, content).expect("atomic_write_bytes failed");
282
283        // Content must match.
284        let read_back = fs::read(&target).expect("read back failed");
285        assert_eq!(read_back, content, "content mismatch after atomic write");
286
287        // No leftover tempfiles in parent.
288        let entries: Vec<_> = fs::read_dir(dir.path())
289            .expect("read_dir failed")
290            .filter_map(|e| e.ok())
291            .collect();
292        // Only the target file should exist.
293        assert_eq!(
294            entries.len(),
295            1,
296            "unexpected files left in parent dir: {entries:?}"
297        );
298        assert_eq!(
299            entries[0].path(),
300            target,
301            "the only file in parent should be the target"
302        );
303    }
304
305    // ── Test: overwrite existing regular file ────────────────────────────────
306
307    /// Verify that an existing regular file is replaced with new content.
308    #[test]
309    fn atomic_write_overwrites_existing_regular_file() {
310        let dir = tmp_dir();
311        let target = dir.path().join("existing.txt");
312        let old_content = b"old content";
313        let new_content = b"new content -- replaced atomically";
314
315        // Write old content directly (not through our helper).
316        fs::write(&target, old_content).expect("pre-write failed");
317        assert!(target.is_file(), "pre-condition: target is a regular file");
318
319        atomic_write_bytes(&target, new_content).expect("atomic_write_bytes failed on overwrite");
320
321        let read_back = fs::read(&target).expect("read back failed");
322        assert_eq!(read_back, new_content, "content should have been replaced");
323    }
324
325    // ── Test: symlink target rejection ───────────────────────────────────────
326
327    /// If the target path is itself a symlink, the call must return Err
328    /// without modifying the symlink or its destination.
329    #[cfg(unix)]
330    #[test]
331    fn atomic_write_rejects_symlink_target() {
332        let dir = tmp_dir();
333        let real_file = dir.path().join("real.txt");
334        let symlink_target = dir.path().join("link.txt");
335
336        // Create a real file and a symlink pointing to it.
337        fs::write(&real_file, b"original").expect("pre-write failed");
338        std::os::unix::fs::symlink(&real_file, &symlink_target).expect("symlink creation failed");
339
340        assert!(
341            symlink_target
342                .symlink_metadata()
343                .map(|m| m.file_type().is_symlink())
344                .unwrap_or(false),
345            "pre-condition: symlink_target must be a symlink"
346        );
347
348        let result = atomic_write_bytes(&symlink_target, b"new bytes");
349        assert!(result.is_err(), "expected Err for symlink target, got Ok");
350
351        // The real file behind the symlink must remain unchanged.
352        let real_content = fs::read(&real_file).expect("read real_file failed");
353        assert_eq!(real_content, b"original", "real file must not be modified");
354
355        // The symlink itself must still exist and still be a symlink.
356        let lmeta = symlink_target
357            .symlink_metadata()
358            .expect("symlink should still exist");
359        assert!(
360            lmeta.file_type().is_symlink(),
361            "symlink must remain a symlink"
362        );
363    }
364
365    // ── Test: symlink parent rejection ───────────────────────────────────────
366
367    /// If the parent directory of the target is a symlink, the call must
368    /// return Err. The target must not be created.
369    ///
370    /// Only compiled on Unix because creating directory symlinks on Windows
371    /// requires elevated privileges.
372    #[cfg(unix)]
373    #[test]
374    fn atomic_write_rejects_symlink_parent() {
375        let dir = tmp_dir();
376        // Create a real subdirectory and a symlink pointing to it.
377        let real_subdir = dir.path().join("real_subdir");
378        let link_subdir = dir.path().join("link_subdir");
379        fs::create_dir(&real_subdir).expect("create real_subdir failed");
380        std::os::unix::fs::symlink(&real_subdir, &link_subdir)
381            .expect("symlink to directory failed");
382
383        // The target's parent will be the symlinked directory.
384        let target = link_subdir.join("output.txt");
385
386        let result = atomic_write_bytes(&target, b"should not be written");
387        assert!(result.is_err(), "expected Err for symlink parent, got Ok");
388
389        // No file should have been created under real_subdir or link_subdir.
390        assert!(
391            !real_subdir.join("output.txt").exists(),
392            "file must not be created in real_subdir"
393        );
394    }
395
396    // ── Test: temp cleanup on rename failure ─────────────────────────────────
397
398    /// Induce a rename failure by pointing the target inside a read-only
399    /// directory (on Unix). Verify that:
400    ///   1. The call returns Err.
401    ///   2. No tempfile is left behind in the (writable) temp source dir.
402    ///
403    /// This test is Unix-only because chmod on directories behaves differently
404    /// on Windows.
405    #[cfg(unix)]
406    #[test]
407    fn atomic_write_temp_cleanup_on_failure() {
408        use std::os::unix::fs::PermissionsExt as _;
409
410        let dir = tmp_dir();
411
412        // Create a subdirectory that will be made read-only so rename into it
413        // fails. The tempfile is created in a *different* writable dir.
414        //
415        // Strategy: create the target inside a read-only dir; the tempfile
416        // will be created in `writable_dir` (which the target path resolves
417        // its parent from). We do this by having the target path *literally*
418        // inside a read-only dir so that `parent()` returns that dir.
419        let readonly_dir = dir.path().join("readonly");
420        fs::create_dir(&readonly_dir).expect("create readonly_dir failed");
421
422        // Make it read-only *before* we try to write so rename fails.
423        let mut perms = fs::metadata(&readonly_dir)
424            .expect("stat readonly_dir")
425            .permissions();
426        perms.set_mode(0o500); // r-x------
427        fs::set_permissions(&readonly_dir, perms).expect("chmod failed");
428
429        let target = readonly_dir.join("output.txt");
430        let result = atomic_write_bytes(&target, b"data");
431        assert!(
432            result.is_err(),
433            "expected Err when rename into read-only dir"
434        );
435
436        // Restore permissions so TempDir cleanup can remove the directory.
437        let mut perms = fs::metadata(&readonly_dir)
438            .expect("stat readonly_dir")
439            .permissions();
440        perms.set_mode(0o700);
441        fs::set_permissions(&readonly_dir, perms).ok();
442
443        // No tempfile left behind in readonly_dir.
444        let remaining: Vec<_> = fs::read_dir(&readonly_dir)
445            .expect("read_dir readonly_dir")
446            .filter_map(|e| e.ok())
447            .collect();
448        assert!(
449            remaining.is_empty(),
450            "no tempfile should remain after failure: {remaining:?}"
451        );
452    }
453}