1use 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
23pub 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
35pub 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
50pub fn try_atomic_write_unswept(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
55 write_staged(path, bytes, None, || true, false)
56}
57
58pub 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 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 #[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 let _ = std::fs::remove_file(&tmp);
155 return Err(e);
156 }
157
158 #[cfg(unix)]
159 fsync_dir(parent);
160
161 Ok(())
162}
163
164pub 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#[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 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 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#[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
232pub 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#[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 pub fn take() -> u64 {
272 SEEN.with(|n| n.replace(0))
273 }
274}
275
276fn 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
311pub 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 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#[derive(Debug)]
345pub enum StoreReadError {
346 Escapes,
349 Io(std::io::Error),
350}
351
352pub 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 #[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
407pub 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
427pub 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 #[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 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 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 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}