lean_ctx/core/
atomic_fs.rs1use 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
22pub(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 let _ = std::fs::remove_file(&tmp);
72 return Err(e);
73 }
74 Ok(())
75}
76
77pub(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 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
109pub(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
129pub(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 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 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}