Skip to main content

lean_ctx/core/
atomic_fs.rs

1//! Shared, policy-free atomic-write mechanics.
2//!
3//! Both the config installer ([`crate::config_io`]) and the edit tools
4//! (`crate::tools::edit_io`) need the same durable write dance: a
5//! same-directory temp file + `rename`, with an in-place-overwrite fallback when
6//! the directory is read-only but the file inode is writable (#459). Only the
7//! *mechanism* lives here — one audited implementation. The differing *policy*
8//! stays in each caller:
9//!
10//! * `edit_io` rejects symlinks (`reject_symlink` + `O_NOFOLLOW`) and guards
11//!   TOCTOU/read-only-roots before calling in;
12//! * `config_io` resolves a user-managed symlink to its real target (within
13//!   `$HOME`) and then writes through to it.
14
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18fn invalid_input(msg: &'static str) -> std::io::Error {
19    std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
20}
21
22/// Durable, crash-atomic write: a temp file in the **same directory** as `path`
23/// followed by `rename` over the target. Requires write permission on the parent
24/// directory; the read-only-directory fallback is handled by
25/// [`write_bytes_with_fallback`].
26pub(crate) fn try_atomic_write(
27    path: &Path,
28    bytes: &[u8],
29    permissions: Option<&std::fs::Permissions>,
30) -> std::io::Result<()> {
31    use std::io::Write;
32
33    let parent = path
34        .parent()
35        .ok_or_else(|| invalid_input("invalid path (no parent directory)"))?;
36    let filename = path
37        .file_name()
38        .ok_or_else(|| invalid_input("invalid path (no filename)"))?
39        .to_string_lossy();
40
41    // #958: sweep this directory for temps orphaned by a previous crash
42    // between temp-file creation and rename — the only place these were
43    // ever cleaned up before was the rename error path, so a hard crash
44    // left them behind for good with no separate startup pass to catch
45    // them. Piggybacking here means every atomic write into this directory
46    // gets a chance to reap whatever an earlier crashed write left behind.
47    cleanup_orphaned_temps(parent, &filename);
48
49    let pid = std::process::id();
50    let nanos = SystemTime::now()
51        .duration_since(UNIX_EPOCH)
52        .map_or(0, |d| d.as_nanos());
53    let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
54
55    {
56        let mut f = std::fs::OpenOptions::new()
57            .write(true)
58            .create_new(true)
59            .open(&tmp)?;
60        f.write_all(bytes)?;
61        let _ = f.flush();
62        let _ = f.sync_all();
63    }
64
65    if let Some(perms) = permissions {
66        let _ = std::fs::set_permissions(&tmp, perms.clone());
67    }
68
69    // #956: on Windows, `rename` fails outright if `path` already exists, so
70    // this used to `remove_file(path)` first and rename second — two
71    // non-atomic syscalls. A reader landing in that window sees ENOENT for a
72    // file that "exists", and a process that dies after the remove but
73    // before the rename loses the original file for good while the temp file
74    // is left behind orphaned. `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`
75    // performs the swap as one atomic operation, matching what `rename(2)`
76    // already gives us for free on Unix.
77    #[cfg(windows)]
78    if let Err(e) = windows_replace(&tmp, path) {
79        let _ = std::fs::remove_file(&tmp);
80        return Err(e);
81    }
82
83    #[cfg(not(windows))]
84    if let Err(e) = std::fs::rename(&tmp, path) {
85        // Don't leave a half-written temp behind before the caller decides
86        // whether to fall back.
87        let _ = std::fs::remove_file(&tmp);
88        return Err(e);
89    }
90
91    #[cfg(unix)]
92    fsync_dir(parent);
93
94    Ok(())
95}
96
97/// Atomically replace `path` with `tmp` via `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`
98/// — the Windows equivalent of POSIX `rename(2)`'s implicit replace-if-exists.
99#[cfg(windows)]
100fn windows_replace(tmp: &Path, path: &Path) -> std::io::Result<()> {
101    use std::os::windows::ffi::OsStrExt;
102    use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};
103
104    fn to_wide(p: &Path) -> Vec<u16> {
105        p.as_os_str()
106            .encode_wide()
107            .chain(std::iter::once(0))
108            .collect()
109    }
110
111    let tmp_w = to_wide(tmp);
112    let path_w = to_wide(path);
113
114    let ok = unsafe { MoveFileExW(tmp_w.as_ptr(), path_w.as_ptr(), MOVEFILE_REPLACE_EXISTING) };
115    if ok == 0 {
116        return Err(std::io::Error::last_os_error());
117    }
118    Ok(())
119}
120
121/// Best-effort `fsync` of a directory's inode so a preceding `rename`/`create`
122/// into it survives a crash (Unix only).
123#[cfg(unix)]
124fn fsync_dir(dir: &Path) {
125    if let Ok(f) = std::fs::File::open(dir) {
126        let _ = f.sync_all();
127    }
128}
129
130/// Removes `.{filename}.lean-ctx.tmp.{pid}.{nanos}` leftovers in `parent` from
131/// a crash between temp-file creation and rename (#958).
132fn cleanup_orphaned_temps(parent: &Path, filename: &str) {
133    const STALE_AGE: std::time::Duration = std::time::Duration::from_hours(1);
134    let prefix = format!(".{filename}.lean-ctx.tmp.");
135
136    let Ok(entries) = std::fs::read_dir(parent) else {
137        return;
138    };
139    let now = SystemTime::now();
140    for entry in entries.flatten() {
141        let name = entry.file_name();
142        let Some(rest) = name
143            .to_string_lossy()
144            .strip_prefix(&prefix)
145            .map(str::to_string)
146        else {
147            continue;
148        };
149        let pid: Option<u32> = rest.split('.').next().and_then(|s| s.parse().ok());
150        let pid_dead = pid.is_some_and(|p| !crate::ipc::process::is_alive(p));
151        let stale_by_age = entry
152            .metadata()
153            .and_then(|m| m.modified())
154            .ok()
155            .and_then(|m| now.duration_since(m).ok())
156            .is_some_and(|age| age > STALE_AGE);
157        if pid_dead || stale_by_age {
158            let _ = std::fs::remove_file(entry.path());
159        }
160    }
161}
162
163/// In-place overwrite of an existing file inode (`O_WRONLY|O_TRUNC`, plus
164/// `O_NOFOLLOW` on Unix). Works when the parent directory is read-only but the
165/// file itself is writable. Not crash-atomic — used only as a fallback when the
166/// atomic path is impossible.
167pub(crate) fn in_place_overwrite(
168    path: &Path,
169    bytes: &[u8],
170    permissions: Option<&std::fs::Permissions>,
171) -> std::io::Result<()> {
172    use std::io::Write;
173
174    let mut opts = std::fs::OpenOptions::new();
175    opts.write(true).truncate(true);
176    #[cfg(unix)]
177    {
178        use std::os::unix::fs::OpenOptionsExt;
179        // O_NOFOLLOW: a symlink swapped in after the caller's checks must never
180        // be followed here (mirrors the read-side O_NOFOLLOW boundary).
181        opts.custom_flags(libc::O_NOFOLLOW);
182    }
183
184    let mut f = opts.open(path)?;
185    f.write_all(bytes)?;
186    let _ = f.flush();
187    let _ = f.sync_all();
188
189    if let Some(perms) = permissions {
190        let _ = std::fs::set_permissions(path, perms.clone());
191    }
192    Ok(())
193}
194
195/// True for errors that mean "this directory won't accept create/rename" even
196/// though the target file may be writable: `EROFS` (read-only fs) plus
197/// `EACCES`/`EPERM` (directory write denied).
198pub(crate) fn is_readonly_dir_error(e: &std::io::Error) -> bool {
199    if e.kind() == std::io::ErrorKind::PermissionDenied {
200        return true;
201    }
202    #[cfg(unix)]
203    {
204        matches!(
205            e.raw_os_error(),
206            Some(libc::EROFS | libc::EACCES | libc::EPERM)
207        )
208    }
209    #[cfg(not(unix))]
210    {
211        false
212    }
213}
214
215/// Atomic write with the read-only-directory in-place fallback (#459). Tries the
216/// crash-atomic temp+rename first; if that fails because the *directory* is
217/// read-only/permission-denied but an existing file inode is writable, overwrite
218/// it in place. `permissions`, when given, is applied to the written file.
219pub(crate) fn write_bytes_with_fallback(
220    path: &Path,
221    bytes: &[u8],
222    permissions: Option<&std::fs::Permissions>,
223) -> Result<(), String> {
224    match try_atomic_write(path, bytes, permissions) {
225        Ok(()) => Ok(()),
226        Err(e) if is_readonly_dir_error(&e) && path.is_file() => {
227            in_place_overwrite(path, bytes, permissions).map_err(|fallback_err| {
228                format!(
229                    "atomic write failed ({e}); in-place fallback also failed: {fallback_err} ({})",
230                    path.display()
231                )
232            })
233        }
234        Err(e) => Err(format!("atomic write failed: {e} ({})", path.display())),
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn readonly_dir_error_classification() {
244        assert!(is_readonly_dir_error(&std::io::Error::from(
245            std::io::ErrorKind::PermissionDenied
246        )));
247        assert!(!is_readonly_dir_error(&std::io::Error::from(
248            std::io::ErrorKind::NotFound
249        )));
250        #[cfg(unix)]
251        {
252            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
253                libc::EROFS
254            )));
255            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
256                libc::EACCES
257            )));
258            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
259                libc::EPERM
260            )));
261        }
262    }
263
264    #[cfg(unix)]
265    #[test]
266    fn fsync_dir_succeeds_on_a_real_directory() {
267        let dir = tempfile::tempdir().unwrap();
268        fsync_dir(dir.path());
269    }
270
271    // --- #958: orphaned-temp sweep ---
272
273    #[test]
274    fn cleanup_orphaned_temps_removes_dead_pid_leftovers() {
275        let dir = tempfile::tempdir().unwrap();
276        let filename = "cfg.toml";
277        let dead_pid = 999_999_999u32;
278        let orphan = dir
279            .path()
280            .join(format!(".{filename}.lean-ctx.tmp.{dead_pid}.123"));
281        std::fs::write(&orphan, b"stale").unwrap();
282
283        cleanup_orphaned_temps(dir.path(), filename);
284
285        assert!(
286            !orphan.exists(),
287            "orphaned temp with a dead PID must be removed"
288        );
289    }
290
291    #[test]
292    fn cleanup_orphaned_temps_keeps_fresh_temp_from_a_live_pid() {
293        let dir = tempfile::tempdir().unwrap();
294        let filename = "cfg.toml";
295        let my_pid = std::process::id();
296        let in_progress = dir
297            .path()
298            .join(format!(".{filename}.lean-ctx.tmp.{my_pid}.123"));
299        std::fs::write(&in_progress, b"still writing").unwrap();
300
301        cleanup_orphaned_temps(dir.path(), filename);
302
303        assert!(
304            in_progress.exists(),
305            "a live writer's own in-progress temp must survive"
306        );
307    }
308
309    #[test]
310    fn cleanup_orphaned_temps_removes_old_temp_even_with_a_live_pid() {
311        let dir = tempfile::tempdir().unwrap();
312        let filename = "cfg.toml";
313        let my_pid = std::process::id();
314        let ancient = dir
315            .path()
316            .join(format!(".{filename}.lean-ctx.tmp.{my_pid}.123"));
317        std::fs::write(&ancient, b"ancient").unwrap();
318        filetime::set_file_mtime(&ancient, filetime::FileTime::from_unix_time(0, 0)).unwrap();
319
320        cleanup_orphaned_temps(dir.path(), filename);
321
322        assert!(
323            !ancient.exists(),
324            "ancient temp must be removed regardless of PID liveness"
325        );
326    }
327
328    #[test]
329    fn cleanup_orphaned_temps_ignores_unrelated_files() {
330        let dir = tempfile::tempdir().unwrap();
331        let filename = "cfg.toml";
332        let unrelated = dir.path().join("other-file.txt");
333        std::fs::write(&unrelated, b"keep me").unwrap();
334
335        cleanup_orphaned_temps(dir.path(), filename);
336
337        assert!(unrelated.exists(), "non-matching files must be left alone");
338    }
339    #[test]
340    fn try_atomic_write_creates_and_replaces() {
341        let dir = tempfile::tempdir().unwrap();
342        let path = dir.path().join("cfg.toml");
343        try_atomic_write(&path, b"first", None).unwrap();
344        assert_eq!(std::fs::read(&path).unwrap(), b"first");
345        // No leftover temp files.
346        let strays: Vec<_> = std::fs::read_dir(dir.path())
347            .unwrap()
348            .flatten()
349            .filter(|e| e.file_name().to_string_lossy().contains(".lean-ctx.tmp."))
350            .collect();
351        assert!(strays.is_empty(), "temp file must not linger");
352        try_atomic_write(&path, b"second", None).unwrap();
353        assert_eq!(std::fs::read(&path).unwrap(), b"second");
354    }
355
356    #[cfg(unix)]
357    #[test]
358    fn in_place_overwrite_truncates_existing_file() {
359        let dir = tempfile::tempdir().unwrap();
360        let path = dir.path().join("config.jsonc");
361        std::fs::write(&path, b"longer original content").unwrap();
362        in_place_overwrite(&path, b"short", None).unwrap();
363        assert_eq!(std::fs::read(&path).unwrap(), b"short");
364    }
365
366    #[cfg(unix)]
367    #[test]
368    fn fallback_overwrites_when_parent_dir_is_readonly() {
369        use std::os::unix::fs::PermissionsExt;
370        let dir = tempfile::tempdir().unwrap();
371        let path = dir.path().join("cfg.toml");
372        std::fs::write(&path, b"original").unwrap();
373        // Read-only parent dir: temp+rename is impossible, but the file inode
374        // stays writable, so the in-place fallback must succeed.
375        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
376        let res = write_bytes_with_fallback(&path, b"updated", None);
377        let _ = std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700));
378        res.expect("read-only-dir fallback must succeed");
379        assert_eq!(std::fs::read(&path).unwrap(), b"updated");
380    }
381}