Skip to main content

osp_cli/config/
store.rs

1//! Helpers for editing TOML-backed config stores on disk.
2//!
3//! This module exists to keep config-file mutation logic separate from config
4//! resolution. Callers provide a validated key, typed value, and scope; this
5//! layer applies the edit atomically to the right TOML table structure.
6//!
7//! Contract:
8//!
9//! - this module owns on-disk TOML edits and atomic write behavior
10//! - schema validation and scope validation still happen before a write lands
11//! - callers should treat these helpers as persistence primitives, not config
12//!   resolution APIs
13
14use 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/// Options that control how TOML-backed config edits are applied.
24#[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    /// Creates edit options for a normal persisted write.
33    pub const fn new() -> Self {
34        Self {
35            mode: TomlStoreEditMode::Persist,
36            secret_permissions: TomlSecretPermissions::ProcessDefault,
37        }
38    }
39
40    /// Creates edit options for a dry run that validates and computes diffs
41    /// without writing the file.
42    pub const fn dry_run() -> Self {
43        Self {
44            mode: TomlStoreEditMode::DryRun,
45            secret_permissions: TomlSecretPermissions::ProcessDefault,
46        }
47    }
48
49    /// Replaces the edit mode.
50    pub const fn with_mode(mut self, mode: TomlStoreEditMode) -> Self {
51        self.mode = mode;
52        self
53    }
54
55    /// Replaces the secret-file permission policy used for atomic temp-file
56    /// creation.
57    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    /// Uses owner-only temp-file permissions suitable for secrets stores.
66    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/// Whether a TOML store edit should be written to disk or treated as a dry
81/// run.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum TomlStoreEditMode {
84    /// Validate and persist the edit to disk.
85    #[default]
86    Persist,
87    /// Validate and compute the edit result without writing the file.
88    DryRun,
89}
90
91/// Permission policy used for temp files created during atomic TOML writes.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum TomlSecretPermissions {
94    /// Use the process default permission behavior for temp-file creation.
95    #[default]
96    ProcessDefault,
97    /// Request owner-only temp-file permissions (`0o600` on Unix).
98    OwnerOnly,
99}
100
101/// Writes one scoped key into a TOML-backed config store.
102///
103/// The edit runs through normal schema and scope validation first and returns
104/// the previously stored typed value when the key already existed.
105///
106/// `options` controls whether the edit is a dry run and whether the atomic
107/// write path should request owner-only temp-file permissions for secrets
108/// stores.
109///
110/// # Examples
111///
112/// ```
113/// use osp_cli::config::{
114///     ConfigValue, Scope, TomlStoreEditOptions, set_scoped_value_in_toml,
115///     unset_scoped_value_in_toml,
116/// };
117///
118/// let path = std::env::temp_dir().join(format!(
119///     "osp-cli-doc-{}-{}.toml",
120///     std::process::id(),
121///     std::time::SystemTime::now()
122///         .duration_since(std::time::UNIX_EPOCH)
123///         .unwrap()
124///         .as_nanos()
125/// ));
126/// let _ = std::fs::remove_file(&path);
127///
128/// let value = ConfigValue::String("dracula".to_string());
129/// let options = TomlStoreEditOptions::new();
130///
131/// set_scoped_value_in_toml(
132///     &path,
133///     "theme.name",
134///     &value,
135///     &Scope::global(),
136///     options,
137/// )
138/// .unwrap();
139/// let removed = unset_scoped_value_in_toml(
140///     &path,
141///     "theme.name",
142///     &Scope::global(),
143///     options,
144/// )
145/// .unwrap();
146///
147/// assert_eq!(removed.previous, Some(value));
148/// let _ = std::fs::remove_file(&path);
149/// ```
150pub 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
160/// Removes one scoped key from a TOML-backed config store.
161///
162/// The returned edit result includes the previous typed value so callers can
163/// report or inspect what changed without reparsing the file.
164///
165/// `options` has the same meaning as on
166/// [`set_scoped_value_in_toml`].
167pub 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)]
381/// Returns the Unix permission bits for a secrets file.
382///
383/// Unix-only.
384pub 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;