//! TOML loading, layered merging, and `$VAR` expansion. //! //! The merged result is the **default** config; requirements layers //! sit on top via [`crate::validation`]. use std::path::Path; use crate::paths::{system_config_dir, user_kigi_home}; use crate::validation::{load_requirements, load_system_requirements}; use crate::version_overrides::{self, apply_version_overrides}; /// Load and parse a TOML file, expanding `$VAR` references. Empty table if absent. pub fn load_toml_file(path: &Path) -> std::io::Result { match std::fs::read_to_string(path) { Ok(s) => match toml::from_str::(&s) { Ok(mut v) => { expand_env_vars_in_toml(&mut v); Ok(v) } Err(e) => { // Built from the span, never from Display — Display echoes the // offending source line, which may carry a secret. Safe to log and // to return to a client. let detail = toml_error_detail(&s, &e); tracing::error!(file = %path.display(), "config toml has syntax errors: {detail}"); Err(std::io::Error::other(detail)) } }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Ok(toml::Value::Table(toml::map::Map::new())) } Err(e) => { tracing::error!(file = %path.display(), "config file unreadable: {e}"); Err(e) } } } /// A snippet-free description of a TOML parse error: `"TOML parse error at line /// L, column C: "` (or just the message when there's no span). Never /// includes the offending source line — `Display` echoes it and it may carry a /// secret — so this is safe to log or surface to a client. Shared with the trace /// `config_files` artifact so the redaction rule lives in one place. pub fn toml_error_detail(src: &str, e: &toml::de::Error) -> String { match e.span() { Some(span) => { let (line, col) = line_col(src, span.start); format!( "TOML parse error at line {line}, column {col}: {}", e.message() ) } None => e.message().to_owned(), } } /// 1-based (line, column) of a byte offset within `src`. fn line_col(src: &str, byte: usize) -> (usize, usize) { let mut line = 1; let mut col = 1; for (i, ch) in src.char_indices() { if i >= byte { break; } if ch == '\n' { line += 1; col = 1; } else { col += 1; } } (line, col) } /// [`load_toml_file`] plus that layer's `[[version_overrides]]`. Use for /// kigi config files; use [`load_toml_file`] directly for unrelated TOML. pub fn load_config_file(path: &Path) -> std::io::Result { let mut v = load_toml_file(path)?; apply_version_overrides_with_registered(&mut v)?; Ok(v) } pub fn load_from_disk() -> std::io::Result { load_user_config_layer(user_kigi_home().as_deref(), "config.toml") } pub const MANAGED_CONFIG_FILENAME: &str = "managed_config.toml"; pub fn load_managed_config() -> std::io::Result { load_user_config_layer(user_kigi_home().as_deref(), MANAGED_CONFIG_FILENAME) } /// Load a user-tier config layer from `/`. With no resolvable /// user home, returns an empty table rather than reading a cwd-relative /// `.kigi/` (the cwd-fallback would silently promote an untrusted /// project `.kigi` to the user tier). fn load_user_config_layer(home: Option<&Path>, filename: &str) -> std::io::Result { match home { Some(g) => load_config_file(&g.join(filename)), None => Ok(toml::Value::Table(toml::map::Map::new())), } } pub fn load_system_managed_config() -> std::io::Result { let mut v = match system_config_dir() { Some(dir) => load_toml_file(&dir.join(MANAGED_CONFIG_FILENAME))?, None => toml::Value::Table(toml::map::Map::new()), }; apply_version_overrides_with_registered(&mut v)?; Ok(v) } #[derive(Debug, Clone)] pub struct ManagedConfigLayer { pub value: toml::Value, pub path: std::path::PathBuf, /// `true` for the root-owned system layer (`/etc/kigi`), derived from the /// load directory. pub is_system: bool, } /// All `managed_config.toml` layers in apply order (system first, user last). /// Absent layers are skipped; unparsable layers are skipped with a warning. /// One bad layer never drops the others. pub fn managed_config_layers() -> Vec { managed_config_layers_at(system_config_dir().as_deref(), user_kigi_home().as_deref()) } /// [`managed_config_layers`] with explicit directories. pub fn managed_config_layers_at( system_dir: Option<&Path>, user_home: Option<&Path>, ) -> Vec { let mut layers = Vec::new(); for (dir, is_system) in [(system_dir, true), (user_home, false)] { let Some(path) = dir.map(|d| d.join(MANAGED_CONFIG_FILENAME)) else { continue; }; if !path.is_file() { continue; } match load_config_file(&path) { Ok(value) => layers.push(ManagedConfigLayer { value, path, is_system, }), Err(e) => { tracing::warn!(path = %path.display(), error = %e, "skipping managed_config.toml layer that failed to load or parse") } } } layers } /// Layers lowest→highest priority. `[[campaigns]]` taken off each layer at load. #[derive(Clone)] pub struct ConfigLayers { pub system_managed: toml::Value, pub managed: toml::Value, pub user: toml::Value, pub user_requirements: Option, pub system_requirements: Option, /// macOS MDM requirements; highest requirements tier when present. pub mdm_requirements: Option, pub campaigns: crate::campaigns::CampaignOverrides, } impl Default for ConfigLayers { fn default() -> Self { Self { system_managed: toml::Value::Table(Default::default()), managed: toml::Value::Table(Default::default()), user: toml::Value::Table(Default::default()), user_requirements: None, system_requirements: None, mdm_requirements: None, campaigns: crate::campaigns::CampaignOverrides::default(), } } } impl ConfigLayers { pub fn load() -> std::io::Result { use crate::campaigns::{CampaignOverrides, take_campaign_entries}; let mut system_managed = load_system_managed_config()?; let system_managed_campaigns = take_campaign_entries(&mut system_managed, "system_managed"); let mut managed = load_managed_config()?; let managed_campaigns = take_campaign_entries(&mut managed, "managed"); let mut user = load_from_disk()?; let user_campaigns = take_campaign_entries(&mut user, "user"); let mut user_requirements = load_requirements(); let mut system_requirements = load_system_requirements(); let mut mdm_requirements = crate::validation::mdm_requirements_value(); // Highest-authority requirements tier first: `merge_campaign_entries` is // first-id-wins, so a duplicate campaign id must resolve mdm > system > // user — matching the layer precedence in `effective_config_base` (where // mdm is merged last/highest). let mut requirements_campaigns = Vec::new(); if let Some(ref mut req) = mdm_requirements { requirements_campaigns.extend(take_campaign_entries(req, "requirements")); } if let Some(ref mut req) = system_requirements { requirements_campaigns.extend(take_campaign_entries(req, "requirements")); } if let Some(ref mut req) = user_requirements { requirements_campaigns.extend(take_campaign_entries(req, "requirements")); } Ok(Self { system_managed, managed, user, user_requirements, system_requirements, mdm_requirements, campaigns: CampaignOverrides { requirements: requirements_campaigns, user: user_campaigns, managed: managed_campaigns, system_managed: system_managed_campaigns, }, }) } /// Layer merge only (no campaign overlay). pub fn effective_config_base(&self) -> toml::Value { let mut merged = self.system_managed.clone(); deep_merge_toml(&mut merged, &self.managed); deep_merge_toml(&mut merged, &self.user); if let Some(req) = &self.user_requirements { deep_merge_toml(&mut merged, req); } if let Some(sys_req) = &self.system_requirements { deep_merge_toml(&mut merged, sys_req); } if let Some(mdm_req) = &self.mdm_requirements { deep_merge_toml(&mut merged, mdm_req); } merged } /// Campaign source slices in priority order (first id wins): /// requirements > remote > user > managed > system_managed. Single source of /// truth for the precedence; both this crate and the shell resolver consume it. pub fn campaign_source_slices<'a>( &'a self, remote_campaigns: &'a [crate::campaigns::CampaignEntry], ) -> [&'a [crate::campaigns::CampaignEntry]; 5] { [ &self.campaigns.requirements, remote_campaigns, &self.campaigns.user, &self.campaigns.managed, &self.campaigns.system_managed, ] } /// Active campaigns against `base`: kill switch → priority merge (first-id-wins) /// → drop dismissed. The single place disk campaign resolution lives; the shell /// wraps this with the `KIGI_CAMPAIGNS_OVERRIDE` env layer. pub fn resolve_campaigns( &self, base: &toml::Value, remote_campaigns: &[crate::campaigns::CampaignEntry], dismissed_ids: &std::collections::HashSet, ) -> Vec { if campaigns_application_disabled(base) { return Vec::new(); } let merged = crate::campaigns::merge_campaign_entries( &self.campaign_source_slices(remote_campaigns), ); crate::campaigns::filter_active_campaigns(merged, dismissed_ids) } /// Re-merge the requirements layers so an admin's `requirements.toml` always /// wins over a campaign overlay, regardless of the campaign's source layer. /// Campaigns are full-power (any field), so this is the structural guarantee /// that a lower-trust layer's campaign can't override an admin-set field. fn reapply_requirements(&self, merged: &mut toml::Value) { for req in [ &self.user_requirements, &self.system_requirements, &self.mdm_requirements, ] .into_iter() .flatten() { deep_merge_toml(merged, req); } } /// Apply active campaign patches onto `merged`, then restore requirements /// precedence. The single overlay step shared by this crate and the shell. pub fn apply_campaign_overrides( &self, merged: &mut toml::Value, active: &[crate::campaigns::CampaignEntry], ) { crate::campaigns::apply_active_campaign_patches(merged, active); self.reapply_requirements(merged); } /// Layer merge + disk/remote campaign overlay, honoring the kill switch. The /// shell's `load_effective_config` is the remote/override-aware path; this is /// used by `effective_config_disk_only` and tests. pub fn effective_config_with_campaigns( &self, remote_campaigns: &[crate::campaigns::CampaignEntry], dismissed_ids: &std::collections::HashSet, ) -> toml::Value { let mut merged = self.effective_config_base(); let active = self.resolve_campaigns(&merged, remote_campaigns, dismissed_ids); self.apply_campaign_overrides(&mut merged, &active); merged } /// Disk campaigns + on-disk dismiss (`campaigns_state.json`); **no remote, no /// env override**. Named to make the divergence from the shell's remote-aware /// `load_effective_config` explicit at every call site. pub fn effective_config_disk_only(&self) -> toml::Value { self.effective_config_with_campaigns(&[], &load_dismissed_ids_from_home()) } pub fn has_managed(&self) -> bool { self.managed.as_table().is_some_and(|t| !t.is_empty()) || self .system_managed .as_table() .is_some_and(|t| !t.is_empty()) } pub fn has_system_managed(&self) -> bool { self.system_managed .as_table() .is_some_and(|t| !t.is_empty()) } } /// `KIGI_CAMPAIGNS=0` or `[features] campaigns = false` on pre-campaign base. pub fn campaigns_application_disabled(base_effective: &toml::Value) -> bool { if crate::env_bool("KIGI_CAMPAIGNS") == Some(false) { return true; } base_effective .get("features") .and_then(|f| f.get("campaigns")) .and_then(|c| c.as_bool()) == Some(false) } /// Disk layers only (no remote, no env override). Prefer the shell loader /// (`kigi_shell::util::config::load_effective_config`) when remote campaigns /// or `KIGI_CAMPAIGNS_OVERRIDE` must be honored. The name mirrors the /// [`ConfigLayers::effective_config_disk_only`] method so the divergence from the /// remote-aware loader is un-ignorable at every call site. pub fn load_effective_config_disk_only() -> std::io::Result { Ok(ConfigLayers::load()?.effective_config_disk_only()) } /// On-disk campaign dismiss state. Single source of truth for the file's name, /// location, and JSON shape — the shell's writer reuses these so the read and /// write sides can't drift. pub const CAMPAIGNS_STATE_FILE: &str = "campaigns_state.json"; #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct CampaignsState { #[serde(default)] pub dismissed_ids: Vec, } pub fn campaigns_state_path(home: &std::path::Path) -> std::path::PathBuf { home.join(CAMPAIGNS_STATE_FILE) } /// Fail-open dismissed ids from `$KIGI_SHARE_DIR/campaigns_state.json`. pub fn load_dismissed_ids_from_home() -> std::collections::HashSet { let Some(home) = crate::user_kigi_home() else { return std::collections::HashSet::new(); }; let Ok(contents) = std::fs::read_to_string(campaigns_state_path(&home)) else { return std::collections::HashSet::new(); }; serde_json::from_str::(&contents) .map(|s| s.dismissed_ids.into_iter().collect()) .unwrap_or_default() } /// Applies matching `[[version_overrides]]` patches against the running /// CLI version; strips the section either way. If the installed version /// can't be parsed (broken `KIGI_TEST_VERSION` in dev), silently strips /// without applying — keeps the CLI usable on a bad dev override. pub fn apply_version_overrides_with_registered(value: &mut toml::Value) -> std::io::Result<()> { match kigi_version::installed_semver() { Ok(version) => apply_version_overrides(value, &version) .map_err(|e| std::io::Error::other(e.to_string())), Err(_) => { if let Some(table) = value.as_table_mut() { table.remove(version_overrides::VERSION_OVERRIDES_KEY); } Ok(()) } } } /// Recursively merge `overrides` into `base`. Values in `overrides` win. pub fn deep_merge_toml(base: &mut toml::Value, overrides: &toml::Value) { if let toml::Value::Table(overrides_table) = overrides && let toml::Value::Table(base_table) = base { for (key, value) in overrides_table { if let Some(existing) = base_table.get_mut(key) { deep_merge_toml(existing, value); } else { base_table.insert(key.clone(), value.clone()); } } } else { *base = overrides.clone(); } } /// Expand `$VAR` / `${VAR}` in all string values. pub fn expand_env_vars_in_toml(value: &mut toml::Value) { match value { toml::Value::String(s) => { let expanded = expand_env_vars_in_string(s); if expanded != *s { *s = expanded; } } toml::Value::Array(items) => { for item in items { expand_env_vars_in_toml(item); } } toml::Value::Table(table) => { for (_, item) in table.iter_mut() { expand_env_vars_in_toml(item); } } _ => {} } } /// Expand `$VAR` / `${VAR}` in a single string. pub fn expand_env_vars_in_string(input: &str) -> String { let context = |name: &str| std::env::var(name).ok(); shellexpand::env_with_context_no_errors(input, context).into_owned() } #[cfg(test)] mod tests { use super::*; #[test] fn full_layer_precedence_requirements_over_config_over_managed() { let system_managed: toml::Value = toml::from_str("[telemetry]\nmode = \"system_managed_value\"\n").unwrap(); let managed: toml::Value = toml::from_str("[telemetry]\nmode = \"managed_value\"\n").unwrap(); let user: toml::Value = toml::from_str("[telemetry]\nmode = \"user_value\"\n").unwrap(); let user_requirements: toml::Value = toml::from_str("[telemetry]\nmode = \"user_requirements_value\"\n").unwrap(); let system_requirements: toml::Value = toml::from_str("[telemetry]\nmode = \"system_requirements_value\"\n").unwrap(); let mut merged = system_managed; deep_merge_toml(&mut merged, &managed); deep_merge_toml(&mut merged, &user); deep_merge_toml(&mut merged, &user_requirements); deep_merge_toml(&mut merged, &system_requirements); assert_eq!( merged["telemetry"]["mode"].as_str(), Some("system_requirements_value") ); } #[test] fn effective_config_mdm_requirements_win_over_system_and_user() { // MDM is merged last, so an admin-forced value clamps the effective // config over both the user config and the system requirements layer. let layers = ConfigLayers { user: toml::from_str("[features]\nweb_fetch = true\n").unwrap(), system_requirements: Some(toml::from_str("[features]\nweb_fetch = true\n").unwrap()), mdm_requirements: Some(toml::from_str("[features]\nweb_fetch = false\n").unwrap()), ..Default::default() }; assert_eq!( layers.effective_config_disk_only()["features"]["web_fetch"].as_bool(), Some(false), ); } #[test] fn full_precedence_holds_when_values_come_from_version_overrides() { let cli_version = semver::Version::parse("1.8.0").unwrap(); let mut managed: toml::Value = toml::from_str( r#" [[version_overrides]] minimum_version = "1.0.0" [version_overrides.telemetry] mode = "managed_versioned" "#, ) .unwrap(); let mut user: toml::Value = toml::from_str( r#" [[version_overrides]] minimum_version = "1.0.0" [version_overrides.telemetry] mode = "user_versioned" "#, ) .unwrap(); let mut requirements: toml::Value = toml::from_str( r#" [[version_overrides]] minimum_version = "1.0.0" [version_overrides.telemetry] mode = "requirements_versioned" "#, ) .unwrap(); apply_version_overrides(&mut managed, &cli_version).unwrap(); apply_version_overrides(&mut user, &cli_version).unwrap(); apply_version_overrides(&mut requirements, &cli_version).unwrap(); let mut merged = managed; deep_merge_toml(&mut merged, &user); deep_merge_toml(&mut merged, &requirements); assert_eq!( merged["telemetry"]["mode"].as_str(), Some("requirements_versioned") ); } /// Direct contract for `deep_merge_toml`: nested tables merge (siblings /// preserved), arrays replace (not concatenate), missing keys insert. #[test] fn deep_merge_toml_table_merge_array_replace_and_insert() { let mut base: toml::Value = toml::from_str( r#" [features.telemetry] enabled = false sample_rate = 0.0 [server] allowed = ["a", "b"] "#, ) .unwrap(); let overrides: toml::Value = toml::from_str( r#" [features.telemetry] enabled = true [server] allowed = ["c"] [brand_new] x = 1 "#, ) .unwrap(); deep_merge_toml(&mut base, &overrides); assert_eq!( base["features"]["telemetry"]["enabled"].as_bool(), Some(true) ); assert_eq!( base["features"]["telemetry"]["sample_rate"].as_float(), Some(0.0) ); let arr: Vec<_> = base["server"]["allowed"] .as_array() .unwrap() .iter() .filter_map(|v| v.as_str()) .collect(); assert_eq!(arr, vec!["c"]); assert_eq!(base["brand_new"]["x"].as_integer(), Some(1)); } #[test] fn user_version_overrides_dont_escape_their_layer() { let cli_version = semver::Version::parse("1.8.0").unwrap(); let mut user: toml::Value = toml::from_str( r#" [[version_overrides]] minimum_version = "1.0.0" [version_overrides.telemetry] mode = "enabled" "#, ) .unwrap(); apply_version_overrides(&mut user, &cli_version).unwrap(); assert_eq!(user["telemetry"]["mode"].as_str(), Some("enabled")); let requirements: toml::Value = toml::from_str( r#" [telemetry] mode = "disabled" "#, ) .unwrap(); let mut merged = user; deep_merge_toml(&mut merged, &requirements); assert_eq!(merged["telemetry"]["mode"].as_str(), Some("disabled")); } #[test] fn load_user_config_layer_is_empty_without_user_home() { // No resolvable user home: no user layer, and crucially no // cwd-relative .kigi read. let v = load_user_config_layer(None, "config.toml").unwrap(); assert_eq!(v.as_table().map(|t| t.is_empty()), Some(true)); } #[test] fn load_user_config_layer_reads_file_when_home_present() { use std::io::Write; let dir = std::env::temp_dir().join(format!("kigi-load-layer-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let mut f = std::fs::File::create(dir.join("config.toml")).unwrap(); writeln!(f, "[telemetry]\nmode = \"from_file\"\n").unwrap(); let v = load_user_config_layer(Some(&dir), "config.toml").unwrap(); assert_eq!(v["telemetry"]["mode"].as_str(), Some("from_file")); let _ = std::fs::remove_dir_all(&dir); } /// The returned error keeps the parser's kind + location but never the source /// snippet, which can carry a secret and would reach a client caller. #[test] fn parse_error_keeps_kind_but_not_snippet() { let dir = std::env::temp_dir().join(format!("kigi-toml-leak-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("bad.toml"); // Duplicate key: the message names the key; the secret-bearing source line is only in Display. std::fs::write( &path, "api_key = \"xai-secretmustnotleak\"\napi_key = \"xai-secretmustnotleak2\"\n", ) .unwrap(); let msg = load_toml_file(&path).unwrap_err().to_string(); assert!( msg.contains("TOML parse error at line 2"), "want location: {msg}" ); assert!(msg.contains("duplicate key"), "want parser kind: {msg}"); assert!( !msg.contains("xai-secretmustnotleak"), "leaked the secret value: {msg}" ); assert!( !msg.contains('|') && !msg.contains('^'), "leaked the source snippet/caret: {msg}" ); let _ = std::fs::remove_dir_all(&dir); } /// `KIGI_CAMPAIGNS=0` disables campaign application regardless of config. /// `KIGI_CAMPAIGNS` is process-global, so this test serializes itself with a /// module-local mutex and save/restores the prior value. (This crate has no /// `serial_test` dev-dep and no other test reads this var, so a local guard /// is sufficient.) #[test] fn kill_switch_env_var_disables() { static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner()); let prior = std::env::var_os("KIGI_CAMPAIGNS"); let empty = toml::Value::Table(Default::default()); // SAFETY: ENV_GUARD serializes this against itself; no other test in the // crate mutates or reads KIGI_CAMPAIGNS concurrently. unsafe { std::env::set_var("KIGI_CAMPAIGNS", "0") }; assert!(campaigns_application_disabled(&empty)); unsafe { std::env::remove_var("KIGI_CAMPAIGNS") }; assert!(!campaigns_application_disabled(&empty)); match prior { Some(v) => unsafe { std::env::set_var("KIGI_CAMPAIGNS", v) }, None => unsafe { std::env::remove_var("KIGI_CAMPAIGNS") }, } } }