Skip to main content

lean_ctx/
config_io.rs

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
19/// Writes TOML config while preserving comments, formatting, key ordering, and
20/// any keys present on disk but absent from `new_content` (user customizations,
21/// unknown/future keys). Values from `new_content` are merged onto the existing
22/// document. Falls back to a plain atomic write when there is nothing to merge
23/// or the existing file cannot be parsed.
24pub 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
34/// Loads a TOML file into an editable document, preserving comments and
35/// formatting. Returns an empty document when the file is missing or invalid.
36pub 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
43/// Persists an edited document via the atomic-with-backup path.
44pub fn write_toml_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<(), String> {
45    write_atomic_with_backup(path, &doc.to_string())
46}
47
48/// Like `write_toml_preserving`, but keeps the config minimal: keys whose value
49/// equals the type's default AND are not already present on disk are skipped,
50/// so a hand-written config is not bloated with every default key. Existing
51/// keys are always updated (preserving comments), and non-default values are
52/// always written. `default_content` is `toml::to_string_pretty(&T::default())`.
53pub 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
62/// Lock-free core: the caller must already hold [`ConfigWriteGuard`].
63///
64/// Holding the lock around this (rather than only around the final byte write)
65/// is what pulls the merge read below inside the critical section: the merge
66/// decides the output from the current on-disk document, so reading it
67/// unlocked let a concurrent writer's committed value be merged away.
68fn 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            // Refuse to overwrite a non-empty file we cannot parse. `new_content`
76            // and `default_content` come from our own serializer (always valid),
77            // so a merge failure means the on-disk config is corrupt — clobbering
78            // it with defaults would silently wipe customizations (#443). We
79            // propagate the error and leave the file untouched instead.
80            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        // No existing file: write a fresh minimal document (drop defaults).
88        _ => 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
94/// Merges `incoming` TOML values onto the `existing` document, retaining the
95/// existing document's comments, whitespace, and unknown keys.
96fn 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
126/// Recursively merges `source` keys into `target`, updating values in place so
127/// surrounding comments (key decor) survive, recursing into nested tables, and
128/// preserving inline value decor (trailing comments) on updated leaves.
129///
130/// When `defaults` is `Some`, a key that is absent from `target` and whose value
131/// equals the corresponding default is skipped (minimal-config mode).
132fn 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                // New table in minimal mode: build it from non-default leaves
165                // only and skip it entirely if nothing meaningful remains.
166                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
185/// Compares a serialized item against its default, ignoring decor. Both sides
186/// originate from the same serializer, so their normalized string form matches
187/// exactly when the underlying values are equal.
188fn 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
195/// Remove stale timestamped `.bak` files left by the old backup scheme.
196/// Called once at startup to clean up the accumulated backups.
197pub 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    // Resolve once: a retargeted caller alias must not redirect the locked write.
216    // Backups therefore live beside the canonical target for every entry point.
217    let guard = ConfigWriteGuard::acquire(path)?;
218    write_atomic_with_backup_locked(guard.path(), content, expected_mtime)
219}
220
221/// Lock-free core: the caller must already hold [`ConfigWriteGuard`].
222fn 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    // #596: a user may symlink agent config (`~/.claude.json`,
253    // `~/.codex/config.toml`, …) into a managed dotfiles repo. Resolve the
254    // symlink to its real target and write THROUGH it (preserving the symlink)
255    // instead of hard-blocking. The target must stay within `$HOME`, so a
256    // planted symlink can never redirect a config write outside the user's own
257    // home (preserves the GL#442 symlink-hijack protection).
258    let target = resolve_write_target(path)?;
259
260    if let Some(parent) = target.parent() {
261        ensure_dir(parent)?;
262    }
263
264    // Force owner-only perms on the real config file (a symlink itself has no
265    // meaningful mode); Windows ACLs are left untouched. The temp+rename
266    // mechanics and the read-only-directory in-place fallback (#459) are shared
267    // with the edit tools via `core::atomic_fs`.
268    #[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
279/// Resolve the real file to write, honoring a user-managed symlink (#596).
280///
281/// * not a symlink (or missing) → `path` unchanged.
282/// * symlink whose resolved target stays within `$HOME` → the target (write
283///   THROUGH, preserving the symlink) — the legitimate dotfiles pattern.
284/// * symlink whose target escapes `$HOME` → refuse (preserves the GL#442
285///   symlink-hijack protection).
286fn 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
299/// Read a symlink and resolve its target to an absolute path, resolving symlinks
300/// in the existing-ancestor portion (so a symlinked *parent* is followed too)
301/// while tolerating a not-yet-created target file/dir.
302fn 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
316/// Canonicalize `path` by resolving its deepest *existing* ancestor (following
317/// symlinks) and re-appending the not-yet-created tail, so the home-only check
318/// runs on a real path even when the target file/dir doesn't exist yet.
319fn 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
347/// SECURITY (#596 / GL#442): a resolved symlink target must stay within `$HOME`
348/// or under one of the explicitly opted-in [`allowed_symlink_roots`]. Otherwise
349/// a planted symlink could redirect a config write to an attacker-chosen path.
350fn 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
377/// Trusted roots OUTSIDE `$HOME` the user explicitly opted into for symlinked
378/// agent configs (#596). Sourced from the `LEAN_CTX_ALLOW_SYMLINK_ROOTS` env var
379/// (path-list separator) and the user-level `allow_symlink_roots` config key
380/// (untrusted project-local configs are stripped at load — see
381/// `strip_sensitive_overrides`). Each entry is made absolute + canonicalized so
382/// the boundary check compares real paths; relative/empty entries are dropped.
383fn 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
400/// Write `content` to `path` atomically with a backup, logging (not
401/// returning) a failure.
402pub fn write_if_changed(path: &Path, content: &str) {
403    // Skip identical rewrites: re-running setup/init must not churn mtimes or
404    // leave .bak files behind for content that did not change (GL #558).
405    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
413/// Create a setup directory, surfacing a clear error instead of silently
414/// swallowing it (#596).
415///
416/// A user may symlink `~/.claude` / `~/.codex` (or a child) into a dotfiles
417/// repo; [`ensure_dir`] follows such a symlink to its real
418/// in-`$HOME` target and tolerates a dangling one. Returns `false` (after
419/// printing the reason) when the directory cannot be prepared, so the caller can
420/// skip the now-impossible writes rather than failing confusingly downstream.
421pub fn ensure_dir_reported(dir: &Path) -> bool {
422    match ensure_dir(dir) {
423        Ok(()) => true,
424        Err(e) => {
425            // Always surface — a swallowed dir failure was the #596 footgun.
426            eprintln!("lean-ctx setup: cannot prepare {}: {e}", dir.display());
427            false
428        }
429    }
430}
431
432/// `create_dir_all` that tolerates a user-managed symlinked directory (#596):
433///
434/// * regular dir / missing path → `create_dir_all`.
435/// * symlink to an existing directory → ok (no-op).
436/// * dangling symlink whose target is within `$HOME` → create the real target.
437/// * symlink to a non-directory, or a target escaping `$HOME` → clear error.
438pub 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                    // Dangling symlink: create the intended target if it is in
449                    // $HOME (or an explicitly allow-listed root, #596).
450                    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;