Skip to main content

rightkit_fs/
atomic.rs

1//! Atomic file replacement: temp file in the destination directory, `fsync`, rename over the
2//! target, `fsync` the directory. Readers see the old content or the new content, never a mix,
3//! and a crash at any point leaves the old file intact plus (at worst) an orphan temp file
4//! that [`sweep_stale_temps`] reclaims.
5//!
6//! Consolidates CutRight `fsutil::atomic_write` (project and qa copies), CodeRight
7//! `private_fs::atomic` and the settings/model-store writers.
8
9use crate::error::{FsError, Result};
10use std::fs::{self, File, OpenOptions};
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::{Duration, SystemTime};
15
16/// Marker inside every temp name; sweeping only ever touches names containing it.
17pub const TEMP_MARKER: &str = ".rktmp-";
18static COUNTER: AtomicU64 = AtomicU64::new(0);
19const TEMP_ATTEMPTS: u32 = 32;
20
21#[derive(Clone, Copy, Debug)]
22pub struct AtomicOptions {
23    /// Temp inode is created owner-only (0600 / owner DACL) and the result stays owner-only.
24    pub private: bool,
25    /// Create missing parent directories first (owner-only when `private`).
26    pub create_parents: bool,
27    /// `fsync` the file and its directory. Disable only for rebuildable data.
28    pub durable: bool,
29}
30
31impl Default for AtomicOptions {
32    fn default() -> Self {
33        Self {
34            private: false,
35            create_parents: false,
36            durable: true,
37        }
38    }
39}
40
41impl AtomicOptions {
42    pub fn private() -> Self {
43        Self {
44            private: true,
45            ..Self::default()
46        }
47    }
48}
49
50/// Atomically replace `path` with `bytes`.
51pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
52    atomic_write_with(path, AtomicOptions::default(), |f| f.write_all(bytes))
53}
54
55/// Atomically replace `path` with `bytes`, owner-only.
56pub fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<()> {
57    atomic_write_with(path, AtomicOptions::private(), |f| f.write_all(bytes))
58}
59
60/// Atomically replace `path` with whatever `fill` writes. If `fill` fails, the destination is
61/// untouched and the temp file is removed.
62pub fn atomic_write_with(
63    path: &Path,
64    options: AtomicOptions,
65    fill: impl FnOnce(&mut File) -> std::io::Result<()>,
66) -> Result<()> {
67    let mut writer = AtomicWriter::create(path, options)?;
68    fill(writer.file_mut()).map_err(|e| FsError::io(writer.temp_path(), e))?;
69    writer.commit()
70}
71
72/// A streaming atomic write. Nothing is visible at the destination until [`commit`]; dropping
73/// an uncommitted writer removes its temp file. A process killed mid-write leaves only the
74/// temp file behind.
75///
76/// [`commit`]: AtomicWriter::commit
77#[derive(Debug)]
78pub struct AtomicWriter {
79    file: Option<File>,
80    temp: PathBuf,
81    target: PathBuf,
82    options: AtomicOptions,
83}
84
85impl AtomicWriter {
86    pub fn create(target: &Path, options: AtomicOptions) -> Result<Self> {
87        let parent = parent_of(target);
88        if options.create_parents {
89            if options.private {
90                crate::private::ensure_private_dir(&parent)?;
91            } else {
92                fs::create_dir_all(&parent).map_err(|e| FsError::io(&parent, e))?;
93            }
94        }
95        // Keep the mode of an existing regular destination (never a symlink's target).
96        #[cfg(unix)]
97        let existing_mode = match fs::symlink_metadata(target) {
98            Ok(m) if m.is_file() => {
99                use std::os::unix::fs::PermissionsExt;
100                Some(m.permissions().mode() & 0o7777)
101            }
102            _ => None,
103        };
104        let name = target
105            .file_name()
106            .map(|n| n.to_string_lossy().into_owned())
107            .ok_or_else(|| FsError::BadName(target.display().to_string()))?;
108        let mut last = None;
109        for attempt in 0..TEMP_ATTEMPTS {
110            let temp = parent.join(temp_name(&name, attempt));
111            let mut open = OpenOptions::new();
112            open.write(true).create_new(true);
113            #[cfg(unix)]
114            {
115                use std::os::unix::fs::OpenOptionsExt;
116                let mode = if options.private {
117                    0o600
118                } else {
119                    existing_mode.unwrap_or(0o666)
120                };
121                open.mode(mode)
122                    .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
123            }
124            match open.open(&temp) {
125                Ok(file) => {
126                    #[cfg(not(unix))]
127                    if options.private {
128                        if let Err(e) = crate::private::restrict_owner_only(&temp) {
129                            let _ = fs::remove_file(&temp);
130                            return Err(e);
131                        }
132                    }
133                    #[cfg(unix)]
134                    if let (false, Some(mode)) = (options.private, existing_mode) {
135                        // `mode()` above is masked by umask; restore the exact prior bits.
136                        use std::os::unix::fs::PermissionsExt;
137                        let _ = file.set_permissions(fs::Permissions::from_mode(mode));
138                    }
139                    return Ok(Self {
140                        file: Some(file),
141                        temp,
142                        target: target.to_path_buf(),
143                        options,
144                    });
145                }
146                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e),
147                Err(e) => return Err(FsError::io(&temp, e)),
148            }
149        }
150        Err(FsError::io(
151            target,
152            last.unwrap_or_else(|| std::io::Error::other("temp name collisions")),
153        ))
154    }
155
156    pub fn temp_path(&self) -> &Path {
157        &self.temp
158    }
159
160    pub fn file_mut(&mut self) -> &mut File {
161        self.file.as_mut().expect("writer is open until commit")
162    }
163
164    pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
165        let temp = self.temp.clone();
166        self.file_mut()
167            .write_all(bytes)
168            .map_err(|e| FsError::io(temp, e))
169    }
170
171    /// Publish: flush, `fsync`, rename over the target, `fsync` the directory.
172    pub fn commit(mut self) -> Result<()> {
173        let file = self.file.take().expect("writer is open until commit");
174        let result = (|| -> Result<()> {
175            if self.options.durable {
176                durable_sync(&file).map_err(|e| FsError::io(&self.temp, e))?;
177            }
178            drop(file);
179            fs::rename(&self.temp, &self.target).map_err(|e| FsError::io(&self.target, e))?;
180            if self.options.durable {
181                sync_dir(&parent_of(&self.target));
182            }
183            Ok(())
184        })();
185        if result.is_err() {
186            let _ = fs::remove_file(&self.temp);
187        }
188        // Either published or cleaned up: nothing left for Drop.
189        self.temp = PathBuf::new();
190        result
191    }
192}
193
194impl Drop for AtomicWriter {
195    fn drop(&mut self) {
196        if !self.temp.as_os_str().is_empty() {
197            self.file.take();
198            let _ = fs::remove_file(&self.temp);
199        }
200    }
201}
202
203fn parent_of(path: &Path) -> PathBuf {
204    match path.parent() {
205        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
206        _ => PathBuf::from("."),
207    }
208}
209
210fn temp_name(name: &str, attempt: u32) -> String {
211    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
212    let nanos = SystemTime::now()
213        .duration_since(SystemTime::UNIX_EPOCH)
214        .map(|d| d.subsec_nanos())
215        .unwrap_or(0);
216    // `<pid>` is parsed back by `sweep_stale_temps` to tell a crashed writer from a live one.
217    format!(
218        ".{name}{TEMP_MARKER}{}-{n}-{nanos:x}-{attempt}",
219        std::process::id()
220    )
221}
222
223/// Best-effort directory `fsync` so a rename itself survives power loss (no-op off unix).
224pub fn sync_dir(dir: &Path) {
225    #[cfg(unix)]
226    if let Ok(d) = File::open(dir) {
227        let _ = durable_sync(&d);
228    }
229    #[cfg(not(unix))]
230    let _ = dir;
231}
232
233/// Flush a file to stable storage. On Apple targets this matches libuv's `uv__fs_fsync`:
234/// `F_FULLFSYNC`, then `F_BARRIERFSYNC`, then plain `fsync`, because `F_FULLFSYNC` fails with
235/// `EIO`/`ENOTSUP` on some external and network volumes where the weaker syncs succeed.
236/// Elsewhere it is `File::sync_all`.
237pub fn durable_sync(file: &File) -> std::io::Result<()> {
238    #[cfg(target_vendor = "apple")]
239    {
240        use std::os::fd::AsRawFd;
241        const F_BARRIERFSYNC: libc::c_int = 85;
242        let fd = file.as_raw_fd();
243        // SAFETY: `fd` is a valid open descriptor borrowed from `file` for these calls.
244        if unsafe { libc::fcntl(fd, libc::F_FULLFSYNC) } == 0
245            || unsafe { libc::fcntl(fd, F_BARRIERFSYNC) } == 0
246            || unsafe { libc::fsync(fd) } == 0
247        {
248            return Ok(());
249        }
250        Err(std::io::Error::last_os_error())
251    }
252    #[cfg(not(target_vendor = "apple"))]
253    file.sync_all()
254}
255
256/// Remove orphaned temp files in `dir` left by crashed writers. A temp file is reclaimed when
257/// the process that created it is gone, or when it is older than `max_age` (pid reuse guard).
258/// Only names containing [`TEMP_MARKER`] are ever considered. Returns the number removed.
259pub fn sweep_stale_temps(dir: &Path, max_age: Duration) -> Result<usize> {
260    let entries = match fs::read_dir(dir) {
261        Ok(e) => e,
262        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
263        Err(e) => return Err(FsError::io(dir, e)),
264    };
265    let mut removed = 0;
266    for entry in entries.flatten() {
267        let name = entry.file_name().to_string_lossy().into_owned();
268        let Some(pid) = temp_owner_pid(&name) else {
269            continue;
270        };
271        let Ok(meta) = entry.metadata() else { continue };
272        if !meta.is_file() {
273            continue;
274        }
275        let old = meta
276            .modified()
277            .ok()
278            .and_then(|m| m.elapsed().ok())
279            .is_some_and(|age| age >= max_age);
280        if ((pid != std::process::id() && !crate::process_alive(pid)) || old)
281            && fs::remove_file(entry.path()).is_ok()
282        {
283            removed += 1;
284        }
285    }
286    Ok(removed)
287}
288
289fn temp_owner_pid(name: &str) -> Option<u32> {
290    let rest = &name[name.find(TEMP_MARKER)? + TEMP_MARKER.len()..];
291    rest.split('-').next()?.parse().ok()
292}