Skip to main content

lean_ctx_fs/
atomic_fs.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared, policy-free atomic-write mechanics.
3//!
4//! Both the engine's config installer (`config_io`) and its edit tools
5//! (`tools::edit_io`) need the same durable write dance: a
6//! same-directory temp file + `rename`, with an in-place-overwrite fallback when
7//! the directory is read-only but the file inode is writable (#459). Only the
8//! *mechanism* lives here — one audited implementation. The differing *policy*
9//! stays in each caller:
10//!
11//! * `edit_io` rejects symlinks (`reject_symlink` + `O_NOFOLLOW`) and guards
12//!   TOCTOU/read-only-roots before calling in;
13//! * `config_io` resolves a user-managed symlink to its real target (within
14//!   `$HOME`) and then writes through to it.
15
16use std::path::Path;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19fn invalid_input(msg: &'static str) -> std::io::Error {
20    std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
21}
22
23/// Durable, crash-atomic write: a temp file in the **same directory** as `path`
24/// followed by `rename` over the target. Requires write permission on the parent
25/// directory; the read-only-directory fallback is handled by
26/// [`write_bytes_with_fallback`].
27pub fn try_atomic_write(
28    path: &Path,
29    bytes: &[u8],
30    permissions: Option<&std::fs::Permissions>,
31) -> std::io::Result<()> {
32    try_atomic_write_if(path, bytes, permissions, || true)
33}
34
35/// [`try_atomic_write`] with a last word for the caller: `admit` is asked once
36/// the new content is fully staged and synced, immediately before the replace.
37/// On `false` the staged file is removed, `path` is untouched and the error
38/// kind is `TimedOut`. Everything slow (cleanup, create, write, sync) lies
39/// before that question; after it only the replace itself follows — which on
40/// Windows may retry a held target for about a second.
41pub fn try_atomic_write_if(
42    path: &Path,
43    bytes: &[u8],
44    permissions: Option<&std::fs::Permissions>,
45    admit: impl FnOnce() -> bool,
46) -> std::io::Result<()> {
47    write_staged(path, bytes, permissions, admit, true)
48}
49
50/// [`try_atomic_write`] without the sweep for orphaned temps: for a directory
51/// of very many files (the gateway evidence store) where listing it on every
52/// write would cost more than the write, and whose owner removes stale temps
53/// itself ([`is_orphaned_temp`]).
54pub fn try_atomic_write_unswept(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
55    write_staged(path, bytes, None, || true, false)
56}
57
58/// Whether `name` is a temp file of these atomic writes that a crash left
59/// behind: its writer is gone, or it is older than an hour (#958).
60pub fn is_orphaned_temp(name: &str, modified: SystemTime, now: SystemTime) -> bool {
61    let Some(rest) = name
62        .strip_prefix('.')
63        .and_then(|name| name.split_once(".lean-ctx.tmp."))
64        .map(|(_, rest)| rest)
65    else {
66        return false;
67    };
68    let pid: Option<u32> = rest.split('.').next().and_then(|s| s.parse().ok());
69    pid.is_some_and(|p| !crate::process::is_alive(p))
70        || now
71            .duration_since(modified)
72            .is_ok_and(|age| age > ORPHAN_AGE)
73}
74
75const ORPHAN_AGE: std::time::Duration = std::time::Duration::from_hours(1);
76
77fn write_staged(
78    path: &Path,
79    bytes: &[u8],
80    permissions: Option<&std::fs::Permissions>,
81    admit: impl FnOnce() -> bool,
82    sweep: bool,
83) -> std::io::Result<()> {
84    use std::io::Write;
85
86    let parent = path
87        .parent()
88        .ok_or_else(|| invalid_input("invalid path (no parent directory)"))?;
89    let filename = path
90        .file_name()
91        .ok_or_else(|| invalid_input("invalid path (no filename)"))?
92        .to_string_lossy();
93
94    // #958: sweep this directory for temps orphaned by a previous crash
95    // between temp-file creation and rename — the only place these were
96    // ever cleaned up before was the rename error path, so a hard crash
97    // left them behind for good with no separate startup pass to catch
98    // them. Piggybacking here means every atomic write into this directory
99    // gets a chance to reap whatever an earlier crashed write left behind.
100    if sweep {
101        cleanup_orphaned_temps(parent, &filename);
102    }
103
104    let pid = std::process::id();
105    let nanos = SystemTime::now()
106        .duration_since(UNIX_EPOCH)
107        .map_or(0, |d| d.as_nanos());
108    let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
109
110    {
111        let mut options = std::fs::OpenOptions::new();
112        options.write(true).create_new(true);
113        #[cfg(unix)]
114        if let Some(perms) = permissions {
115            use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
116            options.mode(perms.mode());
117        }
118        let mut f = options.open(&tmp)?;
119        f.write_all(bytes)?;
120        f.flush()?;
121        f.sync_all()?;
122    }
123
124    if let Some(perms) = permissions {
125        std::fs::set_permissions(&tmp, perms.clone())?;
126    }
127
128    if !admit() {
129        let _ = std::fs::remove_file(&tmp);
130        return Err(std::io::Error::new(
131            std::io::ErrorKind::TimedOut,
132            "atomic write was not admitted to commit",
133        ));
134    }
135
136    // #956: on Windows, `rename` fails outright if `path` already exists, so
137    // this used to `remove_file(path)` first and rename second — two
138    // non-atomic syscalls. A reader landing in that window sees ENOENT for a
139    // file that "exists", and a process that dies after the remove but
140    // before the rename loses the original file for good while the temp file
141    // is left behind orphaned. `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`
142    // performs the swap as one atomic operation, matching what `rename(2)`
143    // already gives us for free on Unix.
144    #[cfg(windows)]
145    if let Err(e) = windows_replace(&tmp, path) {
146        let _ = std::fs::remove_file(&tmp);
147        return Err(e);
148    }
149
150    #[cfg(not(windows))]
151    if let Err(e) = std::fs::rename(&tmp, path) {
152        // Don't leave a half-written temp behind before the caller decides
153        // whether to fall back.
154        let _ = std::fs::remove_file(&tmp);
155        return Err(e);
156    }
157
158    #[cfg(unix)]
159    fsync_dir(parent);
160
161    Ok(())
162}
163
164/// Atomically move a fully written `tmp` over `path`: `rename(2)` on Unix,
165/// `windows_replace` (with its bounded retry of a held target) on Windows.
166/// `tmp` is left in place on failure; the caller removes it.
167pub fn replace_file(tmp: &Path, path: &Path) -> std::io::Result<()> {
168    #[cfg(windows)]
169    {
170        windows_replace(tmp, path)
171    }
172    #[cfg(not(windows))]
173    {
174        std::fs::rename(tmp, path)
175    }
176}
177
178/// Atomically replace `path` with `tmp` via `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`
179/// — the Windows equivalent of POSIX `rename(2)`'s implicit replace-if-exists.
180#[cfg(windows)]
181fn windows_replace(tmp: &Path, path: &Path) -> std::io::Result<()> {
182    use std::os::windows::ffi::OsStrExt;
183    use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};
184
185    fn to_wide(p: &Path) -> Vec<u16> {
186        p.as_os_str()
187            .encode_wide()
188            .chain(std::iter::once(0))
189            .collect()
190    }
191
192    let tmp_w = to_wide(tmp);
193    let path_w = to_wide(path);
194
195    // A reader, Defender or the search indexer briefly holding `path` open
196    // without FILE_SHARE_DELETE makes the replace fail with ACCESS_DENIED (5)
197    // or SHARING_VIOLATION (32) until the handle closes. Those are transient:
198    // retry with backoff for about a second before reporting the error.
199    const ACCESS_DENIED: i32 = 5;
200    const SHARING_VIOLATION: i32 = 32;
201    let mut delay = std::time::Duration::from_millis(5);
202    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
203    loop {
204        // SAFETY: both vectors own NUL-terminated UTF-16 buffers and remain
205        // alive for the duration of the call.
206        let ok = unsafe { MoveFileExW(tmp_w.as_ptr(), path_w.as_ptr(), MOVEFILE_REPLACE_EXISTING) };
207        if ok != 0 {
208            return Ok(());
209        }
210        let error = std::io::Error::last_os_error();
211        let transient = matches!(
212            error.raw_os_error(),
213            Some(ACCESS_DENIED | SHARING_VIOLATION)
214        );
215        if !transient || std::time::Instant::now() >= deadline {
216            return Err(error);
217        }
218        std::thread::sleep(delay);
219        delay = (delay * 2).min(std::time::Duration::from_millis(100));
220    }
221}
222
223/// Best-effort `fsync` of a directory's inode so a preceding `rename`/`create`
224/// into it survives a crash (Unix only).
225#[cfg(unix)]
226fn fsync_dir(dir: &Path) {
227    if let Ok(f) = std::fs::File::open(dir) {
228        let _ = f.sync_all();
229    }
230}
231
232/// Make a preceding `rename`/`create`/`remove` in `dir` durable, reporting
233/// failure. On Unix this fsyncs the directory. Windows cannot flush a directory
234/// through a plain `File::open` (it fails with `ERROR_ACCESS_DENIED`); NTFS
235/// journals directory metadata itself, so only the directory's existence is
236/// verified there — the same contract as the `#[cfg(unix)]` sync sites
237/// elsewhere in the crate.
238pub fn sync_directory(dir: &Path) -> std::io::Result<()> {
239    #[cfg(unix)]
240    {
241        std::fs::File::open(dir)?.sync_all()
242    }
243    #[cfg(not(unix))]
244    {
245        if std::fs::metadata(dir)?.is_dir() {
246            Ok(())
247        } else {
248            Err(std::io::Error::new(
249                std::io::ErrorKind::NotADirectory,
250                "sync target is not a directory",
251            ))
252        }
253    }
254}
255
256/// Directory listings made on this thread, so tests assert a write path
257/// lists nothing by counting operations instead of timing them.
258#[cfg(any(test, feature = "test-support"))]
259pub mod listings {
260    use std::cell::Cell;
261
262    thread_local! {
263        static SEEN: Cell<u64> = const { Cell::new(0) };
264    }
265
266    pub fn seen() {
267        SEEN.with(|n| n.set(n.get() + 1));
268    }
269
270    /// The listings since the last call.
271    pub fn take() -> u64 {
272        SEEN.with(|n| n.replace(0))
273    }
274}
275
276/// Removes `.{filename}.lean-ctx.tmp.{pid}.{nanos}` leftovers in `parent` from
277/// a crash between temp-file creation and rename (#958).
278fn cleanup_orphaned_temps(parent: &Path, filename: &str) {
279    const STALE_AGE: std::time::Duration = ORPHAN_AGE;
280    let prefix = format!(".{filename}.lean-ctx.tmp.");
281    #[cfg(any(test, feature = "test-support"))]
282    listings::seen();
283
284    let Ok(entries) = std::fs::read_dir(parent) else {
285        return;
286    };
287    let now = SystemTime::now();
288    for entry in entries.flatten() {
289        let name = entry.file_name();
290        let Some(rest) = name
291            .to_string_lossy()
292            .strip_prefix(&prefix)
293            .map(str::to_string)
294        else {
295            continue;
296        };
297        let pid: Option<u32> = rest.split('.').next().and_then(|s| s.parse().ok());
298        let pid_dead = pid.is_some_and(|p| !crate::process::is_alive(p));
299        let stale_by_age = entry
300            .metadata()
301            .and_then(|m| m.modified())
302            .ok()
303            .and_then(|m| now.duration_since(m).ok())
304            .is_some_and(|age| age > STALE_AGE);
305        if pid_dead || stale_by_age {
306            let _ = std::fs::remove_file(entry.path());
307        }
308    }
309}
310
311/// In-place overwrite of an existing file inode (`O_WRONLY|O_TRUNC`, plus
312/// `O_NOFOLLOW` on Unix). Works when the parent directory is read-only but the
313/// file itself is writable. Not crash-atomic — used only as a fallback when the
314/// atomic path is impossible.
315pub fn in_place_overwrite(
316    path: &Path,
317    bytes: &[u8],
318    permissions: Option<&std::fs::Permissions>,
319) -> std::io::Result<()> {
320    use std::io::Write;
321
322    let mut opts = std::fs::OpenOptions::new();
323    opts.write(true).truncate(true);
324    #[cfg(unix)]
325    {
326        use std::os::unix::fs::OpenOptionsExt;
327        // O_NOFOLLOW: a symlink swapped in after the caller's checks must never
328        // be followed here (mirrors the read-side O_NOFOLLOW boundary).
329        opts.custom_flags(libc::O_NOFOLLOW);
330    }
331
332    let mut f = opts.open(path)?;
333    f.write_all(bytes)?;
334    let _ = f.flush();
335    let _ = f.sync_all();
336
337    if let Some(perms) = permissions {
338        let _ = std::fs::set_permissions(path, perms.clone());
339    }
340    Ok(())
341}
342
343/// Why a store file could not be read safely.
344#[derive(Debug)]
345pub enum StoreReadError {
346    /// The path, a directory on it, or the opened file is a symlink, not a regular
347    /// file, or resolves outside `store_root`.
348    Escapes,
349    Io(std::io::Error),
350}
351
352/// Read a file of a local content store (archive, tee) for the model without
353/// following links out of the store and without a check-then-open race.
354///
355/// The parent directory must canonicalize to a path inside `store_root`, the final
356/// component is opened without following symlinks (`O_NOFOLLOW` on Unix), and the
357/// opened handle must be a regular file. Whatever the path pointed at between the
358/// check and the read, the bytes returned come from the file that was checked.
359pub fn read_store_file(
360    store_root: &std::path::Path,
361    path: &std::path::Path,
362) -> Result<String, StoreReadError> {
363    use std::io::Read;
364
365    let root = std::fs::canonicalize(store_root).map_err(StoreReadError::Io)?;
366    let parent = path.parent().ok_or(StoreReadError::Escapes)?;
367    let canonical_parent = std::fs::canonicalize(parent).map_err(StoreReadError::Io)?;
368    if !canonical_parent.starts_with(&root) {
369        return Err(StoreReadError::Escapes);
370    }
371    let link = std::fs::symlink_metadata(path).map_err(StoreReadError::Io)?;
372    if link.file_type().is_symlink() || !link.is_file() {
373        return Err(StoreReadError::Escapes);
374    }
375    let mut options = std::fs::OpenOptions::new();
376    options.read(true);
377    #[cfg(unix)]
378    {
379        use std::os::unix::fs::OpenOptionsExt;
380        options.custom_flags(libc::O_NOFOLLOW);
381    }
382    let mut file = options.open(path).map_err(|error| {
383        // ELOOP: the final component was swapped for a symlink after the check.
384        #[cfg(unix)]
385        if error.raw_os_error() == Some(libc::ELOOP) {
386            return StoreReadError::Escapes;
387        }
388        StoreReadError::Io(error)
389    })?;
390    let opened = file.metadata().map_err(StoreReadError::Io)?;
391    if !opened.is_file() {
392        return Err(StoreReadError::Escapes);
393    }
394    #[cfg(unix)]
395    {
396        use std::os::unix::fs::MetadataExt;
397        if opened.dev() != link.dev() || opened.ino() != link.ino() {
398            return Err(StoreReadError::Escapes);
399        }
400    }
401    let mut content = String::new();
402    file.read_to_string(&mut content)
403        .map_err(StoreReadError::Io)?;
404    Ok(content)
405}
406
407/// True for errors that mean "this directory won't accept create/rename" even
408/// though the target file may be writable: `EROFS` (read-only fs) plus
409/// `EACCES`/`EPERM` (directory write denied).
410pub fn is_readonly_dir_error(e: &std::io::Error) -> bool {
411    if e.kind() == std::io::ErrorKind::PermissionDenied {
412        return true;
413    }
414    #[cfg(unix)]
415    {
416        matches!(
417            e.raw_os_error(),
418            Some(libc::EROFS | libc::EACCES | libc::EPERM)
419        )
420    }
421    #[cfg(not(unix))]
422    {
423        false
424    }
425}
426
427/// Atomic write with the read-only-directory in-place fallback (#459). Tries the
428/// crash-atomic temp+rename first; if that fails because the *directory* is
429/// read-only/permission-denied but an existing file inode is writable, overwrite
430/// it in place. `permissions`, when given, is applied to the written file.
431pub fn write_bytes_with_fallback(
432    path: &Path,
433    bytes: &[u8],
434    permissions: Option<&std::fs::Permissions>,
435) -> Result<(), String> {
436    match try_atomic_write(path, bytes, permissions) {
437        Ok(()) => Ok(()),
438        Err(e) if is_readonly_dir_error(&e) && path.is_file() => {
439            in_place_overwrite(path, bytes, permissions).map_err(|fallback_err| {
440                format!(
441                    "atomic write failed ({e}); in-place fallback also failed: {fallback_err} ({})",
442                    path.display()
443                )
444            })
445        }
446        Err(e) => Err(format!("atomic write failed: {e} ({})", path.display())),
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn readonly_dir_error_classification() {
456        assert!(is_readonly_dir_error(&std::io::Error::from(
457            std::io::ErrorKind::PermissionDenied
458        )));
459        assert!(!is_readonly_dir_error(&std::io::Error::from(
460            std::io::ErrorKind::NotFound
461        )));
462        #[cfg(unix)]
463        {
464            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
465                libc::EROFS
466            )));
467            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
468                libc::EACCES
469            )));
470            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
471                libc::EPERM
472            )));
473        }
474    }
475
476    #[cfg(unix)]
477    #[test]
478    fn fsync_dir_succeeds_on_a_real_directory() {
479        let dir = tempfile::tempdir().unwrap();
480        fsync_dir(dir.path());
481    }
482
483    // --- #958: orphaned-temp sweep ---
484
485    #[test]
486    fn cleanup_orphaned_temps_removes_dead_pid_leftovers() {
487        let dir = tempfile::tempdir().unwrap();
488        let filename = "cfg.toml";
489        let dead_pid = 999_999_999u32;
490        let orphan = dir
491            .path()
492            .join(format!(".{filename}.lean-ctx.tmp.{dead_pid}.123"));
493        std::fs::write(&orphan, b"stale").unwrap();
494
495        cleanup_orphaned_temps(dir.path(), filename);
496
497        assert!(
498            !orphan.exists(),
499            "orphaned temp with a dead PID must be removed"
500        );
501    }
502
503    #[test]
504    fn cleanup_orphaned_temps_keeps_fresh_temp_from_a_live_pid() {
505        let dir = tempfile::tempdir().unwrap();
506        let filename = "cfg.toml";
507        let my_pid = std::process::id();
508        let in_progress = dir
509            .path()
510            .join(format!(".{filename}.lean-ctx.tmp.{my_pid}.123"));
511        std::fs::write(&in_progress, b"still writing").unwrap();
512
513        cleanup_orphaned_temps(dir.path(), filename);
514
515        assert!(
516            in_progress.exists(),
517            "a live writer's own in-progress temp must survive"
518        );
519    }
520
521    #[test]
522    fn cleanup_orphaned_temps_removes_old_temp_even_with_a_live_pid() {
523        let dir = tempfile::tempdir().unwrap();
524        let filename = "cfg.toml";
525        let my_pid = std::process::id();
526        let ancient = dir
527            .path()
528            .join(format!(".{filename}.lean-ctx.tmp.{my_pid}.123"));
529        std::fs::write(&ancient, b"ancient").unwrap();
530        filetime::set_file_mtime(&ancient, filetime::FileTime::from_unix_time(0, 0)).unwrap();
531
532        cleanup_orphaned_temps(dir.path(), filename);
533
534        assert!(
535            !ancient.exists(),
536            "ancient temp must be removed regardless of PID liveness"
537        );
538    }
539
540    #[test]
541    fn cleanup_orphaned_temps_ignores_unrelated_files() {
542        let dir = tempfile::tempdir().unwrap();
543        let filename = "cfg.toml";
544        let unrelated = dir.path().join("other-file.txt");
545        std::fs::write(&unrelated, b"keep me").unwrap();
546
547        cleanup_orphaned_temps(dir.path(), filename);
548
549        assert!(unrelated.exists(), "non-matching files must be left alone");
550    }
551    #[test]
552    fn try_atomic_write_creates_and_replaces() {
553        let dir = tempfile::tempdir().unwrap();
554        let path = dir.path().join("cfg.toml");
555        try_atomic_write(&path, b"first", None).unwrap();
556        assert_eq!(std::fs::read(&path).unwrap(), b"first");
557        // No leftover temp files.
558        let strays: Vec<_> = std::fs::read_dir(dir.path())
559            .unwrap()
560            .flatten()
561            .filter(|e| e.file_name().to_string_lossy().contains(".lean-ctx.tmp."))
562            .collect();
563        assert!(strays.is_empty(), "temp file must not linger");
564        try_atomic_write(&path, b"second", None).unwrap();
565        assert_eq!(std::fs::read(&path).unwrap(), b"second");
566
567        // A commit the caller does not admit leaves the target as it was
568        // and nothing staged behind.
569        let refused = try_atomic_write_if(&path, b"third", None, || false).unwrap_err();
570        assert_eq!(refused.kind(), std::io::ErrorKind::TimedOut);
571        assert_eq!(std::fs::read(&path).unwrap(), b"second");
572        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
573    }
574
575    #[cfg(unix)]
576    #[test]
577    fn in_place_overwrite_truncates_existing_file() {
578        let dir = tempfile::tempdir().unwrap();
579        let path = dir.path().join("config.jsonc");
580        std::fs::write(&path, b"longer original content").unwrap();
581        in_place_overwrite(&path, b"short", None).unwrap();
582        assert_eq!(std::fs::read(&path).unwrap(), b"short");
583    }
584
585    #[cfg(unix)]
586    #[test]
587    fn fallback_overwrites_when_parent_dir_is_readonly() {
588        use std::os::unix::fs::PermissionsExt;
589        let dir = tempfile::tempdir().unwrap();
590        let path = dir.path().join("cfg.toml");
591        std::fs::write(&path, b"original").unwrap();
592        // Read-only parent dir: temp+rename is impossible, but the file inode
593        // stays writable, so the in-place fallback must succeed.
594        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
595        let res = write_bytes_with_fallback(&path, b"updated", None);
596        let _ = std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700));
597        res.expect("read-only-dir fallback must succeed");
598        assert_eq!(std::fs::read(&path).unwrap(), b"updated");
599    }
600}