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