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/// Writes TOML config while preserving comments, formatting, key ordering, and
17/// any keys present on disk but absent from `new_content` (user customizations,
18/// unknown/future keys). Values from `new_content` are merged onto the existing
19/// document. Falls back to a plain atomic write when there is nothing to merge
20/// or the existing file cannot be parsed.
21pub 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
31/// Loads a TOML file into an editable document, preserving comments and
32/// formatting. Returns an empty document when the file is missing or invalid.
33pub 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
40/// Persists an edited document via the atomic-with-backup path.
41pub fn write_toml_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
42    write_atomic_with_backup(path, &doc.to_string())
43}
44
45/// Like `write_toml_preserving`, but keeps the config minimal: keys whose value
46/// equals the type's default AND are not already present on disk are skipped,
47/// so a hand-written config is not bloated with every default key. Existing
48/// keys are always updated (preserving comments), and non-default values are
49/// always written. `default_content` is `toml::to_string_pretty(&T::default())`.
50pub 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            // Refuse to overwrite a non-empty file we cannot parse. `new_content`
58            // and `default_content` come from our own serializer (always valid),
59            // so a merge failure means the on-disk config is corrupt — clobbering
60            // it with defaults would silently wipe customizations (#443). We
61            // propagate the error and leave the file untouched instead.
62            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        // No existing file: write a fresh minimal document (drop defaults).
70        _ => 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
76/// Merges `incoming` TOML values onto the `existing` document, retaining the
77/// existing document's comments, whitespace, and unknown keys.
78fn 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
108/// Recursively merges `source` keys into `target`, updating values in place so
109/// surrounding comments (key decor) survive, recursing into nested tables, and
110/// preserving inline value decor (trailing comments) on updated leaves.
111///
112/// When `defaults` is `Some`, a key that is absent from `target` and whose value
113/// equals the corresponding default is skipped (minimal-config mode).
114fn 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                // New table in minimal mode: build it from non-default leaves
147                // only and skip it entirely if nothing meaningful remains.
148                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
167/// Compares a serialized item against its default, ignoring decor. Both sides
168/// originate from the same serializer, so their normalized string form matches
169/// exactly when the underlying values are equal.
170fn 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
177/// Remove stale timestamped `.bak` files left by the old backup scheme.
178/// Called once at startup to clean up the accumulated backups.
179pub 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    // #596: a user may symlink agent config (`~/.claude.json`,
217    // `~/.codex/config.toml`, …) into a managed dotfiles repo. Resolve the
218    // symlink to its real target and write THROUGH it (preserving the symlink)
219    // instead of hard-blocking. The target must stay within `$HOME`, so a
220    // planted symlink can never redirect a config write outside the user's own
221    // home (preserves the GL#442 symlink-hijack protection).
222    let target = resolve_write_target(path)?;
223
224    if let Some(parent) = target.parent() {
225        ensure_dir(parent)?;
226    }
227
228    // Force owner-only perms on the real config file (a symlink itself has no
229    // meaningful mode); Windows ACLs are left untouched. The temp+rename
230    // mechanics and the read-only-directory in-place fallback (#459) are shared
231    // with the edit tools via `core::atomic_fs`.
232    #[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
243/// Resolve the real file to write, honoring a user-managed symlink (#596).
244///
245/// * not a symlink (or missing) → `path` unchanged.
246/// * symlink whose resolved target stays within `$HOME` → the target (write
247///   THROUGH, preserving the symlink) — the legitimate dotfiles pattern.
248/// * symlink whose target escapes `$HOME` → refuse (preserves the GL#442
249///   symlink-hijack protection).
250fn 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
263/// Read a symlink and resolve its target to an absolute path, resolving symlinks
264/// in the existing-ancestor portion (so a symlinked *parent* is followed too)
265/// while tolerating a not-yet-created target file/dir.
266fn 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
280/// Canonicalize `path` by resolving its deepest *existing* ancestor (following
281/// symlinks) and re-appending the not-yet-created tail, so the home-only check
282/// runs on a real path even when the target file/dir doesn't exist yet.
283fn 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
311/// SECURITY (#596 / GL#442): a resolved symlink target must stay within `$HOME`
312/// or under one of the explicitly opted-in [`allowed_symlink_roots`]. Otherwise
313/// a planted symlink could redirect a config write to an attacker-chosen path.
314fn 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
341/// Trusted roots OUTSIDE `$HOME` the user explicitly opted into for symlinked
342/// agent configs (#596). Sourced from the `LEAN_CTX_ALLOW_SYMLINK_ROOTS` env var
343/// (path-list separator) and the user-level `allow_symlink_roots` config key
344/// (untrusted project-local configs are stripped at load — see
345/// `strip_sensitive_overrides`). Each entry is made absolute + canonicalized so
346/// the boundary check compares real paths; relative/empty entries are dropped.
347fn 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
364/// `create_dir_all` that tolerates a user-managed symlinked directory (#596):
365///
366/// * regular dir / missing path → `create_dir_all`.
367/// * symlink to an existing directory → ok (no-op).
368/// * dangling symlink whose target is within `$HOME` → create the real target.
369/// * symlink to a non-directory, or a target escaping `$HOME` → clear error.
370pub 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                    // Dangling symlink: create the intended target if it is in
381                    // $HOME (or an explicitly allow-listed root, #596).
382                    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        // Comments survive.
422        assert!(merged.contains("# My custom config — do not delete!"));
423        assert!(merged.contains("# inline note"));
424        assert!(merged.contains("# Section about the proxy"));
425        // Unknown / user keys survive.
426        assert!(merged.contains("custom_user_key = \"keep-me\""));
427        // Values are updated.
428        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        // On-disk: only ultra_compact is explicitly set, with a comment.
436        let existing = "# my config\nultra_compact = true\n";
437        // Incoming: full serialization (all fields present).
438        let incoming = "ultra_compact = false\ncheckpoint_interval = 15\ntheme = \"default\"\n";
439        // Defaults: what an untouched config would serialize to.
440        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        // Existing key updated + comment preserved.
445        assert!(merged.contains("# my config"));
446        assert!(merged.contains("ultra_compact = false"));
447        // Default-valued keys that were never on disk are NOT added (stay minimal).
448        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        // Non-default value is written, default value is skipped.
461        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        // Everything equals default and nothing exists on disk → empty output.
474        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        // Backup created.
508        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        // #443: a corrupt config must never be silently replaced with defaults.
527        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/// #596: write THROUGH a user-managed symlink to its real (in-`$HOME`) target,
554/// reject targets that escape `$HOME`, and make `ensure_dir` tolerant of
555/// symlinked directories. Unix-only (POSIX symlinks + `$HOME` override).
556#[cfg(all(test, unix))]
557mod symlink_596_tests {
558    use super::*;
559    use std::os::unix::fs::symlink;
560
561    /// RAII override of `$HOME` that restores the previous value on drop (even on
562    /// panic). Pair with `test_env_lock()` so env access stays serialized.
563    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    /// RAII override of `LEAN_CTX_ALLOW_SYMLINK_ROOTS` (restores on drop).
638    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        // #596 premium: an out-of-$HOME target IS written through once its root
658        // is explicitly opted into via LEAN_CTX_ALLOW_SYMLINK_ROOTS.
659        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        // Canonical root (macOS tempdirs live under /var → /private/var).
665        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        // Dangling: link → home/dotfiles/.codex, neither exists yet.
714        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}