Skip to main content

lean_ctx/
config_io.rs

1use std::path::{Path, PathBuf};
2
3fn backup_path_for(path: &Path) -> Option<PathBuf> {
4    let filename = path.file_name()?.to_string_lossy();
5    Some(path.with_file_name(format!("{filename}.bak")))
6}
7
8pub fn snapshot_mtime(path: &Path) -> Option<std::time::SystemTime> {
9    std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
10}
11
12pub fn write_atomic_with_backup(path: &Path, content: &str) -> Result<(), String> {
13    write_atomic_with_backup_checked(path, content, None)
14}
15
16/// Remove stale timestamped `.bak` files left by the old backup scheme.
17/// Called once at startup to clean up the accumulated backups.
18pub fn cleanup_legacy_backups(data_dir: &Path) {
19    let Ok(entries) = std::fs::read_dir(data_dir) else {
20        return;
21    };
22    for entry in entries.flatten() {
23        let name = entry.file_name();
24        let name = name.to_string_lossy();
25        if name.contains(".lean-ctx.") && name.ends_with(".bak") {
26            let _ = std::fs::remove_file(entry.path());
27        }
28    }
29}
30
31pub fn write_atomic_with_backup_checked(
32    path: &Path,
33    content: &str,
34    expected_mtime: Option<std::time::SystemTime>,
35) -> Result<(), String> {
36    if path.exists() {
37        if let Some(expected) = expected_mtime {
38            let current = snapshot_mtime(path);
39            if current != Some(expected) {
40                return Err(format!(
41                    "file was modified externally since last read: {}",
42                    path.display()
43                ));
44            }
45        }
46        if let Some(bak) = backup_path_for(path) {
47            let _ = std::fs::copy(path, &bak);
48        }
49    }
50
51    write_atomic(path, content)
52}
53
54pub fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
55    reject_symlink(path)?;
56
57    if let Some(parent) = path.parent() {
58        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
59    }
60
61    let parent = path
62        .parent()
63        .ok_or_else(|| "invalid path (no parent directory)".to_string())?;
64    let filename = path
65        .file_name()
66        .ok_or_else(|| "invalid path (no filename)".to_string())?
67        .to_string_lossy();
68
69    let pid = std::process::id();
70    let nanos = std::time::SystemTime::now()
71        .duration_since(std::time::UNIX_EPOCH)
72        .map_or(0, |d| d.as_nanos());
73
74    let tmp = parent.join(format!(".{filename}.lean-ctx.tmp.{pid}.{nanos}"));
75    std::fs::write(&tmp, content).map_err(|e| e.to_string())?;
76
77    #[cfg(windows)]
78    {
79        if path.exists() {
80            let _ = std::fs::remove_file(path);
81        }
82    }
83
84    std::fs::rename(&tmp, path).map_err(|e| {
85        format!(
86            "atomic write failed: {} (tmp: {})",
87            e,
88            tmp.to_string_lossy()
89        )
90    })?;
91
92    restrict_file_permissions(path);
93
94    Ok(())
95}
96
97fn reject_symlink(path: &Path) -> Result<(), String> {
98    if path.exists()
99        && path
100            .symlink_metadata()
101            .is_ok_and(|m| m.file_type().is_symlink())
102    {
103        return Err(format!(
104            "refusing to write through symlink: {}",
105            path.display()
106        ));
107    }
108    Ok(())
109}
110
111#[cfg(unix)]
112fn restrict_file_permissions(path: &Path) {
113    use std::os::unix::fs::PermissionsExt;
114    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
115}
116
117#[cfg(not(unix))]
118fn restrict_file_permissions(_path: &Path) {}