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
170pub(crate) fn with_config_write_lock<T>(
171    path: &Path,
172    operation: impl FnOnce(&Path) -> Result<T>,
173) -> Result<T> {
174    let path = prepare_config_path(path)?;
175    let lock_path = adjacent_lock_path(&path)?;
176    super::reject_path_symlink(&lock_path)?;
177
178    let mut options = fs::OpenOptions::new();
179    options.read(true).write(true).create(true);
180    #[cfg(unix)]
181    {
182        use std::os::unix::fs::OpenOptionsExt as _;
183        options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
184    }
185    #[cfg(windows)]
186    {
187        use std::os::windows::fs::OpenOptionsExt as _;
188        use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
189        options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
190    }
191    let lock_file = options.open(&lock_path).with_context(|| {
192        format!(
193            "failed to open config lock at {}",
194            crate::quote_os_path(&lock_path)
195        )
196    })?;
197    #[cfg(unix)]
198    {
199        use std::os::unix::fs::PermissionsExt as _;
200        lock_file
201            .set_permissions(fs::Permissions::from_mode(0o600))
202            .with_context(|| {
203                format!(
204                    "failed to secure config lock at {}",
205                    crate::quote_os_path(&lock_path)
206                )
207            })?;
208    }
209    #[cfg(windows)]
210    validate_windows_lock_handle(&lock_file, &lock_path)?;
211    let mut lock = fd_lock::RwLock::new(lock_file);
212    let _guard = lock.write().with_context(|| {
213        format!(
214            "failed to acquire config lock at {}",
215            crate::quote_os_path(&lock_path)
216        )
217    })?;
218    operation(&path)
219}
220
221#[cfg(windows)]
222fn validate_windows_lock_handle(file: &fs::File, expected_path: &Path) -> Result<()> {
223    use std::ffi::OsString;
224    use std::os::windows::ffi::OsStringExt as _;
225    use std::os::windows::fs::MetadataExt as _;
226    use std::os::windows::io::AsRawHandle as _;
227    use windows_sys::Win32::Storage::FileSystem::{
228        FILE_ATTRIBUTE_REPARSE_POINT, FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW,
229        VOLUME_NAME_DOS,
230    };
231
232    let metadata = file.metadata().with_context(|| {
233        format!(
234            "failed to inspect config lock at {}",
235            crate::quote_os_path(expected_path)
236        )
237    })?;
238    if !metadata.file_type().is_file()
239        || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
240    {
241        bail!(
242            "refusing non-regular or reparse-point config lock at {}",
243            crate::quote_os_path(expected_path)
244        );
245    }
246
247    let handle = file.as_raw_handle();
248    let flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
249    // SAFETY: `handle` remains owned by `file`; a null output buffer asks for
250    // the required UTF-16 length.
251    let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
252    if needed == 0 {
253        return Err(std::io::Error::last_os_error()).with_context(|| {
254            format!(
255                "failed to resolve config lock at {}",
256                crate::quote_os_path(expected_path)
257            )
258        });
259    }
260    let mut buffer = vec![0u16; needed as usize + 1];
261    // SAFETY: `buffer` is writable for its declared length and `handle` stays
262    // valid through the call.
263    let written = unsafe {
264        GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags)
265    };
266    if written == 0 || written as usize >= buffer.len() {
267        return Err(std::io::Error::last_os_error()).with_context(|| {
268            format!(
269                "failed to resolve config lock at {}",
270                crate::quote_os_path(expected_path)
271            )
272        });
273    }
274    let actual = OsString::from_wide(&buffer[..written as usize]);
275    if normalize_windows_path_for_comparison(Path::new(&actual))?
276        != normalize_windows_path_for_comparison(expected_path)?
277    {
278        bail!(
279            "config lock was redirected while opening {}",
280            crate::quote_os_path(expected_path)
281        );
282    }
283    Ok(())
284}
285
286#[cfg(windows)]
287fn normalize_windows_path_for_comparison(path: &Path) -> Result<String> {
288    let text = path.to_str().ok_or_else(|| {
289        anyhow::anyhow!(
290            "config lock path {} contains invalid Unicode and cannot be compared safely",
291            crate::quote_os_path(path)
292        )
293    })?;
294    let without_device_prefix = text.strip_prefix(r"\\?\").unwrap_or(text);
295    let normalized_prefix = without_device_prefix.strip_prefix("UNC\\").map_or_else(
296        || without_device_prefix.to_string(),
297        |rest| format!(r"\\{rest}"),
298    );
299    Ok(normalized_prefix
300        .replace('/', "\\")
301        .trim_end_matches('\\')
302        .to_lowercase())
303}
304
305fn prepare_config_path(path: &Path) -> Result<PathBuf> {
306    let absolute = if path.is_absolute() {
307        path.to_path_buf()
308    } else {
309        std::env::current_dir()
310            .context("failed to resolve current directory for config path")?
311            .join(path)
312    };
313    if let Some(parent) = absolute
314        .parent()
315        .filter(|parent| !parent.as_os_str().is_empty())
316    {
317        fs::create_dir_all(parent).with_context(|| {
318            format!(
319                "failed to create config directory {}",
320                crate::quote_os_path(parent)
321            )
322        })?;
323    }
324    normalize_config_file_path(absolute)
325}
326
327fn adjacent_lock_path(path: &Path) -> Result<PathBuf> {
328    let mut file_name = path
329        .file_name()
330        .context("config path must include a file name")?
331        .to_os_string();
332    file_name.push(".lock");
333    Ok(path
334        .parent()
335        .context("config path must include a parent directory")?
336        .join(file_name))
337}
338
339fn read_optional_config(path: &Path) -> Result<Option<String>> {
340    if checked_path_exists(path)? {
341        read_checked_config_file(path).map(Some)
342    } else {
343        Ok(None)
344    }
345}
346
347fn persist_locked(path: &Path, original: Option<&str>, body: &[u8]) -> Result<()> {
348    if original.is_some() {
349        write_one_time_config_backup(path)?;
350    }
351    persistence::atomic_write(path, body)
352        .with_context(|| format!("failed to write config at {}", crate::quote_os_path(path)))
353}
354
355fn remove_key_preserving_leading_decor(table: &mut dyn toml_edit::TableLike, key: &str) -> bool {
356    let mut found = false;
357    let next_key = table.iter().find_map(|(candidate, _)| {
358        if found {
359            Some(candidate.to_owned())
360        } else {
361            found = candidate == key;
362            None
363        }
364    });
365    let leading_prefix = leading_prefix_for_key(table, key);
366    if table.remove(key).is_none() {
367        return false;
368    }
369    let Some(prefix) = leading_prefix else {
370        return true;
371    };
372    let Some(next_key) = next_key else {
373        return true;
374    };
375    if prefix.as_str() == Some("") {
376        return true;
377    }
378    if let Some(mut next_key_decor) = table.key_mut(&next_key)
379        && decor_prefix_is_empty(next_key_decor.leaf_decor())
380    {
381        next_key_decor.leaf_decor_mut().set_prefix(prefix);
382    }
383    true
384}
385
386fn decor_prefix_is_empty(decor: &toml_edit::Decor) -> bool {
387    match decor.prefix() {
388        Some(prefix) => prefix.as_str() == Some(""),
389        None => true,
390    }
391}
392
393fn leading_prefix_for_key(
394    table: &dyn toml_edit::TableLike,
395    key: &str,
396) -> Option<toml_edit::RawString> {
397    table
398        .key(key)
399        .and_then(|key| key.leaf_decor().prefix().cloned())
400        .or_else(|| {
401            table
402                .get(key)
403                .and_then(|item| item.as_value())
404                .and_then(|value| value.decor().prefix().cloned())
405        })
406}
407
408#[derive(Clone, Copy, PartialEq, Eq)]
409enum PathLookup {
410    Create,
411    Existing,
412}
413
414fn table_like_at_path_mut<'a>(
415    root: &'a mut toml_edit::Table,
416    segments: &[&str],
417    lookup: PathLookup,
418) -> Result<Option<&'a mut dyn toml_edit::TableLike>> {
419    let mut current: &mut dyn toml_edit::TableLike = root;
420    for segment in segments {
421        if current.get(segment).is_none() {
422            match lookup {
423                PathLookup::Create => {
424                    let mut table = toml_edit::Table::new();
425                    table.set_implicit(true);
426                    current.insert(segment, toml_edit::Item::Table(table));
427                }
428                PathLookup::Existing => return Ok(None),
429            }
430        }
431        let item = current
432            .get_mut(segment)
433            .expect("segment exists or was inserted above");
434        match item.as_table_like_mut() {
435            Some(table) => current = table,
436            None => match lookup {
437                PathLookup::Create => bail!("`{segment}` in config.toml must be a table"),
438                PathLookup::Existing => return Ok(None),
439            },
440        }
441    }
442    Ok(Some(current))
443}
444
445#[cfg(test)]
446mod tests {
447    #[test]
448    fn healing_lifts_nested_extras_towers_to_the_top_level() {
449        let tmp = tempfile::tempdir().expect("tempdir");
450        let path = tmp.path().join("config.toml");
451        std::fs::write(
452            &path,
453            concat!(
454                "reasoning_effort = \"high\"\n\n",
455                "[projects.\"/live\"]\n",
456                "trust_level = \"trusted\"\n\n",
457                "[extras.extras]\n",
458                "chatgpt_access_token = \"tok\"\n",
459                "reasoning_effort = \"low\"\n\n",
460                "[extras.extras.projects.\"/old\"]\n",
461                "trust_level = \"trusted\"\n",
462            ),
463        )
464        .expect("write fixture");
465
466        super::mutate_config_document(&path, |_| anyhow::Ok(())).expect("mutate heals");
467
468        let healed: toml::Value =
469            toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
470        assert!(
471            healed.get("extras").is_none(),
472            "tower must be gone: {healed}"
473        );
474        assert_eq!(
475            healed["chatgpt_access_token"].as_str(),
476            Some("tok"),
477            "trapped scalar lifted to the root"
478        );
479        assert_eq!(
480            healed["reasoning_effort"].as_str(),
481            Some("high"),
482            "existing top-level values win over shadowed duplicates"
483        );
484        assert_eq!(
485            healed["projects"]["/live"]["trust_level"].as_str(),
486            Some("trusted"),
487            "live records untouched"
488        );
489        // The nested projects table was shadowed by the live one at the
490        // first lift; healing never merges table contents, only lifts whole
491        // missing keys, so the shadowed duplicate is dropped.
492    }
493
494    #[test]
495    fn healing_recovers_project_tables_when_no_top_level_exists() {
496        let tmp = tempfile::tempdir().expect("tempdir");
497        let path = tmp.path().join("config.toml");
498        std::fs::write(
499            &path,
500            concat!(
501                "[extras.extras.extras.projects.\"/old\"]\n",
502                "trust_level = \"trusted\"\n",
503            ),
504        )
505        .expect("write fixture");
506
507        super::mutate_config_document(&path, |_| anyhow::Ok(())).expect("mutate heals");
508
509        let healed: toml::Value =
510            toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
511        assert!(healed.get("extras").is_none(), "{healed}");
512        assert_eq!(
513            healed["projects"]["/old"]["trust_level"].as_str(),
514            Some("trusted"),
515            "trapped trust record restored: {healed}"
516        );
517    }
518
519    use std::sync::{Arc, Barrier};
520    use std::thread;
521
522    use super::*;
523
524    #[test]
525    fn malformed_config_diagnostics_never_echo_secret_contents_or_keys() {
526        let dir = tempfile::tempdir().expect("tempdir");
527        let path = dir.path().join("config.toml");
528        let secret = "sentinel";
529        fs::write(
530            &path,
531            format!("[providers.xai]\napi_key = \"{secret}\" trailing-junk\n"),
532        )
533        .expect("seed malformed config");
534
535        let error = mutate_config_document(&path, |_| Ok(())).expect_err("must reject malformed");
536        let diagnostic = format!("{error:#}");
537        assert!(!diagnostic.contains(secret), "{diagnostic}");
538        assert!(!diagnostic.contains("api_key"), "{diagnostic}");
539        assert!(
540            diagnostic.contains("file contents were omitted"),
541            "{diagnostic}"
542        );
543    }
544
545    #[cfg(windows)]
546    #[test]
547    fn windows_lock_path_comparison_rejects_unpaired_utf16() {
548        use std::ffi::OsString;
549        use std::os::windows::ffi::OsStringExt as _;
550
551        let invalid = PathBuf::from(OsString::from_wide(&[
552            b'C' as u16,
553            b':' as u16,
554            b'\\' as u16,
555            0xd800,
556        ]));
557        assert!(normalize_windows_path_for_comparison(&invalid).is_err());
558        assert_eq!(
559            normalize_windows_path_for_comparison(Path::new(r"C:\Config\A\config.toml.lock"))
560                .unwrap(),
561            normalize_windows_path_for_comparison(Path::new(r"C:\Config\a\config.toml.lock"))
562                .unwrap(),
563            "Windows lock identity must compare case-insensitively"
564        );
565    }
566
567    #[test]
568    fn targeted_mutation_preserves_unknown_provider_data_and_comments() {
569        let dir = tempfile::tempdir().expect("tempdir");
570        let path = dir.path().join("config.toml");
571        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";
572        fs::write(&path, original).expect("seed");
573
574        mutate_config_document(&path, |doc| {
575            set_config_document_value(
576                doc,
577                &["providers", "xai", "external_credentials", "access"],
578                "read_only",
579            )
580        })
581        .expect("mutate");
582
583        let saved = fs::read_to_string(path).expect("read");
584        for expected in [
585            "# operator",
586            "reasoning_stream_style = \"structured\" # keep",
587            "max_concurrency = 7",
588            "custom_future = { preserve = true }",
589            "[providers.my_private]",
590            "api_key_env = \"PRIVATE_KEY\"",
591        ] {
592            assert!(saved.contains(expected), "missing {expected:?}:\n{saved}");
593        }
594    }
595
596    #[test]
597    fn shared_lock_makes_revoke_win_without_losing_unrelated_update() {
598        let dir = tempfile::tempdir().expect("tempdir");
599        let path = dir.path().join("config.toml");
600        fs::write(
601            &path,
602            "[providers.xai.external_credentials]\naccess = \"read_only\"\nprovider = \"xai\"\nsource = \"grok_cli\"\npath = \"/external/auth.json\"\nconsent_version = 1\n",
603        )
604        .expect("seed");
605        let entered = Arc::new(Barrier::new(2));
606        let release = Arc::new(Barrier::new(2));
607        let revoke_path = path.clone();
608        let entered_revoke = Arc::clone(&entered);
609        let release_revoke = Arc::clone(&release);
610        let revoke = thread::spawn(move || {
611            mutate_config_document(&revoke_path, |doc| {
612                entered_revoke.wait();
613                release_revoke.wait();
614                unset_config_document_value(doc, &["providers", "xai", "external_credentials"])?;
615                Ok(())
616            })
617        });
618        entered.wait();
619        let update_path = path.clone();
620        let update = thread::spawn(move || {
621            mutate_config_document(&update_path, |doc| {
622                set_config_document_value(doc, &["tui", "low_motion"], true)
623            })
624        });
625        release.wait();
626        revoke.join().expect("revoke thread").expect("revoke");
627        update.join().expect("update thread").expect("update");
628
629        let saved = fs::read_to_string(path).expect("read");
630        assert!(!saved.contains("external_credentials"), "{saved}");
631        assert!(saved.contains("low_motion = true"), "{saved}");
632    }
633}