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
16pub fn write_toml_preserving(path: &Path, new_content: &str) -> Result<(), String> {
22 let merged = match std::fs::read_to_string(path) {
23 Ok(existing) if !existing.trim().is_empty() => {
24 merge_toml(&existing, new_content).unwrap_or_else(|_| new_content.to_string())
25 }
26 _ => new_content.to_string(),
27 };
28 write_atomic_with_backup(path, &merged)
29}
30
31pub fn load_toml_document(path: &Path) -> toml_edit::DocumentMut {
34 std::fs::read_to_string(path)
35 .ok()
36 .and_then(|c| c.parse::<toml_edit::DocumentMut>().ok())
37 .unwrap_or_default()
38}
39
40pub fn write_toml_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
42 write_atomic_with_backup(path, &doc.to_string())
43}
44
45pub fn write_toml_preserving_minimal(
51 path: &Path,
52 new_content: &str,
53 default_content: &str,
54) -> Result<(), String> {
55 let merged = match std::fs::read_to_string(path) {
56 Ok(existing) if !existing.trim().is_empty() => {
57 merge_toml_inner(&existing, new_content, Some(default_content)).map_err(|e| {
63 format!(
64 "refusing to overwrite an unparseable config at {}: {e}",
65 path.display()
66 )
67 })?
68 }
69 _ => merge_toml_inner("", new_content, Some(default_content))
71 .unwrap_or_else(|_| new_content.to_string()),
72 };
73 write_atomic_with_backup(path, &merged)
74}
75
76fn merge_toml(existing: &str, incoming: &str) -> Result<String, String> {
79 merge_toml_inner(existing, incoming, None)
80}
81
82fn merge_toml_inner(
83 existing: &str,
84 incoming: &str,
85 defaults: Option<&str>,
86) -> Result<String, String> {
87 let mut existing_doc = existing
88 .parse::<toml_edit::DocumentMut>()
89 .map_err(|e| e.to_string())?;
90 let incoming_doc = incoming
91 .parse::<toml_edit::DocumentMut>()
92 .map_err(|e| e.to_string())?;
93 let default_doc = match defaults {
94 Some(d) => Some(
95 d.parse::<toml_edit::DocumentMut>()
96 .map_err(|e| e.to_string())?,
97 ),
98 None => None,
99 };
100 merge_table(
101 existing_doc.as_table_mut(),
102 incoming_doc.as_table(),
103 default_doc.as_ref().map(toml_edit::DocumentMut::as_table),
104 );
105 Ok(existing_doc.to_string())
106}
107
108fn merge_table(
115 target: &mut toml_edit::Table,
116 source: &toml_edit::Table,
117 defaults: Option<&toml_edit::Table>,
118) {
119 use toml_edit::Item;
120 for (key, source_item) in source {
121 let default_item = defaults.and_then(|d| d.get(key));
122 match (source_item, target.get_mut(key)) {
123 (Item::Table(source_tbl), Some(Item::Table(target_tbl))) => {
124 merge_table(
125 target_tbl,
126 source_tbl,
127 default_item.and_then(Item::as_table),
128 );
129 }
130 (Item::Value(source_val), Some(Item::Value(target_val))) => {
131 let prefix = target_val.decor().prefix().cloned();
132 let suffix = target_val.decor().suffix().cloned();
133 let mut new_val = source_val.clone();
134 if let Some(p) = prefix {
135 new_val.decor_mut().set_prefix(p);
136 }
137 if let Some(s) = suffix {
138 new_val.decor_mut().set_suffix(s);
139 }
140 *target_val = new_val;
141 }
142 (_, Some(target_item)) => {
143 *target_item = source_item.clone();
144 }
145 (Item::Table(source_tbl), None) if defaults.is_some() => {
146 let mut fresh = toml_edit::Table::new();
149 merge_table(
150 &mut fresh,
151 source_tbl,
152 default_item.and_then(Item::as_table),
153 );
154 if !fresh.is_empty() {
155 target.insert(key, Item::Table(fresh));
156 }
157 }
158 (_, None) => {
159 if defaults.is_none() || !item_equals_default(source_item, default_item) {
160 target.insert(key, source_item.clone());
161 }
162 }
163 }
164 }
165}
166
167fn item_equals_default(item: &toml_edit::Item, default: Option<&toml_edit::Item>) -> bool {
171 match default {
172 Some(d) => item.to_string().trim() == d.to_string().trim(),
173 None => false,
174 }
175}
176
177pub fn cleanup_legacy_backups(data_dir: &Path) {
180 let Ok(entries) = std::fs::read_dir(data_dir) else {
181 return;
182 };
183 for entry in entries.flatten() {
184 let name = entry.file_name();
185 let name = name.to_string_lossy();
186 if name.contains(".lean-ctx.") && name.ends_with(".bak") {
187 let _ = std::fs::remove_file(entry.path());
188 }
189 }
190}
191
192pub fn write_atomic_with_backup_checked(
193 path: &Path,
194 content: &str,
195 expected_mtime: Option<std::time::SystemTime>,
196) -> Result<(), String> {
197 if path.exists() {
198 if let Some(expected) = expected_mtime {
199 let current = snapshot_mtime(path);
200 if current != Some(expected) {
201 return Err(format!(
202 "file was modified externally since last read: {}",
203 path.display()
204 ));
205 }
206 }
207 if let Some(bak) = backup_path_for(path) {
208 let _ = std::fs::copy(path, &bak);
209 }
210 }
211
212 write_atomic(path, content)
213}
214
215pub fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
216 let target = resolve_write_target(path)?;
223
224 if let Some(parent) = target.parent() {
225 ensure_dir(parent)?;
226 }
227
228 #[cfg(unix)]
233 let perms = {
234 use std::os::unix::fs::PermissionsExt;
235 Some(std::fs::Permissions::from_mode(0o600))
236 };
237 #[cfg(not(unix))]
238 let perms: Option<std::fs::Permissions> = None;
239
240 crate::core::atomic_fs::write_bytes_with_fallback(&target, content.as_bytes(), perms.as_ref())
241}
242
243fn resolve_write_target(path: &Path) -> Result<PathBuf, String> {
251 let Ok(meta) = path.symlink_metadata() else {
252 return Ok(path.to_path_buf());
253 };
254 if !crate::core::pathutil::is_symlink_or_reparse(&meta) {
255 return Ok(path.to_path_buf());
256 }
257
258 let real_target = resolve_symlink_target(path)?;
259 ensure_target_allowed(path, &real_target)?;
260 Ok(real_target)
261}
262
263fn resolve_symlink_target(link_path: &Path) -> Result<PathBuf, String> {
267 let link = std::fs::read_link(link_path)
268 .map_err(|e| format!("cannot read symlink {}: {e}", link_path.display()))?;
269 let raw_target = if link.is_absolute() {
270 link
271 } else {
272 link_path
273 .parent()
274 .unwrap_or_else(|| Path::new("."))
275 .join(link)
276 };
277 canonicalize_existing_prefix(&raw_target)
278}
279
280fn canonicalize_existing_prefix(path: &Path) -> Result<PathBuf, String> {
284 let mut tail: Vec<std::ffi::OsString> = Vec::new();
285 let mut cur = path;
286 loop {
287 if let Ok(real) = crate::core::pathutil::canonicalize_secure(cur) {
288 let mut out = real;
289 for comp in tail.iter().rev() {
290 out.push(comp);
291 }
292 return Ok(out);
293 }
294 match cur.parent() {
295 Some(parent) if parent != cur => {
296 if let Some(name) = cur.file_name() {
297 tail.push(name.to_os_string());
298 }
299 cur = parent;
300 }
301 _ => {
302 return Err(format!(
303 "cannot resolve any existing ancestor of {}",
304 path.display()
305 ));
306 }
307 }
308 }
309}
310
311fn ensure_target_allowed(link_path: &Path, real_target: &Path) -> Result<(), String> {
315 let home = crate::core::home::resolve_home_dir()
316 .ok_or_else(|| "cannot determine $HOME to validate symlink target".to_string())?;
317 let real_home = crate::core::pathutil::canonicalize_secure_or_self(&home);
318 if real_target.starts_with(&real_home) {
319 return Ok(());
320 }
321 if allowed_symlink_roots()
322 .iter()
323 .any(|root| real_target.starts_with(root))
324 {
325 return Ok(());
326 }
327 Err(format!(
328 "refusing to write through a symlink whose target escapes $HOME:\n \
329 {} -> {}\n \
330 The target is outside your home directory, so lean-ctx will not follow it \
331 (symlink-hijack protection). To allow this location, either:\n \
332 - point the agent at the real path (set CLAUDE_CONFIG_DIR / CODEX_HOME), or\n \
333 - move the target under $HOME, or\n \
334 - add its parent to `allow_symlink_roots` in your lean-ctx config \
335 (or the LEAN_CTX_ALLOW_SYMLINK_ROOTS env var).",
336 link_path.display(),
337 real_target.display()
338 ))
339}
340
341fn allowed_symlink_roots() -> Vec<PathBuf> {
348 let mut raw: Vec<PathBuf> = Vec::new();
349 if let Some(env) = std::env::var_os("LEAN_CTX_ALLOW_SYMLINK_ROOTS") {
350 raw.extend(std::env::split_paths(&env));
351 }
352 raw.extend(
353 crate::core::config::Config::load()
354 .allow_symlink_roots
355 .into_iter()
356 .map(PathBuf::from),
357 );
358 raw.into_iter()
359 .filter(|p| !p.as_os_str().is_empty() && p.is_absolute())
360 .map(|p| crate::core::pathutil::canonicalize_secure_or_self(&p))
361 .collect()
362}
363
364pub fn ensure_dir(dir: &Path) -> Result<(), String> {
371 match dir.symlink_metadata() {
372 Ok(meta) if crate::core::pathutil::is_symlink_or_reparse(&meta) => {
373 match std::fs::metadata(dir) {
374 Ok(m) if m.is_dir() => Ok(()),
375 Ok(_) => Err(format!(
376 "{} is a symlink to a non-directory; fix or remove the symlink",
377 dir.display()
378 )),
379 Err(_) => {
380 let real_target = resolve_symlink_target(dir)?;
383 ensure_target_allowed(dir, &real_target)?;
384 std::fs::create_dir_all(&real_target).map_err(|e| {
385 format!(
386 "cannot create symlink target dir {}: {e}",
387 real_target.display()
388 )
389 })
390 }
391 }
392 }
393 _ => std::fs::create_dir_all(dir)
394 .map_err(|e| format!("cannot create directory {}: {e}", dir.display())),
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn merge_preserves_comments_and_unknown_keys() {
404 let existing = "\
405# My custom config — do not delete!
406ultra_compact = true # inline note
407
408# Section about the proxy
409[proxy]
410enabled = false
411custom_user_key = \"keep-me\"
412";
413 let incoming = "\
414ultra_compact = false
415
416[proxy]
417enabled = true
418";
419 let merged = merge_toml(existing, incoming).unwrap();
420
421 assert!(merged.contains("# My custom config — do not delete!"));
423 assert!(merged.contains("# inline note"));
424 assert!(merged.contains("# Section about the proxy"));
425 assert!(merged.contains("custom_user_key = \"keep-me\""));
427 assert!(merged.contains("ultra_compact = false"));
429 assert!(merged.contains("enabled = true"));
430 assert!(!merged.contains("enabled = false"));
431 }
432
433 #[test]
434 fn minimal_mode_skips_unset_defaults_but_keeps_existing() {
435 let existing = "# my config\nultra_compact = true\n";
437 let incoming = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
439 let defaults = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
441
442 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
443
444 assert!(merged.contains("# my config"));
446 assert!(merged.contains("ultra_compact = false"));
447 assert!(!merged.contains("checkpoint_interval"));
449 assert!(!merged.contains("theme"));
450 }
451
452 #[test]
453 fn minimal_mode_writes_non_default_values() {
454 let existing = "";
455 let incoming = "ultra_compact = false\ncheckpoint_interval = 42\n";
456 let defaults = "ultra_compact = false\ncheckpoint_interval = 15\n";
457
458 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
459
460 assert!(merged.contains("checkpoint_interval = 42"));
462 assert!(!merged.contains("ultra_compact"));
463 }
464
465 #[test]
466 fn minimal_mode_drops_empty_default_tables() {
467 let existing = "";
468 let incoming = "[proxy]\nenabled = false\n\n[lsp]\n";
469 let defaults = "[proxy]\nenabled = false\n\n[lsp]\n";
470
471 let merged = merge_toml_inner(existing, incoming, Some(defaults)).unwrap();
472
473 assert!(!merged.contains("[lsp]"));
475 assert!(!merged.contains("[proxy]"));
476 }
477
478 #[test]
479 fn merge_adds_new_keys_and_sections() {
480 let existing = "ultra_compact = true\n";
481 let incoming = "ultra_compact = true\nnew_key = 42\n\n[updates]\nauto_update = true\n";
482 let merged = merge_toml(existing, incoming).unwrap();
483 assert!(merged.contains("new_key = 42"));
484 assert!(merged.contains("[updates]"));
485 assert!(merged.contains("auto_update = true"));
486 }
487
488 fn unique_tmp(tag: &str) -> std::path::PathBuf {
489 let nanos = std::time::SystemTime::now()
490 .duration_since(std::time::UNIX_EPOCH)
491 .map_or(0, |d| d.as_nanos());
492 std::env::temp_dir().join(format!("lc_{tag}_{}_{nanos}", std::process::id()))
493 }
494
495 #[test]
496 fn write_toml_preserving_backs_up_and_keeps_comments() {
497 let tmp = unique_tmp("cfg_test");
498 let _ = std::fs::create_dir_all(&tmp);
499 let path = tmp.join("config.toml");
500 std::fs::write(&path, "# keep\nultra_compact = true\n").unwrap();
501
502 write_toml_preserving(&path, "ultra_compact = false\n").unwrap();
503
504 let result = std::fs::read_to_string(&path).unwrap();
505 assert!(result.contains("# keep"));
506 assert!(result.contains("ultra_compact = false"));
507 assert!(path.with_file_name("config.toml.bak").exists());
509
510 let _ = std::fs::remove_dir_all(&tmp);
511 }
512
513 #[test]
514 fn write_toml_preserving_handles_missing_file() {
515 let tmp = unique_tmp("cfg_new");
516 let _ = std::fs::remove_dir_all(&tmp);
517 let path = tmp.join("config.toml");
518 write_toml_preserving(&path, "ultra_compact = true\n").unwrap();
519 let result = std::fs::read_to_string(&path).unwrap();
520 assert!(result.contains("ultra_compact = true"));
521 let _ = std::fs::remove_dir_all(&tmp);
522 }
523
524 #[test]
525 fn minimal_mode_refuses_to_clobber_unparseable_existing() {
526 let tmp = unique_tmp("cfg_corrupt");
528 let _ = std::fs::create_dir_all(&tmp);
529 let path = tmp.join("config.toml");
530 let corrupt = "broken = = =\n";
531 std::fs::write(&path, corrupt).unwrap();
532
533 let result = write_toml_preserving_minimal(
534 &path,
535 "ultra_compact = false\n",
536 "ultra_compact = false\n",
537 );
538
539 assert!(
540 result.is_err(),
541 "must refuse to overwrite an unparseable config"
542 );
543 assert_eq!(
544 std::fs::read_to_string(&path).unwrap(),
545 corrupt,
546 "the corrupt file must be left exactly as-is"
547 );
548
549 let _ = std::fs::remove_dir_all(&tmp);
550 }
551}
552
553#[cfg(all(test, unix))]
557mod symlink_596_tests {
558 use super::*;
559 use std::os::unix::fs::symlink;
560
561 struct HomeGuard(Option<std::ffi::OsString>);
564 impl HomeGuard {
565 fn set(home: &Path) -> Self {
566 let prev = std::env::var_os("HOME");
567 crate::test_env::set_var("HOME", home);
568 HomeGuard(prev)
569 }
570 }
571 impl Drop for HomeGuard {
572 fn drop(&mut self) {
573 match self.0.take() {
574 Some(v) => crate::test_env::set_var("HOME", v),
575 None => crate::test_env::remove_var("HOME"),
576 }
577 }
578 }
579
580 #[test]
581 fn write_through_symlink_in_home_updates_target_and_keeps_link() {
582 let _lock = crate::core::data_dir::test_env_lock();
583 let home = tempfile::tempdir().unwrap();
584 let _home = HomeGuard::set(home.path());
585
586 let dotfiles = home.path().join("dotfiles");
587 std::fs::create_dir_all(&dotfiles).unwrap();
588 let target = dotfiles.join("agent.json");
589 std::fs::write(&target, "{}\n").unwrap();
590 let link = home.path().join(".agent.json");
591 symlink(&target, &link).unwrap();
592
593 write_atomic(&link, "{\"k\":1}\n").unwrap();
594
595 assert!(
596 std::fs::symlink_metadata(&link)
597 .unwrap()
598 .file_type()
599 .is_symlink(),
600 "the user symlink must be preserved (write-through, not replace)"
601 );
602 assert_eq!(std::fs::read_to_string(&target).unwrap(), "{\"k\":1}\n");
603
604 use std::os::unix::fs::PermissionsExt;
605 assert_eq!(
606 std::fs::metadata(&target).unwrap().permissions().mode() & 0o777,
607 0o600,
608 "owner-only perms must land on the real config file"
609 );
610 }
611
612 #[test]
613 fn refuses_symlink_whose_target_escapes_home() {
614 let _lock = crate::core::data_dir::test_env_lock();
615 let home = tempfile::tempdir().unwrap();
616 let outside = tempfile::tempdir().unwrap();
617 let _home = HomeGuard::set(home.path());
618
619 let target = outside.path().join("escape.json");
620 std::fs::write(&target, "{}").unwrap();
621 let link = home.path().join(".agent.json");
622 symlink(&target, &link).unwrap();
623
624 let err = write_atomic(&link, "x").unwrap_err();
625 assert!(err.contains("escapes $HOME"), "got: {err}");
626 assert!(
627 err.contains("allow_symlink_roots"),
628 "error must point at the opt-in escape hatch, got: {err}"
629 );
630 assert_eq!(
631 std::fs::read_to_string(&target).unwrap(),
632 "{}",
633 "an escaping target must be left untouched"
634 );
635 }
636
637 struct AllowRootsGuard(Option<std::ffi::OsString>);
639 impl AllowRootsGuard {
640 fn set(value: &std::ffi::OsStr) -> Self {
641 let prev = std::env::var_os("LEAN_CTX_ALLOW_SYMLINK_ROOTS");
642 crate::test_env::set_var("LEAN_CTX_ALLOW_SYMLINK_ROOTS", value);
643 AllowRootsGuard(prev)
644 }
645 }
646 impl Drop for AllowRootsGuard {
647 fn drop(&mut self) {
648 match self.0.take() {
649 Some(v) => crate::test_env::set_var("LEAN_CTX_ALLOW_SYMLINK_ROOTS", v),
650 None => crate::test_env::remove_var("LEAN_CTX_ALLOW_SYMLINK_ROOTS"),
651 }
652 }
653 }
654
655 #[test]
656 fn allows_symlink_escape_when_target_root_is_allowlisted() {
657 let _lock = crate::core::data_dir::test_env_lock();
660 let home = tempfile::tempdir().unwrap();
661 let outside = tempfile::tempdir().unwrap();
662 let _home = HomeGuard::set(home.path());
663
664 let real_outside = std::fs::canonicalize(outside.path()).unwrap();
666 let target = real_outside.join("agent.json");
667 std::fs::write(&target, "{}\n").unwrap();
668 let link = home.path().join(".agent.json");
669 symlink(&target, &link).unwrap();
670
671 let _roots = AllowRootsGuard::set(real_outside.as_os_str());
672 write_atomic(&link, "{\"k\":1}\n").unwrap();
673
674 assert_eq!(std::fs::read_to_string(&target).unwrap(), "{\"k\":1}\n");
675 assert!(
676 std::fs::symlink_metadata(&link)
677 .unwrap()
678 .file_type()
679 .is_symlink(),
680 "the user symlink must be preserved (write-through, not replace)"
681 );
682 }
683
684 #[test]
685 fn ensure_dir_accepts_symlink_to_dir_rejects_symlink_to_file() {
686 let _lock = crate::core::data_dir::test_env_lock();
687 let home = tempfile::tempdir().unwrap();
688 let _home = HomeGuard::set(home.path());
689
690 let real_dir = home.path().join("real_dir");
691 std::fs::create_dir_all(&real_dir).unwrap();
692 let dir_link = home.path().join(".agentdir");
693 symlink(&real_dir, &dir_link).unwrap();
694 assert!(
695 ensure_dir(&dir_link).is_ok(),
696 "a healthy dir symlink must be accepted"
697 );
698
699 let real_file = home.path().join("real_file");
700 std::fs::write(&real_file, "x").unwrap();
701 let file_link = home.path().join(".agentfile");
702 symlink(&real_file, &file_link).unwrap();
703 let err = ensure_dir(&file_link).unwrap_err();
704 assert!(err.contains("non-directory"), "got: {err}");
705 }
706
707 #[test]
708 fn ensure_dir_creates_dangling_symlink_target_in_home() {
709 let _lock = crate::core::data_dir::test_env_lock();
710 let home = tempfile::tempdir().unwrap();
711 let _home = HomeGuard::set(home.path());
712
713 let target = home.path().join("dotfiles/.codex");
715 let link = home.path().join(".codex");
716 symlink(&target, &link).unwrap();
717
718 ensure_dir(&link).unwrap();
719
720 assert!(target.is_dir(), "dangling symlink target must be created");
721 assert!(
722 std::fs::symlink_metadata(&link)
723 .unwrap()
724 .file_type()
725 .is_symlink(),
726 "the symlink itself must remain"
727 );
728 assert!(std::fs::metadata(&link).unwrap().is_dir());
729 }
730}