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 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 #[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 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#[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#[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
130fn 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
163pub(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 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
195pub(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
215pub(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 #[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 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 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}