//! Enterprise deployment: seed default settings from an admin-supplied `.ini` //! on **first run only** (before any `settings.json` exists). //! //! An admin mass-deploying WhispAssist (GPO / SCCM / Intune) drops a //! `wa-defaults.ini` and every fresh install picks it up once, seeding //! `settings.json` with their chosen defaults (record-by-default, preferred //! backend, retention, model to auto-download, …) — all via native Windows file //! deployment, no WiX custom actions. See `docs/enterprise-deployment.md`. //! //! **Guardrail (CLAUDE.md):** the file must never carry secrets. Keys that look //! like credentials are ignored here as defense in depth — API keys / OAuth //! tokens live only in the OS credential store. use crate::models::Settings; use serde_json::{Map, Value}; use std::path::PathBuf; /// Special (non-`Settings`) INI key: when truthy, the first-run seed also fetches /// the configured `whisper_model` in the background so the machine is ready /// offline. Stripped before the settings merge. const AUTO_DOWNLOAD_KEY: &str = "auto_download_model"; /// Candidate locations, first found wins: /// 1. `%PROGRAMDATA%\WhispAssist\wa-defaults.ini` — machine-wide enterprise path. /// 2. `\wa-defaults.ini` — the bundled template / per-install override. fn candidate_paths() -> Vec { let mut paths = Vec::new(); if let Ok(program_data) = std::env::var("ProgramData") { paths.push(PathBuf::from(program_data).join("WhispAssist").join("wa-defaults.ini")); } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { paths.push(dir.join("wa-defaults.ini")); } } paths } /// Reads the first existing defaults file and produces the seeded settings plus /// the whisper model id to auto-download (if `auto_download_model` was set). /// `None` when no file exists or it contains no overrides (the shipped template /// is fully commented, so normal installs get exactly today's behavior). pub fn seed_settings_from_defaults() -> Option<(Settings, Option)> { let text = candidate_paths() .into_iter() .find_map(|p| std::fs::read_to_string(p).ok())?; seed_from_ini(&text) } /// Testable core: parse INI text → merge onto the built-in defaults. fn seed_from_ini(text: &str) -> Option<(Settings, Option)> { let mut overrides = parse_ini(text); if overrides.is_empty() { return None; } // Pull the non-Settings auto-download flag out before the merge. let auto_download = overrides .remove(AUTO_DOWNLOAD_KEY) .map(|v| truthy(&v)) .unwrap_or(false); // Merge overrides onto the default settings' JSON form, then deserialize. // Unknown keys (typos) are ignored — `Settings` has no deny_unknown_fields. let mut base = match serde_json::to_value(crate::commands::default_settings()) { Ok(Value::Object(map)) => map, _ => return None, }; for (k, v) in overrides { base.insert(k, v); } let settings: Settings = serde_json::from_value(Value::Object(base)).ok()?; let model = if auto_download { Some(settings.whisper_model.clone()) } else { None }; Some((settings, model)) } /// Minimal INI reader: skips blanks, `;`/`#` comments and `[section]` headers; /// splits each `key = value` on the first `=`; coerces values to bool / integer / /// string so serde lands them on the typed `Settings` fields. Silently drops any /// key that looks like a secret (guardrail — no credentials in the deploy file). fn parse_ini(text: &str) -> Map { let mut map = Map::new(); for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with(';') || line.starts_with('#') || line.starts_with('[') { continue; } let Some((key, value)) = line.split_once('=') else { continue; }; let key = key.trim().to_string(); let value = value.trim(); if key.is_empty() || looks_like_secret(&key) { continue; } map.insert(key, coerce(value)); } map } /// `true`/`false` → bool, all-integer → number, everything else → string. fn coerce(value: &str) -> Value { match value.to_ascii_lowercase().as_str() { "true" => return Value::Bool(true), "false" => return Value::Bool(false), _ => {} } if let Ok(n) = value.parse::() { return Value::Number(n.into()); } Value::String(value.to_string()) } fn truthy(v: &Value) -> bool { matches!(v, Value::Bool(true)) || matches!(v, Value::String(s) if s.eq_ignore_ascii_case("true")) } /// Defense in depth: never seed anything that smells like a credential. fn looks_like_secret(key: &str) -> bool { let k = key.to_ascii_lowercase(); ["key", "token", "secret", "credential", "password"] .iter() .any(|needle| k.contains(needle)) } #[cfg(test)] mod tests { use super::*; #[test] fn fully_commented_file_is_a_noop() { let ini = "; default_record = true\n# preferred_backend = cpu\n[general]\n\n"; assert!(seed_from_ini(ini).is_none()); } #[test] fn coerces_bool_int_and_string_fields() { let ini = "default_record = true\nretention_max_age_days = 90\npreferred_backend = cpu\n"; let (settings, model) = seed_from_ini(ini).expect("overrides present"); assert!(settings.default_record); assert_eq!(settings.retention_max_age_days, Some(90)); assert_eq!(settings.preferred_backend, "cpu"); assert!(model.is_none()); } #[test] fn auto_download_returns_the_configured_model() { let ini = "whisper_model = base.en-q5_1\nauto_download_model = true\n"; let (_settings, model) = seed_from_ini(ini).expect("overrides present"); assert_eq!(model.as_deref(), Some("base.en-q5_1")); } #[test] fn unset_fields_keep_their_defaults() { let ini = "default_record = true\n"; let (settings, _) = seed_from_ini(ini).unwrap(); // microphone stays on, auto_start stays off — only the named key changed. assert!(settings.microphone_enabled); assert!(!settings.auto_start); } #[test] fn secret_keys_are_ignored() { let ini = "anthropic_api_key = sk-should-be-dropped\ndefault_record = true\n"; let map = parse_ini(ini); assert!(!map.contains_key("anthropic_api_key")); assert!(map.contains_key("default_record")); } }