Skip to main content

codewhale_config/
config_document.rs

1//! Lossless, serialized `config.toml` mutation.
2//!
3//! Every Codewhale config writer coordinates through the adjacent lock owned
4//! here. Mutations re-read only after acquiring the lock, so a stale process
5//! cannot resurrect revoked credential authority. Callers that still serialize
6//! a full typed snapshot must supply the exact bytes they originally loaded and
7//! fail on a concurrent change.
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, Result, bail};
13
14use crate::{
15    checked_path_exists, normalize_config_file_path, persistence, read_checked_config_file,
16    write_one_time_config_backup,
17};
18
19/// Parse the latest document under the shared write lock, apply `mutate`, and
20/// atomically persist only the resulting delta.
21pub fn mutate_config_document<T, F>(path: &Path, mutate: F) -> Result<T>
22where
23    F: FnOnce(&mut toml_edit::DocumentMut) -> Result<T>,
24{
25    with_config_write_lock(path, |path| {
26        let original = read_optional_config(path)?;
27        let mut document = match original.as_deref() {
28            Some(raw) if !raw.trim().is_empty() => {
29                raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
30                    anyhow::anyhow!(
31                        "failed to parse config at {}; file contents were omitted",
32                        crate::quote_os_path(path)
33                    )
34                })?
35            }
36            _ => toml_edit::DocumentMut::new(),
37        };
38        heal_extras_nesting(&mut document);
39        let result = mutate(&mut document)?;
40        let body = document.to_string();
41        if original.as_deref() == Some(body.as_str()) || (original.is_none() && body.is_empty()) {
42            return Ok(result);
43        }
44        persist_locked(path, original.as_deref(), body.as_bytes())?;
45        Ok(result)
46    })
47}
48
49/// Lift keys trapped under literal `[extras]` tables back to the top level.
50///
51/// The config structs flatten unknown keys into an `extras` map; a historic
52/// writer serialized that map under a literal `extras` key, and every
53/// subsequent buggy round-trip nested it one level deeper
54/// (`[extras.extras.extras.projects."..."]`). That silently strips real
55/// state — workspace trust records, profiles, saved tokens — from every
56/// reader that looks at the canonical top-level tables (2026-07-23 user
57/// report: saved permission/trust ignored on each new session).
58///
59/// Healing runs on every config mutation: entries move up one level per
60/// pass (existing top-level values always win; shadowed duplicates are
61/// dropped), until no literal `extras` table remains. Bounded passes keep a
62/// pathological file from looping.
63pub fn heal_extras_nesting(document: &mut toml_edit::DocumentMut) -> bool {
64    let mut healed = false;
65    for _ in 0..16 {
66        let Some(extras) = document
67            .remove("extras")
68            .and_then(|item| item.into_table().ok())
69        else {
70            break;
71        };
72        healed = true;
73        for (key, value) in extras {
74            if document.get(&key).is_none() {
75                document.insert(&key, value);
76            }
77        }
78    }
79    healed
80}
81
82/// Create a config file only if it is still absent when the shared lock is
83/// acquired. This closes the `exists()`/create race in first-run writers.
84pub fn create_config_document(path: &Path, body: &str) -> Result<()> {
85    replace_config_document_if_unchanged(path, None, body)
86}
87
88/// Replace a full typed snapshot only when on-disk bytes still equal the
89/// snapshot the caller originally loaded. `None` means the file was absent.
90pub fn replace_config_document_if_unchanged(
91    path: &Path,
92    expected: Option<&str>,
93    body: &str,
94) -> Result<()> {
95    with_config_write_lock(path, |path| {
96        let current = read_optional_config(path)?;
97        if current.as_deref() == Some(body) {
98            return Ok(());
99        }
100        if current.as_deref() != expected {
101            bail!(
102                "config changed after it was loaded; reload {} and retry instead of overwriting concurrent changes",
103                crate::quote_os_path(path)
104            );
105        }
106        persist_locked(path, current.as_deref(), body.as_bytes())
107    })
108}
109
110/// Set a value at `segments`, creating implicit parent tables while preserving
111/// existing key/value decor.
112pub fn set_config_document_value(
113    doc: &mut toml_edit::DocumentMut,
114    segments: &[&str],
115    value: impl Into<toml_edit::Value>,
116) -> Result<()> {
117    let (key, parents) = segments
118        .split_last()
119        .context("config value path must not be empty")?;
120    let table = table_like_at_path_mut(doc.as_table_mut(), parents, PathLookup::Create)?
121        .expect("Create lookups always yield a table");
122    match table.get_mut(key) {
123        Some(item) => {
124            let mut value = value.into();
125            if let Some(existing) = item.as_value() {
126                *value.decor_mut() = existing.decor().clone();
127            }
128            *item = toml_edit::Item::Value(value);
129        }
130        None => {
131            table.insert(key, toml_edit::value(value));
132        }
133    }
134    Ok(())
135}
136
137/// Remove a value at `segments` without disturbing unrelated tables or decor.
138pub fn unset_config_document_value(
139    doc: &mut toml_edit::DocumentMut,
140    segments: &[&str],
141) -> Result<bool> {
142    let (key, parents) = segments
143        .split_last()
144        .context("config value path must not be empty")?;
145    let orphaned_root_prefix = (parents.is_empty() && doc.as_table().len() == 1)
146        .then(|| leading_prefix_for_key(doc.as_table(), key))
147        .flatten();
148    let removed = {
149        let Some(table) =
150            table_like_at_path_mut(doc.as_table_mut(), parents, PathLookup::Existing)?
151        else {
152            return Ok(false);
153        };
154        remove_key_preserving_leading_decor(table, key)
155    };
156    if removed
157        && let Some(prefix) = orphaned_root_prefix
158        && prefix.as_str().is_some_and(|prefix| !prefix.is_empty())
159    {
160        let trailing = format!(
161            "{}{}",
162            prefix.as_str().unwrap_or_default(),
163            doc.trailing().as_str().unwrap_or_default()
164        );
165        doc.set_trailing(trailing);
166    }
167    Ok(removed)
168}
169
170fn with_config_write_lock<T>(path: &Path, operation: impl FnOnce(&Path) -> Result<T>) -> Result<T> {
171    let path = prepare_config_path(path)?;
172    let lock_path = adjacent_lock_path(&path)?;
173    super::reject_path_symlink(&lock_path)?;
174
175    let mut options = fs::OpenOptions::new();
176    options.read(true).write(true).create(true);
177    #[cfg(unix)]
178    {
179        use std::os::unix::fs::OpenOptionsExt as _;
180        options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
181    }
182    #[cfg(windows)]
183    {
184        use std::os::windows::fs::OpenOptionsExt as _;
185        use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
186        options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
187    }
188    let lock_file = options.open(&lock_path).with_context(|| {
189        format!(
190            "failed to open config lock at {}",
191            crate::quote_os_path(&lock_path)
192        )
193    })?;
194    #[cfg(unix)]
195    {
196        use std::os::unix::fs::PermissionsExt as _;
197        lock_file
198            .set_permissions(fs::Permissions::from_mode(0o600))
199            .with_context(|| {
200                format!(
201                    "failed to secure config lock at {}",
202                    crate::quote_os_path(&lock_path)
203                )
204            })?;
205    }
206    #[cfg(windows)]
207    validate_windows_lock_handle(&lock_file, &lock_path)?;
208    let mut lock = fd_lock::RwLock::new(lock_file);
209    let _guard = lock.write().with_context(|| {
210        format!(
211            "failed to acquire config lock at {}",
212            crate::quote_os_path(&lock_path)
213        )
214    })?;
215    operation(&path)
216}
217
218#[cfg(windows)]
219fn validate_windows_lock_handle(file: &fs::File, expected_path: &Path) -> Result<()> {
220    use std::ffi::OsString;
221    use std::os::windows::ffi::OsStringExt as _;
222    use std::os::windows::fs::MetadataExt as _;
223    use std::os::windows::io::AsRawHandle as _;
224    use windows_sys::Win32::Storage::FileSystem::{
225        FILE_ATTRIBUTE_REPARSE_POINT, FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW,
226        VOLUME_NAME_DOS,
227    };
228
229    let metadata = file.metadata().with_context(|| {
230        format!(
231            "failed to inspect config lock at {}",
232            crate::quote_os_path(expected_path)
233        )
234    })?;
235    if !metadata.file_type().is_file()
236        || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
237    {
238        bail!(
239            "refusing non-regular or reparse-point config lock at {}",
240            crate::quote_os_path(expected_path)
241        );
242    }
243
244    let handle = file.as_raw_handle();
245    let flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
246    // SAFETY: `handle` remains owned by `file`; a null output buffer asks for
247    // the required UTF-16 length.
248    let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
249    if needed == 0 {
250        return Err(std::io::Error::last_os_error()).with_context(|| {
251            format!(
252                "failed to resolve config lock at {}",
253                crate::quote_os_path(expected_path)
254            )
255        });
256    }
257    let mut buffer = vec![0u16; needed as usize + 1];
258    // SAFETY: `buffer` is writable for its declared length and `handle` stays
259    // valid through the call.
260    let written = unsafe {
261        GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags)
262    };
263    if written == 0 || written as usize >= buffer.len() {
264        return Err(std::io::Error::last_os_error()).with_context(|| {
265            format!(
266                "failed to resolve config lock at {}",
267                crate::quote_os_path(expected_path)
268            )
269        });
270    }
271    let actual = OsString::from_wide(&buffer[..written as usize]);
272    if normalize_windows_path_for_comparison(Path::new(&actual))?
273        != normalize_windows_path_for_comparison(expected_path)?
274    {
275        bail!(
276            "config lock was redirected while opening {}",
277            crate::quote_os_path(expected_path)
278        );
279    }
280    Ok(())
281}
282
283#[cfg(windows)]
284fn normalize_windows_path_for_comparison(path: &Path) -> Result<String> {
285    let text = path.to_str().ok_or_else(|| {
286        anyhow::anyhow!(
287            "config lock path {} contains invalid Unicode and cannot be compared safely",
288            crate::quote_os_path(path)
289        )
290    })?;
291    let without_device_prefix = text.strip_prefix(r"\\?\").unwrap_or(text);
292    let normalized_prefix = without_device_prefix.strip_prefix("UNC\\").map_or_else(
293        || without_device_prefix.to_string(),
294        |rest| format!(r"\\{rest}"),
295    );
296    Ok(normalized_prefix
297        .replace('/', "\\")
298        .trim_end_matches('\\')
299        .to_lowercase())
300}
301
302fn prepare_config_path(path: &Path) -> Result<PathBuf> {
303    let absolute = if path.is_absolute() {
304        path.to_path_buf()
305    } else {
306        std::env::current_dir()
307            .context("failed to resolve current directory for config path")?
308            .join(path)
309    };
310    if let Some(parent) = absolute
311        .parent()
312        .filter(|parent| !parent.as_os_str().is_empty())
313    {
314        fs::create_dir_all(parent).with_context(|| {
315            format!(
316                "failed to create config directory {}",
317                crate::quote_os_path(parent)
318            )
319        })?;
320    }
321    normalize_config_file_path(absolute)
322}
323
324fn adjacent_lock_path(path: &Path) -> Result<PathBuf> {
325    let mut file_name = path
326        .file_name()
327        .context("config path must include a file name")?
328        .to_os_string();
329    file_name.push(".lock");
330    Ok(path
331        .parent()
332        .context("config path must include a parent directory")?
333        .join(file_name))
334}
335
336fn read_optional_config(path: &Path) -> Result<Option<String>> {
337    if checked_path_exists(path)? {
338        read_checked_config_file(path).map(Some)
339    } else {
340        Ok(None)
341    }
342}
343
344fn persist_locked(path: &Path, original: Option<&str>, body: &[u8]) -> Result<()> {
345    if original.is_some() {
346        write_one_time_config_backup(path)?;
347    }
348    persistence::atomic_write(path, body)
349        .with_context(|| format!("failed to write config at {}", crate::quote_os_path(path)))
350}
351
352fn remove_key_preserving_leading_decor(table: &mut dyn toml_edit::TableLike, key: &str) -> bool {
353    let mut found = false;
354    let next_key = table.iter().find_map(|(candidate, _)| {
355        if found {
356            Some(candidate.to_owned())
357        } else {
358            found = candidate == key;
359            None
360        }
361    });
362    let leading_prefix = leading_prefix_for_key(table, key);
363    if table.remove(key).is_none() {
364        return false;
365    }
366    let Some(prefix) = leading_prefix else {
367        return true;
368    };
369    let Some(next_key) = next_key else {
370        return true;
371    };
372    if prefix.as_str() == Some("") {
373        return true;
374    }
375    if let Some(mut next_key_decor) = table.key_mut(&next_key)
376        && decor_prefix_is_empty(next_key_decor.leaf_decor())
377    {
378        next_key_decor.leaf_decor_mut().set_prefix(prefix);
379    }
380    true
381}
382
383fn decor_prefix_is_empty(decor: &toml_edit::Decor) -> bool {
384    match decor.prefix() {
385        Some(prefix) => prefix.as_str() == Some(""),
386        None => true,
387    }
388}
389
390fn leading_prefix_for_key(
391    table: &dyn toml_edit::TableLike,
392    key: &str,
393) -> Option<toml_edit::RawString> {
394    table
395        .key(key)
396        .and_then(|key| key.leaf_decor().prefix().cloned())
397        .or_else(|| {
398            table
399                .get(key)
400                .and_then(|item| item.as_value())
401                .and_then(|value| value.decor().prefix().cloned())
402        })
403}
404
405#[derive(Clone, Copy, PartialEq, Eq)]
406enum PathLookup {
407    Create,
408    Existing,
409}
410
411fn table_like_at_path_mut<'a>(
412    root: &'a mut toml_edit::Table,
413    segments: &[&str],
414    lookup: PathLookup,
415) -> Result<Option<&'a mut dyn toml_edit::TableLike>> {
416    let mut current: &mut dyn toml_edit::TableLike = root;
417    for segment in segments {
418        if current.get(segment).is_none() {
419            match lookup {
420                PathLookup::Create => {
421                    let mut table = toml_edit::Table::new();
422                    table.set_implicit(true);
423                    current.insert(segment, toml_edit::Item::Table(table));
424                }
425                PathLookup::Existing => return Ok(None),
426            }
427        }
428        let item = current
429            .get_mut(segment)
430            .expect("segment exists or was inserted above");
431        match item.as_table_like_mut() {
432            Some(table) => current = table,
433            None => match lookup {
434                PathLookup::Create => bail!("`{segment}` in config.toml must be a table"),
435                PathLookup::Existing => return Ok(None),
436            },
437        }
438    }
439    Ok(Some(current))
440}
441
442#[cfg(test)]
443mod tests {
444    #[test]
445    fn healing_lifts_nested_extras_towers_to_the_top_level() {
446        let tmp = tempfile::tempdir().expect("tempdir");
447        let path = tmp.path().join("config.toml");
448        std::fs::write(
449            &path,
450            concat!(
451                "reasoning_effort = \"high\"\n\n",
452                "[projects.\"/live\"]\n",
453                "trust_level = \"trusted\"\n\n",
454                "[extras.extras]\n",
455                "chatgpt_access_token = \"tok\"\n",
456                "reasoning_effort = \"low\"\n\n",
457                "[extras.extras.projects.\"/old\"]\n",
458                "trust_level = \"trusted\"\n",
459            ),
460        )
461        .expect("write fixture");
462
463        super::mutate_config_document(&path, |_| anyhow::Ok(())).expect("mutate heals");
464
465        let healed: toml::Value =
466            toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
467        assert!(
468            healed.get("extras").is_none(),
469            "tower must be gone: {healed}"
470        );
471        assert_eq!(
472            healed["chatgpt_access_token"].as_str(),
473            Some("tok"),
474            "trapped scalar lifted to the root"
475        );
476        assert_eq!(
477            healed["reasoning_effort"].as_str(),
478            Some("high"),
479            "existing top-level values win over shadowed duplicates"
480        );
481        assert_eq!(
482            healed["projects"]["/live"]["trust_level"].as_str(),
483            Some("trusted"),
484            "live records untouched"
485        );
486        // The nested projects table was shadowed by the live one at the
487        // first lift; healing never merges table contents, only lifts whole
488        // missing keys, so the shadowed duplicate is dropped.
489    }
490
491    #[test]
492    fn healing_recovers_project_tables_when_no_top_level_exists() {
493        let tmp = tempfile::tempdir().expect("tempdir");
494        let path = tmp.path().join("config.toml");
495        std::fs::write(
496            &path,
497            concat!(
498                "[extras.extras.extras.projects.\"/old\"]\n",
499                "trust_level = \"trusted\"\n",
500            ),
501        )
502        .expect("write fixture");
503
504        super::mutate_config_document(&path, |_| anyhow::Ok(())).expect("mutate heals");
505
506        let healed: toml::Value =
507            toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
508        assert!(healed.get("extras").is_none(), "{healed}");
509        assert_eq!(
510            healed["projects"]["/old"]["trust_level"].as_str(),
511            Some("trusted"),
512            "trapped trust record restored: {healed}"
513        );
514    }
515
516    use std::sync::{Arc, Barrier};
517    use std::thread;
518
519    use super::*;
520
521    #[test]
522    fn malformed_config_diagnostics_never_echo_secret_contents_or_keys() {
523        let dir = tempfile::tempdir().expect("tempdir");
524        let path = dir.path().join("config.toml");
525        let secret = "sentinel";
526        fs::write(
527            &path,
528            format!("[providers.xai]\napi_key = \"{secret}\" trailing-junk\n"),
529        )
530        .expect("seed malformed config");
531
532        let error = mutate_config_document(&path, |_| Ok(())).expect_err("must reject malformed");
533        let diagnostic = format!("{error:#}");
534        assert!(!diagnostic.contains(secret), "{diagnostic}");
535        assert!(!diagnostic.contains("api_key"), "{diagnostic}");
536        assert!(
537            diagnostic.contains("file contents were omitted"),
538            "{diagnostic}"
539        );
540    }
541
542    #[cfg(windows)]
543    #[test]
544    fn windows_lock_path_comparison_rejects_unpaired_utf16() {
545        use std::ffi::OsString;
546        use std::os::windows::ffi::OsStringExt as _;
547
548        let invalid = PathBuf::from(OsString::from_wide(&[
549            b'C' as u16,
550            b':' as u16,
551            b'\\' as u16,
552            0xd800,
553        ]));
554        assert!(normalize_windows_path_for_comparison(&invalid).is_err());
555        assert_eq!(
556            normalize_windows_path_for_comparison(Path::new(r"C:\Config\A\config.toml.lock"))
557                .unwrap(),
558            normalize_windows_path_for_comparison(Path::new(r"C:\Config\a\config.toml.lock"))
559                .unwrap(),
560            "Windows lock identity must compare case-insensitively"
561        );
562    }
563
564    #[test]
565    fn targeted_mutation_preserves_unknown_provider_data_and_comments() {
566        let dir = tempfile::tempdir().expect("tempdir");
567        let path = dir.path().join("config.toml");
568        let original = "# operator\n[providers.xai]\nreasoning_stream_style = \"structured\" # keep\nmax_concurrency = 7\ncustom_future = { preserve = true }\n\n[providers.my_private]\nkind = \"openai-compatible\"\napi_key_env = \"PRIVATE_KEY\"\n";
569        fs::write(&path, original).expect("seed");
570
571        mutate_config_document(&path, |doc| {
572            set_config_document_value(
573                doc,
574                &["providers", "xai", "external_credentials", "access"],
575                "read_only",
576            )
577        })
578        .expect("mutate");
579
580        let saved = fs::read_to_string(path).expect("read");
581        for expected in [
582            "# operator",
583            "reasoning_stream_style = \"structured\" # keep",
584            "max_concurrency = 7",
585            "custom_future = { preserve = true }",
586            "[providers.my_private]",
587            "api_key_env = \"PRIVATE_KEY\"",
588        ] {
589            assert!(saved.contains(expected), "missing {expected:?}:\n{saved}");
590        }
591    }
592
593    #[test]
594    fn shared_lock_makes_revoke_win_without_losing_unrelated_update() {
595        let dir = tempfile::tempdir().expect("tempdir");
596        let path = dir.path().join("config.toml");
597        fs::write(
598            &path,
599            "[providers.xai.external_credentials]\naccess = \"read_only\"\nprovider = \"xai\"\nsource = \"grok_cli\"\npath = \"/external/auth.json\"\nconsent_version = 1\n",
600        )
601        .expect("seed");
602        let entered = Arc::new(Barrier::new(2));
603        let release = Arc::new(Barrier::new(2));
604        let revoke_path = path.clone();
605        let entered_revoke = Arc::clone(&entered);
606        let release_revoke = Arc::clone(&release);
607        let revoke = thread::spawn(move || {
608            mutate_config_document(&revoke_path, |doc| {
609                entered_revoke.wait();
610                release_revoke.wait();
611                unset_config_document_value(doc, &["providers", "xai", "external_credentials"])?;
612                Ok(())
613            })
614        });
615        entered.wait();
616        let update_path = path.clone();
617        let update = thread::spawn(move || {
618            mutate_config_document(&update_path, |doc| {
619                set_config_document_value(doc, &["tui", "low_motion"], true)
620            })
621        });
622        release.wait();
623        revoke.join().expect("revoke thread").expect("revoke");
624        update.join().expect("update thread").expect("update");
625
626        let saved = fs::read_to_string(path).expect("read");
627        assert!(!saved.contains("external_credentials"), "{saved}");
628        assert!(saved.contains("low_motion = true"), "{saved}");
629    }
630}