1use std::path::{Path, PathBuf};
2
3mod migration;
4pub use migration::*;
5
6fn backup_path_for(path: &Path) -> Option<PathBuf> {
7 let filename = path.file_name()?.to_string_lossy();
8 Some(path.with_file_name(format!("{filename}.bak")))
9}
10
11pub fn snapshot_mtime(path: &Path) -> Option<std::time::SystemTime> {
12 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
13}
14
15pub fn write_atomic_with_backup(path: &Path, content: &str) -> Result<(), String> {
16 write_atomic_with_backup_checked(path, content, None)
17}
18
19pub fn write_toml_preserving(path: &Path, new_content: &str) -> Result<(), String> {
25 let merged = match std::fs::read_to_string(path) {
26 Ok(existing) if !existing.trim().is_empty() => {
27 merge_toml(&existing, new_content).unwrap_or_else(|_| new_content.to_string())
28 }
29 _ => new_content.to_string(),
30 };
31 write_atomic_with_backup(path, &merged)
32}
33
34pub fn load_toml_document(path: &Path) -> toml_edit::DocumentMut {
37 std::fs::read_to_string(path)
38 .ok()
39 .and_then(|c| c.parse::<toml_edit::DocumentMut>().ok())
40 .unwrap_or_default()
41}
42
43pub fn write_toml_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
45 write_atomic_with_backup(path, &doc.to_string())
46}
47
48pub fn write_toml_preserving_minimal(
54 path: &Path,
55 new_content: &str,
56 default_content: &str,
57) -> Result<(), String> {
58 let guard = ConfigWriteGuard::acquire(path)?;
59 guard.write_toml_preserving_minimal(new_content, default_content)
60}
61
62fn write_toml_preserving_minimal_locked(
69 path: &Path,
70 new_content: &str,
71 default_content: &str,
72) -> Result<(), String> {
73 let merged = match std::fs::read_to_string(path) {
74 Ok(existing) if !existing.trim().is_empty() => {
75 merge_toml_inner(&existing, new_content, Some(default_content)).map_err(|e| {
81 format!(
82 "refusing to overwrite an unparseable config at {}: {e}",
83 path.display()
84 )
85 })?
86 }
87 _ => merge_toml_inner("", new_content, Some(default_content))
89 .unwrap_or_else(|_| new_content.to_string()),
90 };
91 write_atomic_with_backup_locked(path, &merged, None)
92}
93
94fn merge_toml(existing: &str, incoming: &str) -> Result<String, String> {
97 merge_toml_inner(existing, incoming, None)
98}
99
100fn merge_toml_inner(
101 existing: &str,
102 incoming: &str,
103 defaults: Option<&str>,
104) -> Result<String, String> {
105 let mut existing_doc = existing
106 .parse::<toml_edit::DocumentMut>()
107 .map_err(|e| e.to_string())?;
108 let incoming_doc = incoming
109 .parse::<toml_edit::DocumentMut>()
110 .map_err(|e| e.to_string())?;
111 let default_doc = match defaults {
112 Some(d) => Some(
113 d.parse::<toml_edit::DocumentMut>()
114 .map_err(|e| e.to_string())?,
115 ),
116 None => None,
117 };
118 merge_table(
119 existing_doc.as_table_mut(),
120 incoming_doc.as_table(),
121 default_doc.as_ref().map(toml_edit::DocumentMut::as_table),
122 );
123 Ok(existing_doc.to_string())
124}
125
126fn merge_table(
133 target: &mut toml_edit::Table,
134 source: &toml_edit::Table,
135 defaults: Option<&toml_edit::Table>,
136) {
137 use toml_edit::Item;
138 for (key, source_item) in source {
139 let default_item = defaults.and_then(|d| d.get(key));
140 match (source_item, target.get_mut(key)) {
141 (Item::Table(source_tbl), Some(Item::Table(target_tbl))) => {
142 merge_table(
143 target_tbl,
144 source_tbl,
145 default_item.and_then(Item::as_table),
146 );
147 }
148 (Item::Value(source_val), Some(Item::Value(target_val))) => {
149 let prefix = target_val.decor().prefix().cloned();
150 let suffix = target_val.decor().suffix().cloned();
151 let mut new_val = source_val.clone();
152 if let Some(p) = prefix {
153 new_val.decor_mut().set_prefix(p);
154 }
155 if let Some(s) = suffix {
156 new_val.decor_mut().set_suffix(s);
157 }
158 *target_val = new_val;
159 }
160 (_, Some(target_item)) => {
161 *target_item = source_item.clone();
162 }
163 (Item::Table(source_tbl), None) if defaults.is_some() => {
164 let mut fresh = toml_edit::Table::new();
167 merge_table(
168 &mut fresh,
169 source_tbl,
170 default_item.and_then(Item::as_table),
171 );
172 if !fresh.is_empty() {
173 target.insert(key, Item::Table(fresh));
174 }
175 }
176 (_, None) => {
177 if defaults.is_none() || !item_equals_default(source_item, default_item) {
178 target.insert(key, source_item.clone());
179 }
180 }
181 }
182 }
183}
184
185fn item_equals_default(item: &toml_edit::Item, default: Option<&toml_edit::Item>) -> bool {
189 match default {
190 Some(d) => item.to_string().trim() == d.to_string().trim(),
191 None => false,
192 }
193}
194
195pub fn cleanup_legacy_backups(data_dir: &Path) {
198 let Ok(entries) = std::fs::read_dir(data_dir) else {
199 return;
200 };
201 for entry in entries.flatten() {
202 let name = entry.file_name();
203 let name = name.to_string_lossy();
204 if name.contains(".lean-ctx.") && name.ends_with(".bak") {
205 let _ = std::fs::remove_file(entry.path());
206 }
207 }
208}
209
210pub fn write_atomic_with_backup_checked(
211 path: &Path,
212 content: &str,
213 expected_mtime: Option<std::time::SystemTime>,
214) -> Result<(), String> {
215 let guard = ConfigWriteGuard::acquire(path)?;
218 write_atomic_with_backup_locked(guard.path(), content, expected_mtime)
219}
220
221fn write_atomic_with_backup_locked(
223 path: &Path,
224 content: &str,
225 expected_mtime: Option<std::time::SystemTime>,
226) -> Result<(), String> {
227 if path.exists() {
228 if let Some(expected) = expected_mtime {
229 let current = snapshot_mtime(path);
230 if current != Some(expected) {
231 return Err(format!(
232 "file was modified externally since last read: {}",
233 path.display()
234 ));
235 }
236 }
237 if let Some(bak) = backup_path_for(path) {
238 std::fs::copy(path, &bak).map_err(|error| {
239 format!(
240 "cannot create config backup {} before updating {}: {error}",
241 bak.display(),
242 path.display()
243 )
244 })?;
245 }
246 }
247
248 write_atomic(path, content)
249}
250
251pub fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
252 let target = resolve_write_target(path)?;
259
260 if let Some(parent) = target.parent() {
261 ensure_dir(parent)?;
262 }
263
264 #[cfg(unix)]
269 let perms = {
270 use std::os::unix::fs::PermissionsExt;
271 Some(std::fs::Permissions::from_mode(0o600))
272 };
273 #[cfg(not(unix))]
274 let perms: Option<std::fs::Permissions> = None;
275
276 crate::core::atomic_fs::write_bytes_with_fallback(&target, content.as_bytes(), perms.as_ref())
277}
278
279fn resolve_write_target(path: &Path) -> Result<PathBuf, String> {
287 let Ok(meta) = path.symlink_metadata() else {
288 return Ok(path.to_path_buf());
289 };
290 if !crate::core::pathutil::is_symlink_or_reparse(&meta) {
291 return Ok(path.to_path_buf());
292 }
293
294 let real_target = resolve_symlink_target(path)?;
295 ensure_target_allowed(path, &real_target)?;
296 Ok(real_target)
297}
298
299fn resolve_symlink_target(link_path: &Path) -> Result<PathBuf, String> {
303 let link = std::fs::read_link(link_path)
304 .map_err(|e| format!("cannot read symlink {}: {e}", link_path.display()))?;
305 let raw_target = if link.is_absolute() {
306 link
307 } else {
308 link_path
309 .parent()
310 .unwrap_or_else(|| Path::new("."))
311 .join(link)
312 };
313 canonicalize_existing_prefix(&raw_target)
314}
315
316fn canonicalize_existing_prefix(path: &Path) -> Result<PathBuf, String> {
320 let mut tail: Vec<std::ffi::OsString> = Vec::new();
321 let mut cur = path;
322 loop {
323 if let Ok(real) = crate::core::pathutil::canonicalize_secure(cur) {
324 let mut out = real;
325 for comp in tail.iter().rev() {
326 out.push(comp);
327 }
328 return Ok(out);
329 }
330 match cur.parent() {
331 Some(parent) if parent != cur => {
332 if let Some(name) = cur.file_name() {
333 tail.push(name.to_os_string());
334 }
335 cur = parent;
336 }
337 _ => {
338 return Err(format!(
339 "cannot resolve any existing ancestor of {}",
340 path.display()
341 ));
342 }
343 }
344 }
345}
346
347fn ensure_target_allowed(link_path: &Path, real_target: &Path) -> Result<(), String> {
351 let home = crate::core::home::resolve_home_dir()
352 .ok_or_else(|| "cannot determine $HOME to validate symlink target".to_string())?;
353 let real_home = crate::core::pathutil::canonicalize_secure_or_self(&home);
354 if real_target.starts_with(&real_home) {
355 return Ok(());
356 }
357 if allowed_symlink_roots()
358 .iter()
359 .any(|root| real_target.starts_with(root))
360 {
361 return Ok(());
362 }
363 Err(format!(
364 "refusing to write through a symlink whose target escapes $HOME:\n \
365 {} -> {}\n \
366 The target is outside your home directory, so lean-ctx will not follow it \
367 (symlink-hijack protection). To allow this location, either:\n \
368 - point the agent at the real path (set CLAUDE_CONFIG_DIR / CODEX_HOME), or\n \
369 - move the target under $HOME, or\n \
370 - add its parent to `allow_symlink_roots` in your lean-ctx config \
371 (or the LEAN_CTX_ALLOW_SYMLINK_ROOTS env var).",
372 link_path.display(),
373 real_target.display()
374 ))
375}
376
377fn allowed_symlink_roots() -> Vec<PathBuf> {
384 let mut raw: Vec<PathBuf> = Vec::new();
385 if let Some(env) = std::env::var_os("LEAN_CTX_ALLOW_SYMLINK_ROOTS") {
386 raw.extend(std::env::split_paths(&env));
387 }
388 raw.extend(
389 crate::core::config::Config::load()
390 .allow_symlink_roots
391 .into_iter()
392 .map(PathBuf::from),
393 );
394 raw.into_iter()
395 .filter(|p| !p.as_os_str().is_empty() && p.is_absolute())
396 .map(|p| crate::core::pathutil::canonicalize_secure_or_self(&p))
397 .collect()
398}
399
400pub fn write_if_changed(path: &Path, content: &str) {
403 if std::fs::read_to_string(path).is_ok_and(|existing| existing == content) {
406 return;
407 }
408 if let Err(e) = write_atomic_with_backup(path, content) {
409 tracing::error!("Error writing {}: {e}", path.display());
410 }
411}
412
413pub fn ensure_dir_reported(dir: &Path) -> bool {
422 match ensure_dir(dir) {
423 Ok(()) => true,
424 Err(e) => {
425 eprintln!("lean-ctx setup: cannot prepare {}: {e}", dir.display());
427 false
428 }
429 }
430}
431
432pub fn ensure_dir(dir: &Path) -> Result<(), String> {
439 match dir.symlink_metadata() {
440 Ok(meta) if crate::core::pathutil::is_symlink_or_reparse(&meta) => {
441 match std::fs::metadata(dir) {
442 Ok(m) if m.is_dir() => Ok(()),
443 Ok(_) => Err(format!(
444 "{} is a symlink to a non-directory; fix or remove the symlink",
445 dir.display()
446 )),
447 Err(_) => {
448 let real_target = resolve_symlink_target(dir)?;
451 ensure_target_allowed(dir, &real_target)?;
452 std::fs::create_dir_all(&real_target).map_err(|e| {
453 format!(
454 "cannot create symlink target dir {}: {e}",
455 real_target.display()
456 )
457 })
458 }
459 }
460 }
461 _ => std::fs::create_dir_all(dir)
462 .map_err(|e| format!("cannot create directory {}: {e}", dir.display())),
463 }
464}
465
466#[cfg(test)]
467mod tests;
468
469#[cfg(all(test, unix))]
470mod symlink_596_tests;