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}