1use std::io::Write;
15use std::path::{Path, PathBuf};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use crate::config::{
19 ConfigError, ConfigValue, Scope, TomlEditResult, normalize_scope, validate_bootstrap_value,
20 validate_key_scope, with_path_context,
21};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25#[must_use = "TomlStoreEditOptions must be passed to a TOML store edit function to have any effect"]
26pub struct TomlStoreEditOptions {
27 mode: TomlStoreEditMode,
28 secret_permissions: TomlSecretPermissions,
29}
30
31impl TomlStoreEditOptions {
32 pub const fn new() -> Self {
34 Self {
35 mode: TomlStoreEditMode::Persist,
36 secret_permissions: TomlSecretPermissions::ProcessDefault,
37 }
38 }
39
40 pub const fn dry_run() -> Self {
43 Self {
44 mode: TomlStoreEditMode::DryRun,
45 secret_permissions: TomlSecretPermissions::ProcessDefault,
46 }
47 }
48
49 pub const fn with_mode(mut self, mode: TomlStoreEditMode) -> Self {
51 self.mode = mode;
52 self
53 }
54
55 pub const fn with_secret_permissions(
58 mut self,
59 secret_permissions: TomlSecretPermissions,
60 ) -> Self {
61 self.secret_permissions = secret_permissions;
62 self
63 }
64
65 pub const fn for_secrets(mut self) -> Self {
67 self.secret_permissions = TomlSecretPermissions::OwnerOnly;
68 self
69 }
70
71 pub(crate) const fn should_write(self) -> bool {
72 matches!(self.mode, TomlStoreEditMode::Persist)
73 }
74
75 pub(crate) const fn strict_secret_permissions(self) -> bool {
76 matches!(self.secret_permissions, TomlSecretPermissions::OwnerOnly)
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum TomlStoreEditMode {
84 #[default]
86 Persist,
87 DryRun,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum TomlSecretPermissions {
94 #[default]
96 ProcessDefault,
97 OwnerOnly,
99}
100
101pub fn set_scoped_value_in_toml(
151 path: &Path,
152 key: &str,
153 value: &ConfigValue,
154 scope: &Scope,
155 options: TomlStoreEditOptions,
156) -> Result<TomlEditResult, ConfigError> {
157 edit_scoped_value_in_toml(path, key, scope, TomlEditOperation::Set(value), options)
158}
159
160pub fn unset_scoped_value_in_toml(
168 path: &Path,
169 key: &str,
170 scope: &Scope,
171 options: TomlStoreEditOptions,
172) -> Result<TomlEditResult, ConfigError> {
173 edit_scoped_value_in_toml(path, key, scope, TomlEditOperation::Unset, options)
174}
175
176enum TomlEditOperation<'a> {
177 Set(&'a ConfigValue),
178 Unset,
179}
180
181fn edit_scoped_value_in_toml(
182 path: &Path,
183 key: &str,
184 scope: &Scope,
185 operation: TomlEditOperation<'_>,
186 options: TomlStoreEditOptions,
187) -> Result<TomlEditResult, ConfigError> {
188 let normalized_scope = normalize_scope(scope.clone());
189 crate::config::ConfigSchema::default().validate_writable_key(key)?;
190 validate_key_scope(key, &normalized_scope)?;
191 if let TomlEditOperation::Set(value) = operation {
192 validate_bootstrap_value(key, value)?;
193 }
194 let mut root = load_or_create_toml_root(path)?;
195 let root_table = root
196 .as_table_mut()
197 .ok_or(ConfigError::TomlRootMustBeTable)?;
198
199 let previous = match operation {
200 TomlEditOperation::Set(value) => {
201 let scoped_table = scoped_table_mut(root_table, &normalized_scope)?;
202 set_dotted_value(scoped_table, key, value)?
203 }
204 TomlEditOperation::Unset => unset_dotted_value(root_table, &normalized_scope, key)?,
205 };
206
207 if options.should_write() {
208 write_toml_root(path, &root, options.strict_secret_permissions())?;
209 }
210
211 Ok(TomlEditResult { previous })
212}
213
214fn load_or_create_toml_root(path: &Path) -> Result<toml::Value, ConfigError> {
215 if !path.exists() {
216 return Ok(toml::Value::Table(toml::value::Table::new()));
217 }
218
219 let raw = std::fs::read_to_string(path).map_err(|err| ConfigError::FileRead {
220 path: path.display().to_string(),
221 reason: err.to_string(),
222 })?;
223
224 raw.parse::<toml::Value>().map_err(|err| {
225 with_path_context(
226 path.display().to_string(),
227 ConfigError::TomlParse(err.to_string()),
228 )
229 })
230}
231
232fn write_toml_root(
233 path: &Path,
234 root: &toml::Value,
235 strict_secret_permissions: bool,
236) -> Result<(), ConfigError> {
237 if let Some(parent) = path.parent() {
238 std::fs::create_dir_all(parent).map_err(|err| ConfigError::FileWrite {
239 path: parent.display().to_string(),
240 reason: err.to_string(),
241 })?;
242 }
243
244 let payload =
245 toml::to_string_pretty(root).map_err(|err| ConfigError::TomlParse(err.to_string()))?;
246 write_text_atomic(path, payload.as_bytes(), strict_secret_permissions).map_err(|err| {
247 ConfigError::FileWrite {
248 path: path.display().to_string(),
249 reason: err.to_string(),
250 }
251 })?;
252
253 Ok(())
254}
255
256pub(crate) fn write_text_atomic(
257 path: &Path,
258 payload: &[u8],
259 strict_secret_permissions: bool,
260) -> std::io::Result<()> {
261 let parent = path.parent().unwrap_or_else(|| Path::new("."));
262 let file_name = path.file_name().ok_or_else(|| {
263 std::io::Error::new(
264 std::io::ErrorKind::InvalidInput,
265 format!("path has no file name: {}", path.display()),
266 )
267 })?;
268 let pid = std::process::id();
269 let nonce = SystemTime::now()
270 .duration_since(UNIX_EPOCH)
271 .unwrap_or_default()
272 .as_nanos();
273
274 for attempt in 0..16u8 {
275 let temp_name = format!(
276 ".{}.tmp-{pid}-{nonce}-{attempt}",
277 file_name.to_string_lossy()
278 );
279 let temp_path = parent.join(temp_name);
280 match create_temp_file(&temp_path, strict_secret_permissions) {
281 Ok(mut file) => {
282 file.write_all(payload)?;
283 file.sync_all()?;
284 drop(file);
285 replace_file_atomic(&temp_path, path)?;
286 sync_parent_dir(parent)?;
287 return Ok(());
288 }
289 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
290 Err(err) => return Err(err),
291 }
292 }
293
294 Err(std::io::Error::new(
295 std::io::ErrorKind::AlreadyExists,
296 format!("failed to allocate temp file for {}", path.display()),
297 ))
298}
299
300#[cfg(not(windows))]
301fn replace_file_atomic(source: &Path, destination: &Path) -> std::io::Result<()> {
302 std::fs::rename(source, destination)
303}
304
305#[cfg(windows)]
306fn replace_file_atomic(source: &Path, destination: &Path) -> std::io::Result<()> {
307 use std::os::windows::ffi::OsStrExt;
308
309 const MOVEFILE_REPLACE_EXISTING: u32 = 0x1;
310 const MOVEFILE_WRITE_THROUGH: u32 = 0x8;
311
312 unsafe extern "system" {
313 fn MoveFileExW(
314 lp_existing_file_name: *const u16,
315 lp_new_file_name: *const u16,
316 dw_flags: u32,
317 ) -> i32;
318 }
319
320 let source_wide = source
321 .as_os_str()
322 .encode_wide()
323 .chain(std::iter::once(0))
324 .collect::<Vec<_>>();
325 let destination_wide = destination
326 .as_os_str()
327 .encode_wide()
328 .chain(std::iter::once(0))
329 .collect::<Vec<_>>();
330
331 let replaced = unsafe {
332 MoveFileExW(
333 source_wide.as_ptr(),
334 destination_wide.as_ptr(),
335 MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
336 )
337 };
338 if replaced != 0 {
339 Ok(())
340 } else {
341 Err(std::io::Error::last_os_error())
342 }
343}
344
345#[cfg(unix)]
346fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
347 std::fs::File::open(path)?.sync_all()
348}
349
350#[cfg(not(unix))]
351fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
352 Ok(())
353}
354
355#[cfg(unix)]
356fn create_temp_file(
357 path: &Path,
358 strict_secret_permissions: bool,
359) -> std::io::Result<std::fs::File> {
360 use std::os::unix::fs::OpenOptionsExt;
361
362 let mut options = std::fs::OpenOptions::new();
363 options.write(true).create_new(true);
364 if strict_secret_permissions {
365 options.mode(0o600);
366 }
367 options.open(path)
368}
369
370#[cfg(not(unix))]
371fn create_temp_file(
372 path: &Path,
373 _strict_secret_permissions: bool,
374) -> std::io::Result<std::fs::File> {
375 let mut options = std::fs::OpenOptions::new();
376 options.write(true).create_new(true);
377 options.open(path)
378}
379
380#[cfg(unix)]
381pub fn secret_file_mode(path: &Path) -> Result<u32, ConfigError> {
385 use std::os::unix::fs::PermissionsExt;
386
387 let metadata = std::fs::metadata(path).map_err(|err| ConfigError::FileRead {
388 path: path.display().to_string(),
389 reason: err.to_string(),
390 })?;
391 Ok(metadata.permissions().mode() & 0o777)
392}
393
394fn scoped_table_mut<'a>(
395 root: &'a mut toml::value::Table,
396 scope: &Scope,
397) -> Result<&'a mut toml::value::Table, ConfigError> {
398 ensure_table_path(root, &scope_path(scope))
399}
400
401fn scoped_table<'a>(
402 root: &'a toml::value::Table,
403 scope: &Scope,
404) -> Result<Option<&'a toml::value::Table>, ConfigError> {
405 get_table_path(root, &scope_path(scope))
406}
407
408fn scope_path(scope: &Scope) -> Vec<&str> {
409 match (scope.profile.as_deref(), scope.terminal.as_deref()) {
410 (None, None) => vec!["default"],
411 (Some(profile), None) => vec!["profile", profile],
412 (None, Some(terminal)) => vec!["terminal", terminal],
413 (Some(profile), Some(terminal)) => vec!["terminal", terminal, "profile", profile],
414 }
415}
416
417fn ensure_table_path<'a>(
418 table: &'a mut toml::value::Table,
419 path: &[&str],
420) -> Result<&'a mut toml::value::Table, ConfigError> {
421 let mut cursor = table;
422 for section in path {
423 cursor = ensure_table(cursor, section)?;
424 }
425 Ok(cursor)
426}
427
428fn get_table_path<'a>(
429 table: &'a toml::value::Table,
430 path: &[&str],
431) -> Result<Option<&'a toml::value::Table>, ConfigError> {
432 let mut cursor = table;
433 for section in path {
434 let Some(next) = get_table(cursor, section)? else {
435 return Ok(None);
436 };
437 cursor = next;
438 }
439 Ok(Some(cursor))
440}
441
442fn ensure_table<'a>(
443 table: &'a mut toml::value::Table,
444 key: &str,
445) -> Result<&'a mut toml::value::Table, ConfigError> {
446 let entry = table
447 .entry(key.to_string())
448 .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
449 match entry {
450 toml::Value::Table(inner) => Ok(inner),
451 _ => Err(ConfigError::InvalidSection {
452 section: key.to_string(),
453 expected: "table".to_string(),
454 }),
455 }
456}
457
458fn get_table<'a>(
459 table: &'a toml::value::Table,
460 key: &str,
461) -> Result<Option<&'a toml::value::Table>, ConfigError> {
462 let Some(entry) = table.get(key) else {
463 return Ok(None);
464 };
465 match entry {
466 toml::Value::Table(inner) => Ok(Some(inner)),
467 _ => Err(ConfigError::InvalidSection {
468 section: key.to_string(),
469 expected: "table".to_string(),
470 }),
471 }
472}
473
474fn set_dotted_value(
475 table: &mut toml::value::Table,
476 dotted_key: &str,
477 value: &ConfigValue,
478) -> Result<Option<ConfigValue>, ConfigError> {
479 let parts = dotted_key
480 .split('.')
481 .map(str::trim)
482 .filter(|part| !part.is_empty())
483 .collect::<Vec<&str>>();
484
485 if parts.is_empty() {
486 return Err(ConfigError::InvalidConfigKey {
487 key: dotted_key.to_string(),
488 reason: "empty key path".to_string(),
489 });
490 }
491
492 let mut cursor = table;
493 for key in &parts[..parts.len() - 1] {
494 cursor = ensure_table(cursor, key)?;
495 }
496
497 let leaf = parts[parts.len() - 1];
498 let previous = cursor
499 .insert(leaf.to_string(), config_value_to_toml(value))
500 .and_then(|existing| ConfigValue::from_toml(dotted_key, &existing).ok());
501
502 Ok(previous)
503}
504
505fn unset_dotted_value(
506 root: &mut toml::value::Table,
507 scope: &Scope,
508 dotted_key: &str,
509) -> Result<Option<ConfigValue>, ConfigError> {
510 let parts = dotted_key
511 .split('.')
512 .map(str::trim)
513 .filter(|part| !part.is_empty())
514 .collect::<Vec<&str>>();
515
516 if parts.is_empty() {
517 return Err(ConfigError::InvalidConfigKey {
518 key: dotted_key.to_string(),
519 reason: "empty key path".to_string(),
520 });
521 }
522
523 let previous = scoped_table(root, scope)?
524 .and_then(|table| read_dotted_value(table, &parts))
525 .and_then(|value| ConfigValue::from_toml(dotted_key, value).ok());
526
527 let _ = remove_scoped_value(root, scope, &parts)?;
528 prune_empty_scope_tables(root, scope)?;
529
530 Ok(previous)
531}
532
533fn remove_scoped_value(
534 root: &mut toml::value::Table,
535 scope: &Scope,
536 parts: &[&str],
537) -> Result<bool, ConfigError> {
538 let table = ensure_table_path(root, &scope_path(scope))?;
539
540 remove_dotted_value(table, parts)
541}
542
543fn remove_dotted_value(
544 table: &mut toml::value::Table,
545 parts: &[&str],
546) -> Result<bool, ConfigError> {
547 if parts.is_empty() {
548 return Ok(false);
549 }
550
551 if parts.len() == 1 {
552 return Ok(table.remove(parts[0]).is_some());
553 }
554
555 let Some(entry) = table.get_mut(parts[0]) else {
556 return Ok(false);
557 };
558 let child = match entry {
559 toml::Value::Table(inner) => inner,
560 _ => {
561 return Err(ConfigError::InvalidSection {
562 section: parts[0].to_string(),
563 expected: "table".to_string(),
564 });
565 }
566 };
567
568 let removed = remove_dotted_value(child, &parts[1..])?;
569 if removed && child.is_empty() {
570 table.remove(parts[0]);
571 }
572 Ok(removed)
573}
574
575fn prune_empty_scope_tables(
576 root: &mut toml::value::Table,
577 scope: &Scope,
578) -> Result<(), ConfigError> {
579 prune_empty_table_path(root, &scope_path(scope))?;
580 Ok(())
581}
582
583fn prune_empty_table_path(
584 table: &mut toml::value::Table,
585 path: &[&str],
586) -> Result<(), ConfigError> {
587 let Some((head, tail)) = path.split_first() else {
588 return Ok(());
589 };
590 if tail.is_empty() {
591 remove_empty_table(table, head);
592 return Ok(());
593 }
594
595 let should_remove = if let Some(value) = table.get_mut(*head) {
596 let child = as_table_mut(value, head)?;
597 prune_empty_table_path(child, tail)?;
598 child.is_empty()
599 } else {
600 false
601 };
602 if should_remove {
603 table.remove(*head);
604 }
605 Ok(())
606}
607
608fn remove_empty_table(table: &mut toml::value::Table, key: &str) {
609 let should_remove = table
610 .get(key)
611 .and_then(toml::Value::as_table)
612 .is_some_and(|inner| inner.is_empty());
613 if should_remove {
614 table.remove(key);
615 }
616}
617
618fn as_table_mut<'a>(
619 value: &'a mut toml::Value,
620 section: &str,
621) -> Result<&'a mut toml::value::Table, ConfigError> {
622 match value {
623 toml::Value::Table(inner) => Ok(inner),
624 _ => Err(ConfigError::InvalidSection {
625 section: section.to_string(),
626 expected: "table".to_string(),
627 }),
628 }
629}
630
631fn read_dotted_value<'a>(table: &'a toml::value::Table, parts: &[&str]) -> Option<&'a toml::Value> {
632 let (head, tail) = parts.split_first()?;
633 let value = table.get(*head)?;
634 if tail.is_empty() {
635 return Some(value);
636 }
637 read_dotted_value(value.as_table()?, tail)
638}
639
640fn config_value_to_toml(value: &ConfigValue) -> toml::Value {
641 match value {
642 ConfigValue::String(v) => toml::Value::String(v.clone()),
643 ConfigValue::Bool(v) => toml::Value::Boolean(*v),
644 ConfigValue::Integer(v) => toml::Value::Integer(*v),
645 ConfigValue::Float(v) => toml::Value::Float(*v),
646 ConfigValue::List(values) => {
647 toml::Value::Array(values.iter().map(config_value_to_toml).collect())
648 }
649 ConfigValue::Secret(secret) => config_value_to_toml(secret.expose()),
650 }
651}
652
653#[cfg(unix)]
654pub(crate) fn validate_secrets_permissions(
655 path: &PathBuf,
656 strict: bool,
657) -> Result<(), ConfigError> {
658 use std::os::unix::fs::PermissionsExt;
659
660 if !strict {
661 return Ok(());
662 }
663
664 let metadata = std::fs::metadata(path).map_err(|err| ConfigError::FileRead {
665 path: path.display().to_string(),
666 reason: err.to_string(),
667 })?;
668 let mode = metadata.permissions().mode() & 0o777;
669 if mode & 0o077 != 0 {
670 return Err(ConfigError::InsecureSecretsPermissions {
671 path: path.display().to_string(),
672 mode,
673 });
674 }
675
676 Ok(())
677}
678
679#[cfg(not(unix))]
680pub(crate) fn validate_secrets_permissions(
681 _path: &PathBuf,
682 _strict: bool,
683) -> Result<(), ConfigError> {
684 Ok(())
685}
686
687#[cfg(test)]
688mod tests;