1use 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
19pub 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
49pub 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
82pub fn create_config_document(path: &Path, body: &str) -> Result<()> {
85 replace_config_document_if_unchanged(path, None, body)
86}
87
88pub 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
110pub 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
137pub 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 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 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 }
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}