返回 CodeWhale
config_document.rs
根目录 / crates / config / src / 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
9 use std::fs;
10 use std::path::{Path, PathBuf};
11
12 use anyhow::{Context, Result, bail};
13
14 use 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.
21 pub fn mutate_config_document<T, F>(path: &Path, mutate: F) -> Result<T>
22 where
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.
63 pub 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.
84 pub 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.
90 pub 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.
112 pub 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.
138 pub 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
170 pub(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)]
222 fn 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)]
287 fn 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
305 fn 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
327 fn 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
339 fn 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
347 fn 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
355 fn 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
386 fn 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
393 fn 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)]
409 enum PathLookup {
410 Create,
411 Existing,
412 }
413
414 fn 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)]
446 mod 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 }
634
634 lines RUST