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    let pid = std::process::id();
42    let nanos = SystemTime::now()
43        .duration_since(UNIX_EPOCH)
44        .map_or(0, |d| d.as_nanos());
45    let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
46
47    {
48        let mut f = std::fs::OpenOptions::new()
49            .write(true)
50            .create_new(true)
51            .open(&tmp)?;
52        f.write_all(bytes)?;
53        let _ = f.flush();
54        let _ = f.sync_all();
55    }
56
57    if let Some(perms) = permissions {
58        let _ = std::fs::set_permissions(&tmp, perms.clone());
59    }
60
61    #[cfg(windows)]
62    {
63        if path.exists() {
64            let _ = std::fs::remove_file(path);
65        }
66    }
67
68    if let Err(e) = std::fs::rename(&tmp, path) {
69        // Don't leave a half-written temp behind before the caller decides
70        // whether to fall back.
71        let _ = std::fs::remove_file(&tmp);
72        return Err(e);
73    }
74    Ok(())
75}
76
77/// In-place overwrite of an existing file inode (`O_WRONLY|O_TRUNC`, plus
78/// `O_NOFOLLOW` on Unix). Works when the parent directory is read-only but the
79/// file itself is writable. Not crash-atomic — used only as a fallback when the
80/// atomic path is impossible.
81pub(crate) fn in_place_overwrite(
82    path: &Path,
83    bytes: &[u8],
84    permissions: Option<&std::fs::Permissions>,
85) -> std::io::Result<()> {
86    use std::io::Write;
87
88    let mut opts = std::fs::OpenOptions::new();
89    opts.write(true).truncate(true);
90    #[cfg(unix)]
91    {
92        use std::os::unix::fs::OpenOptionsExt;
93        // O_NOFOLLOW: a symlink swapped in after the caller's checks must never
94        // be followed here (mirrors the read-side O_NOFOLLOW boundary).
95        opts.custom_flags(libc::O_NOFOLLOW);
96    }
97
98    let mut f = opts.open(path)?;
99    f.write_all(bytes)?;
100    let _ = f.flush();
101    let _ = f.sync_all();
102
103    if let Some(perms) = permissions {
104        let _ = std::fs::set_permissions(path, perms.clone());
105    }
106    Ok(())
107}
108
109/// True for errors that mean "this directory won't accept create/rename" even
110/// though the target file may be writable: `EROFS` (read-only fs) plus
111/// `EACCES`/`EPERM` (directory write denied).
112pub(crate) fn is_readonly_dir_error(e: &std::io::Error) -> bool {
113    if e.kind() == std::io::ErrorKind::PermissionDenied {
114        return true;
115    }
116    #[cfg(unix)]
117    {
118        matches!(
119            e.raw_os_error(),
120            Some(libc::EROFS | libc::EACCES | libc::EPERM)
121        )
122    }
123    #[cfg(not(unix))]
124    {
125        false
126    }
127}
128
129/// Atomic write with the read-only-directory in-place fallback (#459). Tries the
130/// crash-atomic temp+rename first; if that fails because the *directory* is
131/// read-only/permission-denied but an existing file inode is writable, overwrite
132/// it in place. `permissions`, when given, is applied to the written file.
133pub(crate) fn write_bytes_with_fallback(
134    path: &Path,
135    bytes: &[u8],
136    permissions: Option<&std::fs::Permissions>,
137) -> Result<(), String> {
138    match try_atomic_write(path, bytes, permissions) {
139        Ok(()) => Ok(()),
140        Err(e) if is_readonly_dir_error(&e) && path.is_file() => {
141            in_place_overwrite(path, bytes, permissions).map_err(|fallback_err| {
142                format!(
143                    "atomic write failed ({e}); in-place fallback also failed: {fallback_err} ({})",
144                    path.display()
145                )
146            })
147        }
148        Err(e) => Err(format!("atomic write failed: {e} ({})", path.display())),
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn readonly_dir_error_classification() {
158        assert!(is_readonly_dir_error(&std::io::Error::from(
159            std::io::ErrorKind::PermissionDenied
160        )));
161        assert!(!is_readonly_dir_error(&std::io::Error::from(
162            std::io::ErrorKind::NotFound
163        )));
164        #[cfg(unix)]
165        {
166            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
167                libc::EROFS
168            )));
169            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
170                libc::EACCES
171            )));
172            assert!(is_readonly_dir_error(&std::io::Error::from_raw_os_error(
173                libc::EPERM
174            )));
175        }
176    }
177
178    #[test]
179    fn try_atomic_write_creates_and_replaces() {
180        let dir = tempfile::tempdir().unwrap();
181        let path = dir.path().join("cfg.toml");
182        try_atomic_write(&path, b"first", None).unwrap();
183        assert_eq!(std::fs::read(&path).unwrap(), b"first");
184        // No leftover temp files.
185        let strays: Vec<_> = std::fs::read_dir(dir.path())
186            .unwrap()
187            .flatten()
188            .filter(|e| e.file_name().to_string_lossy().contains(".lean-ctx.tmp."))
189            .collect();
190        assert!(strays.is_empty(), "temp file must not linger");
191        try_atomic_write(&path, b"second", None).unwrap();
192        assert_eq!(std::fs::read(&path).unwrap(), b"second");
193    }
194
195    #[cfg(unix)]
196    #[test]
197    fn in_place_overwrite_truncates_existing_file() {
198        let dir = tempfile::tempdir().unwrap();
199        let path = dir.path().join("config.jsonc");
200        std::fs::write(&path, b"longer original content").unwrap();
201        in_place_overwrite(&path, b"short", None).unwrap();
202        assert_eq!(std::fs::read(&path).unwrap(), b"short");
203    }
204
205    #[cfg(unix)]
206    #[test]
207    fn fallback_overwrites_when_parent_dir_is_readonly() {
208        use std::os::unix::fs::PermissionsExt;
209        let dir = tempfile::tempdir().unwrap();
210        let path = dir.path().join("cfg.toml");
211        std::fs::write(&path, b"original").unwrap();
212        // Read-only parent dir: temp+rename is impossible, but the file inode
213        // stays writable, so the in-place fallback must succeed.
214        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
215        let res = write_bytes_with_fallback(&path, b"updated", None);
216        let _ = std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700));
217        res.expect("read-only-dir fallback must succeed");
218        assert_eq!(std::fs::read(&path).unwrap(), b"updated");
219    }
220}