返回 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 mutate_config_document_with_migration(path, |doc, _| mutate(doc))
26 }
27
28 /// [`mutate_config_document`], also handing `mutate` the receipt of the legacy
29 /// top-level `base_url` / `api_key` move this same write made, so a caller can
30 /// tell a value it just moved from one that was already in its table.
31 pub fn mutate_config_document_with_migration<T, F>(path: &Path, mutate: F) -> Result<T>
32 where
33 F: FnOnce(&mut toml_edit::DocumentMut, &crate::legacy_root::LegacyRootMigration) -> Result<T>,
34 {
35 mutate_locked(path, mutate).map(|(result, _)| result)
36 }
37
38 /// [`mutate_config_document`], also returning what undoes exactly this write.
39 ///
40 /// Both halves are taken under the lock: the bytes this call left on disk and
41 /// the document as it stood just before `mutate` ran (after this writer's own
42 /// one-way repairs), or the file's absence. A caller whose follow-up step is
43 /// refused can then take back only its own change with
44 /// [`ConfigDocumentUndo::undo`].
45 pub fn mutate_config_document_undoable<T, F>(
46 path: &Path,
47 mutate: F,
48 ) -> Result<(T, ConfigDocumentUndo)>
49 where
50 F: FnOnce(&mut toml_edit::DocumentMut) -> Result<T>,
51 {
52 mutate_locked(path, |doc, _| mutate(doc))
53 }
54
55 /// The undoable write, also retaining the migration performed under this same
56 /// lock. The caller can validate a captured route against the freshly moved
57 /// document before writing its delta; undo still compares the exact written bytes.
58 pub fn mutate_config_document_undoable_with_migration<T, F>(
59 path: &Path,
60 mutate: F,
61 ) -> Result<(T, ConfigDocumentUndo)>
62 where
63 F: FnOnce(&mut toml_edit::DocumentMut, &crate::legacy_root::LegacyRootMigration) -> Result<T>,
64 {
65 mutate_locked(path, mutate)
66 }
67
68 fn mutate_locked<T, F>(path: &Path, mutate: F) -> Result<(T, ConfigDocumentUndo)>
69 where
70 F: FnOnce(&mut toml_edit::DocumentMut, &crate::legacy_root::LegacyRootMigration) -> Result<T>,
71 {
72 let requested = path.to_path_buf();
73 with_config_write_lock(path, |path| {
74 let original = read_optional_config(path)?;
75 let mut document = match original.as_deref() {
76 Some(raw) if !raw.trim().is_empty() => {
77 raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
78 anyhow::anyhow!(
79 "failed to parse config at {}; file contents were omitted",
80 crate::quote_os_path(path)
81 )
82 })?
83 }
84 _ => toml_edit::DocumentMut::new(),
85 };
86 heal_extras_nesting(&mut document);
87 // Move legacy top-level `base_url` / `api_key` into their provider
88 // tables as part of this write (#6394). Conflicting pairs stay put
89 // unless this very write changes the table side.
90 let moved = crate::legacy_root::apply_to_document(&mut document, None);
91 let conflicts = crate::legacy_root::conflict_snapshot(&document);
92 let restore = original.as_ref().map(|_| document.to_string());
93 let result = mutate(&mut document, &moved)?;
94 crate::legacy_root::settle_conflicts_after_write(&mut document, conflicts);
95 let body = document.to_string();
96 if original.as_deref() == Some(body.as_str()) || (original.is_none() && body.is_empty()) {
97 let undo = ConfigDocumentUndo {
98 path: requested,
99 written: original,
100 restore,
101 };
102 return Ok((result, undo));
103 }
104 if moved.changes_file()
105 && let Some(original) = original.as_deref()
106 {
107 crate::note_legacy_root_file_migration(path, original)?;
108 }
109 persist_locked(path, original.as_deref(), body.as_bytes())?;
110 let undo = ConfigDocumentUndo {
111 path: requested,
112 written: Some(body),
113 restore,
114 };
115 Ok((result, undo))
116 })
117 }
118
119 /// Takes back one [`mutate_config_document_undoable`] write, and only that.
120 #[derive(Debug, Clone)]
121 pub struct ConfigDocumentUndo {
122 path: PathBuf,
123 /// The bytes that write left on disk; `None` when the file stayed absent.
124 written: Option<String>,
125 /// What `undo` puts back; `None` removes the file that write created.
126 restore: Option<String>,
127 }
128
129 impl ConfigDocumentUndo {
130 /// Put the document back as it was before the write, but only while the
131 /// file still holds exactly the bytes that write left: any newer save
132 /// wins. Returns `false` when the file changed since and was left alone.
133 pub fn undo(&self) -> Result<bool> {
134 with_config_write_lock(&self.path, |path| {
135 let current = read_optional_config(path)?;
136 if current == self.restore {
137 return Ok(true);
138 }
139 if current != self.written {
140 return Ok(false);
141 }
142 match self.restore.as_deref() {
143 Some(body) => persist_locked(path, current.as_deref(), body.as_bytes())?,
144 None => fs::remove_file(path).with_context(|| {
145 format!("failed to remove config at {}", crate::quote_os_path(path))
146 })?,
147 }
148 Ok(true)
149 })
150 }
151 }
152
153 /// Lift keys trapped under literal `[extras]` tables back to the top level.
154 ///
155 /// The config structs flatten unknown keys into an `extras` map; a historic
156 /// writer serialized that map under a literal `extras` key, and every
157 /// subsequent buggy round-trip nested it one level deeper
158 /// (`[extras.extras.extras.projects."..."]`). That silently strips real
159 /// state — workspace trust records, profiles, saved tokens — from every
160 /// reader that looks at the canonical top-level tables (2026-07-23 user
161 /// report: saved permission/trust ignored on each new session).
162 ///
163 /// Healing runs on every config mutation: entries move up one level per
164 /// pass (existing top-level values always win; shadowed duplicates are
165 /// dropped), until no literal `extras` table remains. Bounded passes keep a
166 /// pathological file from looping.
167 ///
168 /// An `extras` key that is *not* table-like (a string, array, or number) has
169 /// nothing to lift, so it is left exactly where it is. Removing it would
170 /// delete user data this function cannot heal, on every subsequent write.
171 pub fn heal_extras_nesting(document: &mut toml_edit::DocumentMut) -> bool {
172 let mut healed = false;
173 for _ in 0..16 {
174 if document
175 .get("extras")
176 .is_none_or(|item| !item.is_table_like())
177 {
178 break;
179 }
180 let Some(extras) = document
181 .remove("extras")
182 .and_then(|item| item.into_table().ok())
183 else {
184 break;
185 };
186 healed = true;
187 for (key, value) in extras {
188 if document.get(&key).is_none() {
189 document.insert(&key, value);
190 }
191 }
192 }
193 healed
194 }
195
196 /// What `codewhale config migrate` would do to the file at `path`, without
197 /// writing it (#6394).
198 pub fn preview_legacy_root_config(
199 path: &Path,
200 prefer: Option<crate::legacy_root::LegacyRootPrefer>,
201 ) -> Result<crate::legacy_root::LegacyRootMigration> {
202 let Some(raw) = read_optional_config(path)? else {
203 return Ok(crate::legacy_root::LegacyRootMigration::default());
204 };
205 let document = raw.parse::<toml_edit::DocumentMut>().map_err(|_| {
206 anyhow::anyhow!(
207 "failed to parse config at {}; file contents were omitted",
208 crate::quote_os_path(path)
209 )
210 })?;
211 Ok(crate::legacy_root::preview_document(&document, prefer))
212 }
213
214 /// Move legacy top-level `base_url` / `api_key` into their provider tables
215 /// on disk, keeping comments. A conflicting pair changes only when `prefer`
216 /// says which side to keep. Before the first write a one-time,
217 /// credential-free backup is kept; its path is returned when one exists.
218 pub fn migrate_legacy_root_config(
219 path: &Path,
220 prefer: Option<crate::legacy_root::LegacyRootPrefer>,
221 ) -> Result<(crate::legacy_root::LegacyRootMigration, Option<PathBuf>)> {
222 with_config_write_lock(path, |path| {
223 let Some(original) = read_optional_config(path)? else {
224 return Ok((crate::legacy_root::LegacyRootMigration::default(), None));
225 };
226 let mut document = original.parse::<toml_edit::DocumentMut>().map_err(|_| {
227 anyhow::anyhow!(
228 "failed to parse config at {}; file contents were omitted",
229 crate::quote_os_path(path)
230 )
231 })?;
232 let receipt = crate::legacy_root::apply_to_document(&mut document, prefer);
233 if !receipt.changes_file() {
234 return Ok((receipt, None));
235 }
236 let backup = crate::write_legacy_root_backup(path, &original)?;
237 persist_locked(path, Some(&original), document.to_string().as_bytes())?;
238 Ok((receipt, Some(backup)))
239 })
240 }
241
242 /// Create a config file only if it is still absent when the shared lock is
243 /// acquired. This closes the `exists()`/create race in first-run writers.
244 pub fn create_config_document(path: &Path, body: &str) -> Result<()> {
245 replace_config_document_if_unchanged(path, None, body)
246 }
247
248 /// Replace a full typed snapshot only when on-disk bytes still equal the
249 /// snapshot the caller originally loaded. `None` means the file was absent.
250 pub fn replace_config_document_if_unchanged(
251 path: &Path,
252 expected: Option<&str>,
253 body: &str,
254 ) -> Result<()> {
255 with_config_write_lock(path, |path| {
256 let current = read_optional_config(path)?;
257 if current.as_deref() == Some(body) {
258 return Ok(());
259 }
260 if current.as_deref() != expected {
261 bail!(
262 "config changed after it was loaded; reload {} and retry instead of overwriting concurrent changes",
263 crate::quote_os_path(path)
264 );
265 }
266 persist_locked(path, current.as_deref(), body.as_bytes())
267 })
268 }
269
270 /// Set a value at `segments`, creating implicit parent tables while preserving
271 /// existing key/value decor.
272 pub fn set_config_document_value(
273 doc: &mut toml_edit::DocumentMut,
274 segments: &[&str],
275 value: impl Into<toml_edit::Value>,
276 ) -> Result<()> {
277 let (key, parents) = segments
278 .split_last()
279 .context("config value path must not be empty")?;
280 let table = table_like_at_path_mut(doc.as_table_mut(), parents, PathLookup::Create)?
281 .expect("Create lookups always yield a table");
282 match table.get_mut(key) {
283 Some(item) => {
284 let mut value = value.into();
285 if let Some(existing) = item.as_value() {
286 *value.decor_mut() = existing.decor().clone();
287 }
288 *item = toml_edit::Item::Value(value);
289 }
290 None => {
291 table.insert(key, toml_edit::value(value));
292 }
293 }
294 Ok(())
295 }
296
297 /// Remove a value at `segments` without disturbing unrelated tables or decor.
298 pub fn unset_config_document_value(
299 doc: &mut toml_edit::DocumentMut,
300 segments: &[&str],
301 ) -> Result<bool> {
302 let (key, parents) = segments
303 .split_last()
304 .context("config value path must not be empty")?;
305 let orphaned_root_prefix = (parents.is_empty() && doc.as_table().len() == 1)
306 .then(|| leading_prefix_for_key(doc.as_table(), key))
307 .flatten();
308 let removed = {
309 let Some(table) =
310 table_like_at_path_mut(doc.as_table_mut(), parents, PathLookup::Existing)?
311 else {
312 return Ok(false);
313 };
314 remove_key_preserving_leading_decor(table, key)
315 };
316 if removed
317 && let Some(prefix) = orphaned_root_prefix
318 && prefix.as_str().is_some_and(|prefix| !prefix.is_empty())
319 {
320 let trailing = format!(
321 "{}{}",
322 prefix.as_str().unwrap_or_default(),
323 doc.trailing().as_str().unwrap_or_default()
324 );
325 doc.set_trailing(trailing);
326 }
327 Ok(removed)
328 }
329
330 /// Serialize a complete read-modify-write operation against a canonical
331 /// configuration path. The callback must not acquire this same lock again.
332 pub fn with_config_write_lock<T>(
333 path: &Path,
334 operation: impl FnOnce(&Path) -> Result<T>,
335 ) -> Result<T> {
336 let path = prepare_config_path(path)?;
337 let lock_path = adjacent_lock_path(&path)?;
338 super::reject_path_symlink(&lock_path)?;
339
340 let mut options = fs::OpenOptions::new();
341 options.read(true).write(true).create(true);
342 #[cfg(unix)]
343 {
344 use std::os::unix::fs::OpenOptionsExt as _;
345 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
346 }
347 #[cfg(windows)]
348 {
349 use std::os::windows::fs::OpenOptionsExt as _;
350 use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
351 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
352 }
353 let lock_file = options.open(&lock_path).with_context(|| {
354 format!(
355 "failed to open config lock at {}",
356 crate::quote_os_path(&lock_path)
357 )
358 })?;
359 #[cfg(unix)]
360 {
361 use std::os::unix::fs::PermissionsExt as _;
362 lock_file
363 .set_permissions(fs::Permissions::from_mode(0o600))
364 .with_context(|| {
365 format!(
366 "failed to secure config lock at {}",
367 crate::quote_os_path(&lock_path)
368 )
369 })?;
370 }
371 #[cfg(windows)]
372 validate_windows_lock_handle(&lock_file, &lock_path)?;
373 let mut lock = fd_lock::RwLock::new(lock_file);
374 let _guard = lock.write().with_context(|| {
375 format!(
376 "failed to acquire config lock at {}",
377 crate::quote_os_path(&lock_path)
378 )
379 })?;
380 operation(&path)
381 }
382
383 #[cfg(windows)]
384 fn validate_windows_lock_handle(file: &fs::File, expected_path: &Path) -> Result<()> {
385 use std::ffi::OsString;
386 use std::os::windows::ffi::OsStringExt as _;
387 use std::os::windows::fs::MetadataExt as _;
388 use std::os::windows::io::AsRawHandle as _;
389 use windows_sys::Win32::Storage::FileSystem::{
390 FILE_ATTRIBUTE_REPARSE_POINT, FILE_NAME_NORMALIZED, GetFinalPathNameByHandleW,
391 VOLUME_NAME_DOS,
392 };
393
394 let metadata = file.metadata().with_context(|| {
395 format!(
396 "failed to inspect config lock at {}",
397 crate::quote_os_path(expected_path)
398 )
399 })?;
400 if !metadata.file_type().is_file()
401 || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
402 {
403 bail!(
404 "refusing non-regular or reparse-point config lock at {}",
405 crate::quote_os_path(expected_path)
406 );
407 }
408
409 let handle = file.as_raw_handle();
410 let flags = FILE_NAME_NORMALIZED | VOLUME_NAME_DOS;
411 // SAFETY: `handle` remains owned by `file`; a null output buffer asks for
412 // the required UTF-16 length.
413 let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
414 if needed == 0 {
415 return Err(std::io::Error::last_os_error()).with_context(|| {
416 format!(
417 "failed to resolve config lock at {}",
418 crate::quote_os_path(expected_path)
419 )
420 });
421 }
422 let mut buffer = vec![0u16; needed as usize + 1];
423 // SAFETY: `buffer` is writable for its declared length and `handle` stays
424 // valid through the call.
425 let written = unsafe {
426 GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags)
427 };
428 if written == 0 || written as usize >= buffer.len() {
429 return Err(std::io::Error::last_os_error()).with_context(|| {
430 format!(
431 "failed to resolve config lock at {}",
432 crate::quote_os_path(expected_path)
433 )
434 });
435 }
436 let actual = OsString::from_wide(&buffer[..written as usize]);
437 if normalize_windows_path_for_comparison(Path::new(&actual))?
438 != normalize_windows_path_for_comparison(expected_path)?
439 {
440 bail!(
441 "config lock was redirected while opening {}",
442 crate::quote_os_path(expected_path)
443 );
444 }
445 Ok(())
446 }
447
448 #[cfg(windows)]
449 fn normalize_windows_path_for_comparison(path: &Path) -> Result<String> {
450 let text = path.to_str().ok_or_else(|| {
451 anyhow::anyhow!(
452 "config lock path {} contains invalid Unicode and cannot be compared safely",
453 crate::quote_os_path(path)
454 )
455 })?;
456 let without_device_prefix = text.strip_prefix(r"\\?\").unwrap_or(text);
457 let normalized_prefix = without_device_prefix.strip_prefix("UNC\\").map_or_else(
458 || without_device_prefix.to_string(),
459 |rest| format!(r"\\{rest}"),
460 );
461 Ok(normalized_prefix
462 .replace('/', "\\")
463 .trim_end_matches('\\')
464 .to_lowercase())
465 }
466
467 fn prepare_config_path(path: &Path) -> Result<PathBuf> {
468 let absolute = if path.is_absolute() {
469 path.to_path_buf()
470 } else {
471 std::env::current_dir()
472 .context("failed to resolve current directory for config path")?
473 .join(path)
474 };
475 if let Some(parent) = absolute
476 .parent()
477 .filter(|parent| !parent.as_os_str().is_empty())
478 {
479 fs::create_dir_all(parent).with_context(|| {
480 format!(
481 "failed to create config directory {}",
482 crate::quote_os_path(parent)
483 )
484 })?;
485 }
486 normalize_config_file_path(absolute)
487 }
488
489 fn adjacent_lock_path(path: &Path) -> Result<PathBuf> {
490 let mut file_name = path
491 .file_name()
492 .context("config path must include a file name")?
493 .to_os_string();
494 file_name.push(".lock");
495 Ok(path
496 .parent()
497 .context("config path must include a parent directory")?
498 .join(file_name))
499 }
500
501 fn read_optional_config(path: &Path) -> Result<Option<String>> {
502 if checked_path_exists(path)? {
503 read_checked_config_file(path).map(Some)
504 } else {
505 Ok(None)
506 }
507 }
508
509 fn persist_locked(path: &Path, original: Option<&str>, body: &[u8]) -> Result<()> {
510 if original.is_some() {
511 write_one_time_config_backup(path)?;
512 }
513 persistence::atomic_write(path, body)
514 .with_context(|| format!("failed to write config at {}", crate::quote_os_path(path)))
515 }
516
517 pub(crate) fn remove_key_preserving_leading_decor(
518 table: &mut dyn toml_edit::TableLike,
519 key: &str,
520 ) -> bool {
521 let mut found = false;
522 let next_key = table.iter().find_map(|(candidate, _)| {
523 if found {
524 Some(candidate.to_owned())
525 } else {
526 found = candidate == key;
527 None
528 }
529 });
530 let leading_prefix = leading_prefix_for_key(table, key);
531 if table.remove(key).is_none() {
532 return false;
533 }
534 let Some(prefix) = leading_prefix else {
535 return true;
536 };
537 let Some(next_key) = next_key else {
538 return true;
539 };
540 if prefix.as_str() == Some("") {
541 return true;
542 }
543 if let Some(mut next_key_decor) = table.key_mut(&next_key)
544 && decor_prefix_is_empty(next_key_decor.leaf_decor())
545 {
546 next_key_decor.leaf_decor_mut().set_prefix(prefix);
547 }
548 true
549 }
550
551 fn decor_prefix_is_empty(decor: &toml_edit::Decor) -> bool {
552 match decor.prefix() {
553 Some(prefix) => prefix.as_str() == Some(""),
554 None => true,
555 }
556 }
557
558 fn leading_prefix_for_key(
559 table: &dyn toml_edit::TableLike,
560 key: &str,
561 ) -> Option<toml_edit::RawString> {
562 table
563 .key(key)
564 .and_then(|key| key.leaf_decor().prefix().cloned())
565 .or_else(|| {
566 table
567 .get(key)
568 .and_then(|item| item.as_value())
569 .and_then(|value| value.decor().prefix().cloned())
570 })
571 }
572
573 #[derive(Clone, Copy, PartialEq, Eq)]
574 enum PathLookup {
575 Create,
576 Existing,
577 }
578
579 fn table_like_at_path_mut<'a>(
580 root: &'a mut toml_edit::Table,
581 segments: &[&str],
582 lookup: PathLookup,
583 ) -> Result<Option<&'a mut dyn toml_edit::TableLike>> {
584 let mut current: &mut dyn toml_edit::TableLike = root;
585 for segment in segments {
586 if current.get(segment).is_none() {
587 match lookup {
588 PathLookup::Create => {
589 let mut table = toml_edit::Table::new();
590 table.set_implicit(true);
591 current.insert(segment, toml_edit::Item::Table(table));
592 }
593 PathLookup::Existing => return Ok(None),
594 }
595 }
596 let item = current
597 .get_mut(segment)
598 .expect("segment exists or was inserted above");
599 match item.as_table_like_mut() {
600 Some(table) => current = table,
601 None => match lookup {
602 PathLookup::Create => bail!("`{segment}` in config.toml must be a table"),
603 PathLookup::Existing => return Ok(None),
604 },
605 }
606 }
607 Ok(Some(current))
608 }
609
610 #[cfg(test)]
611 mod tests {
612 fn set_model(doc: &mut toml_edit::DocumentMut, model: &str) -> anyhow::Result<()> {
613 super::set_config_document_value(doc, &["model"], model)
614 }
615
616 #[test]
617 fn undo_restores_the_document_and_removes_a_file_it_created() {
618 let tmp = tempfile::tempdir().expect("tempdir");
619 let path = tmp.path().join("config.toml");
620 let before = "# keep me\nmodel = \"a\"\n";
621 std::fs::write(&path, before).expect("write fixture");
622 let ((), undo) = super::mutate_config_document_undoable(&path, |doc| set_model(doc, "b"))
623 .expect("write");
624 assert!(
625 std::fs::read_to_string(&path)
626 .expect("read")
627 .contains("\"b\"")
628 );
629 assert!(undo.undo().expect("undo"));
630 assert_eq!(std::fs::read_to_string(&path).expect("read"), before);
631
632 // A file this write created goes away again, not left behind empty.
633 let absent = tmp.path().join("fresh.toml");
634 let ((), undo) = super::mutate_config_document_undoable(&absent, |doc| set_model(doc, "b"))
635 .expect("write");
636 assert!(absent.exists());
637 assert!(undo.undo().expect("undo"));
638 assert!(!absent.exists(), "undo must not leave an empty config file");
639 }
640
641 #[test]
642 fn undo_never_overwrites_a_newer_save() {
643 let tmp = tempfile::tempdir().expect("tempdir");
644 let path = tmp.path().join("config.toml");
645 std::fs::write(&path, "model = \"a\"\n").expect("write fixture");
646 let ((), undo) = super::mutate_config_document_undoable(&path, |doc| set_model(doc, "b"))
647 .expect("write");
648 super::mutate_config_document(&path, |doc| set_model(doc, "c")).expect("newer save");
649 assert!(!undo.undo().expect("undo"), "a changed file is reported");
650 assert_eq!(
651 std::fs::read_to_string(&path).expect("read"),
652 "model = \"c\"\n"
653 );
654
655 // The same holds when the newer save removed a file the write created.
656 let absent = tmp.path().join("fresh.toml");
657 let ((), undo) = super::mutate_config_document_undoable(&absent, |doc| set_model(doc, "b"))
658 .expect("write");
659 std::fs::write(&absent, "model = \"c\"\n").expect("newer save");
660 assert!(!undo.undo().expect("undo"));
661 assert_eq!(
662 std::fs::read_to_string(&absent).expect("read"),
663 "model = \"c\"\n"
664 );
665 }
666
667 #[test]
668 fn healing_keeps_a_non_table_extras_key_it_cannot_lift() {
669 // `extras` is where the config structs flatten unknown keys, so a
670 // scalar or array under that exact name round-trips through the typed
671 // path as ordinary user data. Healing used to `remove()` it before
672 // discovering it was not a table, dropping it on the very next
673 // `codewhale config set` — and reporting `healed == false` while doing
674 // so.
675 for body in [
676 "extras = \"opaque\"\nmodel = \"m\"\n",
677 "extras = [1, 2]\nmodel = \"m\"\n",
678 "model = \"m\"\nextras = 7\n",
679 ] {
680 let tmp = tempfile::tempdir().expect("tempdir");
681 let path = tmp.path().join("config.toml");
682 std::fs::write(&path, body).expect("write fixture");
683
684 super::mutate_config_document(&path, |doc| {
685 super::set_config_document_value(doc, &["tui", "low_motion"], true)
686 })
687 .expect("mutate");
688
689 let saved = std::fs::read_to_string(&path).expect("read");
690 let parsed: toml::Value = toml::from_str(&saved).expect("parse");
691 assert!(
692 parsed.get("extras").is_some(),
693 "non-table `extras` was deleted by an unrelated write: {saved}"
694 );
695 assert!(saved.contains("low_motion = true"), "{saved}");
696 }
697 }
698
699 #[test]
700 fn healing_still_lifts_an_inline_extras_table() {
701 // The preservation guard above must not stop the real healing path:
702 // an inline table is table-like and still gets lifted.
703 let mut doc = "extras = { trust = true }\nmodel = \"m\"\n"
704 .parse::<toml_edit::DocumentMut>()
705 .expect("parse");
706 assert!(super::heal_extras_nesting(&mut doc));
707 let rendered = doc.to_string();
708 assert!(rendered.contains("trust = true"), "{rendered}");
709 assert!(!rendered.contains("extras"), "{rendered}");
710 }
711
712 #[test]
713 fn healing_lifts_nested_extras_towers_to_the_top_level() {
714 let tmp = tempfile::tempdir().expect("tempdir");
715 let path = tmp.path().join("config.toml");
716 std::fs::write(
717 &path,
718 concat!(
719 "reasoning_effort = \"high\"\n\n",
720 "[projects.\"/live\"]\n",
721 "trust_level = \"trusted\"\n\n",
722 "[extras.extras]\n",
723 "chatgpt_access_token = \"tok\"\n",
724 "reasoning_effort = \"low\"\n\n",
725 "[extras.extras.projects.\"/old\"]\n",
726 "trust_level = \"trusted\"\n",
727 ),
728 )
729 .expect("write fixture");
730
731 super::mutate_config_document(&path, |_| anyhow::Ok(())).expect("mutate heals");
732
733 let healed: toml::Value =
734 toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
735 assert!(
736 healed.get("extras").is_none(),
737 "tower must be gone: {healed}"
738 );
739 assert_eq!(
740 healed["chatgpt_access_token"].as_str(),
741 Some("tok"),
742 "trapped scalar lifted to the root"
743 );
744 assert_eq!(
745 healed["reasoning_effort"].as_str(),
746 Some("high"),
747 "existing top-level values win over shadowed duplicates"
748 );
749 assert_eq!(
750 healed["projects"]["/live"]["trust_level"].as_str(),
751 Some("trusted"),
752 "live records untouched"
753 );
754 // The nested projects table was shadowed by the live one at the
755 // first lift; healing never merges table contents, only lifts whole
756 // missing keys, so the shadowed duplicate is dropped.
757 }
758
759 #[test]
760 fn healing_recovers_project_tables_when_no_top_level_exists() {
761 let tmp = tempfile::tempdir().expect("tempdir");
762 let path = tmp.path().join("config.toml");
763 std::fs::write(
764 &path,
765 concat!(
766 "[extras.extras.extras.projects.\"/old\"]\n",
767 "trust_level = \"trusted\"\n",
768 ),
769 )
770 .expect("write fixture");
771
772 super::mutate_config_document(&path, |_| anyhow::Ok(())).expect("mutate heals");
773
774 let healed: toml::Value =
775 toml::from_str(&std::fs::read_to_string(&path).expect("read")).expect("parse");
776 assert!(healed.get("extras").is_none(), "{healed}");
777 assert_eq!(
778 healed["projects"]["/old"]["trust_level"].as_str(),
779 Some("trusted"),
780 "trapped trust record restored: {healed}"
781 );
782 }
783
784 use std::sync::{Arc, Barrier};
785 use std::thread;
786
787 use super::*;
788
789 #[test]
790 fn malformed_config_diagnostics_never_echo_secret_contents_or_keys() {
791 let dir = tempfile::tempdir().expect("tempdir");
792 let path = dir.path().join("config.toml");
793 let secret = "sentinel";
794 fs::write(
795 &path,
796 format!("[providers.xai]\napi_key = \"{secret}\" trailing-junk\n"),
797 )
798 .expect("seed malformed config");
799
800 let error = mutate_config_document(&path, |_| Ok(())).expect_err("must reject malformed");
801 let diagnostic = format!("{error:#}");
802 assert!(!diagnostic.contains(secret), "{diagnostic}");
803 assert!(!diagnostic.contains("api_key"), "{diagnostic}");
804 assert!(
805 diagnostic.contains("file contents were omitted"),
806 "{diagnostic}"
807 );
808 }
809
810 #[cfg(windows)]
811 #[test]
812 fn windows_lock_path_comparison_rejects_unpaired_utf16() {
813 use std::ffi::OsString;
814 use std::os::windows::ffi::OsStringExt as _;
815
816 let invalid = PathBuf::from(OsString::from_wide(&[
817 b'C' as u16,
818 b':' as u16,
819 b'\\' as u16,
820 0xd800,
821 ]));
822 assert!(normalize_windows_path_for_comparison(&invalid).is_err());
823 assert_eq!(
824 normalize_windows_path_for_comparison(Path::new(r"C:\Config\A\config.toml.lock"))
825 .unwrap(),
826 normalize_windows_path_for_comparison(Path::new(r"C:\Config\a\config.toml.lock"))
827 .unwrap(),
828 "Windows lock identity must compare case-insensitively"
829 );
830 }
831
832 #[test]
833 fn targeted_mutation_preserves_unknown_provider_data_and_comments() {
834 let dir = tempfile::tempdir().expect("tempdir");
835 let path = dir.path().join("config.toml");
836 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";
837 fs::write(&path, original).expect("seed");
838
839 mutate_config_document(&path, |doc| {
840 set_config_document_value(
841 doc,
842 &["providers", "xai", "external_credentials", "access"],
843 "read_only",
844 )
845 })
846 .expect("mutate");
847
848 let saved = fs::read_to_string(path).expect("read");
849 for expected in [
850 "# operator",
851 "reasoning_stream_style = \"structured\" # keep",
852 "max_concurrency = 7",
853 "custom_future = { preserve = true }",
854 "[providers.my_private]",
855 "api_key_env = \"PRIVATE_KEY\"",
856 ] {
857 assert!(saved.contains(expected), "missing {expected:?}:\n{saved}");
858 }
859 }
860
861 #[test]
862 fn shared_lock_makes_revoke_win_without_losing_unrelated_update() {
863 let dir = tempfile::tempdir().expect("tempdir");
864 let path = dir.path().join("config.toml");
865 fs::write(
866 &path,
867 "[providers.xai.external_credentials]\naccess = \"read_only\"\nprovider = \"xai\"\nsource = \"grok_cli\"\npath = \"/external/auth.json\"\nconsent_version = 1\n",
868 )
869 .expect("seed");
870 let entered = Arc::new(Barrier::new(2));
871 let release = Arc::new(Barrier::new(2));
872 let revoke_path = path.clone();
873 let entered_revoke = Arc::clone(&entered);
874 let release_revoke = Arc::clone(&release);
875 let revoke = thread::spawn(move || {
876 mutate_config_document(&revoke_path, |doc| {
877 entered_revoke.wait();
878 release_revoke.wait();
879 unset_config_document_value(doc, &["providers", "xai", "external_credentials"])?;
880 Ok(())
881 })
882 });
883 entered.wait();
884 let update_path = path.clone();
885 let update = thread::spawn(move || {
886 mutate_config_document(&update_path, |doc| {
887 set_config_document_value(doc, &["tui", "low_motion"], true)
888 })
889 });
890 release.wait();
891 revoke.join().expect("revoke thread").expect("revoke");
892 update.join().expect("update thread").expect("update");
893
894 let saved = fs::read_to_string(path).expect("read");
895 assert!(!saved.contains("external_credentials"), "{saved}");
896 assert!(saved.contains("low_motion = true"), "{saved}");
897 }
898 }
899
899 lines RUST