//! Parse and merge `.botsecrets` TOML configuration files. //! //! The configuration is layered (most-specific wins): //! //! 1. Built-in defaults //! 2. `~/.config/fermata/.botsecrets` (user-global) //! 3. `/.botsecrets` (project) //! 4. `/.botsecrets.local` (local overrides, git-ignored) //! //! Vec fields like `files.patterns` are *replaced* by more-specific layers. //! `keys.include` and `keys.exclude` *accumulate* across layers. //! Scalar fields (style, mode, enabled) take the most-specific value. use globset::{Glob, GlobMatcher}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use thiserror::Error; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- #[derive(Debug, Error)] pub enum SecretsConfigError { #[error("io error reading {path}: {source}")] Io { path: PathBuf, source: std::io::Error, }, #[error("TOML parse error in {path}: {source}")] Parse { path: PathBuf, source: toml::de::Error, }, } // --------------------------------------------------------------------------- // Config types // --------------------------------------------------------------------------- /// Top-level `.botsecrets` configuration. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SecretsConfig { #[serde(default)] pub files: FilesConfig, #[serde(default)] pub keys: KeysConfig, #[serde(default)] pub redaction: RedactionConfig, #[serde(default)] pub heuristic: HeuristicConfig, #[serde(default)] pub enforcement: EnforcementConfig, #[serde(default, rename = "file")] pub file_overrides: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FilesConfig { #[serde(default = "default_file_patterns")] pub patterns: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct KeysConfig { #[serde(default)] pub include: Vec, #[serde(default)] pub exclude: Vec, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum RedactionStyle { Masked, Typed, Named, Absent, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RedactionConfig { #[serde(default = "default_redaction_style")] pub style: RedactionStyle, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum HeuristicMode { Enforce, Report, Disabled, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HeuristicConfig { #[serde(default = "default_true")] pub enabled: bool, #[serde(default = "default_heuristic_mode")] pub mode: HeuristicMode, #[serde(default)] pub patterns: Vec, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum EnforcementMode { Strict, Permissive, Audit, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum ParseErrorAction { MaskEntireFile, Allow, Deny, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct EnforcementConfig { #[serde(default = "default_enforcement_mode")] pub mode: EnforcementMode, #[serde(default = "default_parse_error_action")] pub on_parse_error: ParseErrorAction, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FileOverride { pub path: String, #[serde(default)] pub format: Option, #[serde(default)] pub keys: Vec, } // --------------------------------------------------------------------------- // Built-in defaults // --------------------------------------------------------------------------- pub(crate) fn default_file_patterns() -> Vec { vec![ ".env", ".env.*", "*.env", "secrets.*", "credentials.*", "*.key", "*.pem", "*.p12", "*.pfx", "id_rsa", "id_ed25519", "id_ecdsa", "Secrets.toml", "Secrets.*.toml", "terraform.tfvars", "*.auto.tfvars", "terraform.tfstate", "*.tfstate", ".docker/config.json", "config/master.key", "config/credentials/*.key", ".aws/credentials", ".netrc", ".htpasswd", "service-account.json", "service-account-key.json", ] .into_iter() .map(String::from) .collect() } /// Built-in key name patterns that are always treated as sensitive. pub const BUILTIN_KEY_PATTERNS: &[&str] = &[ "*PASSWORD*", "*PASSWD*", "*SECRET*", "*API_KEY*", "*APIKEY*", "*TOKEN*", "*ACCESS_KEY*", "*PRIVATE_KEY*", "*AUTH*", "*CREDENTIAL*", "*CONNECTION_STRING*", "*CONN_STR*", "DATABASE_URL", "REDIS_URL", "MONGODB_URI", "AMQP_URL", "AWS_SECRET_ACCESS_KEY", "AWS_ACCESS_KEY_ID", "AWS_SESSION_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "GITLAB_TOKEN", "NPM_TOKEN", "NODE_AUTH_TOKEN", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "SENTRY_DSN", "HEROKU_API_KEY", "SENDGRID_API_KEY", "JWT_SECRET", "JWT_SIGNING_KEY", "SESSION_SECRET", "ENCRYPTION_KEY", "ENCRYPT_KEY", "MASTER_KEY", "SIGNING_KEY", "SECRET_KEY", "SECRET_KEY_BASE", "APP_KEY", "NEXTAUTH_SECRET", ]; fn default_redaction_style() -> RedactionStyle { RedactionStyle::Masked } fn default_heuristic_mode() -> HeuristicMode { HeuristicMode::Enforce } fn default_true() -> bool { true } fn default_enforcement_mode() -> EnforcementMode { EnforcementMode::Permissive } fn default_parse_error_action() -> ParseErrorAction { ParseErrorAction::MaskEntireFile } // --------------------------------------------------------------------------- // Default impls // --------------------------------------------------------------------------- impl Default for SecretsConfig { fn default() -> Self { Self { files: FilesConfig::default(), keys: KeysConfig::default(), redaction: RedactionConfig::default(), heuristic: HeuristicConfig::default(), enforcement: EnforcementConfig::default(), file_overrides: Vec::new(), } } } impl Default for FilesConfig { fn default() -> Self { Self { patterns: default_file_patterns(), } } } impl Default for KeysConfig { fn default() -> Self { Self { include: Vec::new(), exclude: Vec::new(), } } } impl Default for RedactionConfig { fn default() -> Self { Self { style: default_redaction_style(), } } } impl Default for HeuristicConfig { fn default() -> Self { Self { enabled: default_true(), mode: default_heuristic_mode(), patterns: Vec::new(), } } } impl Default for EnforcementConfig { fn default() -> Self { Self { mode: default_enforcement_mode(), on_parse_error: default_parse_error_action(), } } } // --------------------------------------------------------------------------- // Partial layer (for merge) // --------------------------------------------------------------------------- /// A partially-specified config layer parsed from a single `.botsecrets` file. /// `Option`-wrapped fields distinguish "absent" from "explicitly set". #[derive(Debug, Clone, Default, Deserialize)] struct PartialSecretsConfig { #[serde(default)] files: Option, #[serde(default)] keys: Option, #[serde(default)] redaction: Option, #[serde(default)] heuristic: Option, #[serde(default)] enforcement: Option, #[serde(default, rename = "file")] file: Option>, } #[derive(Debug, Clone, Default, Deserialize)] struct PartialFilesConfig { patterns: Option>, } #[derive(Debug, Clone, Default, Deserialize)] struct PartialKeysConfig { include: Option>, exclude: Option>, } #[derive(Debug, Clone, Default, Deserialize)] struct PartialRedactionConfig { style: Option, } #[derive(Debug, Clone, Default, Deserialize)] struct PartialHeuristicConfig { enabled: Option, mode: Option, patterns: Option>, } #[derive(Debug, Clone, Default, Deserialize)] struct PartialEnforcementConfig { mode: Option, on_parse_error: Option, } // --------------------------------------------------------------------------- // Merge logic // --------------------------------------------------------------------------- impl SecretsConfig { /// Apply a partial layer on top of `self`. /// /// - Vec fields (`files.patterns`, `heuristic.patterns`, `file_overrides`): /// **replaced** by the layer's value when present. /// - `keys.include` / `keys.exclude`: **accumulated** (appended). /// - Scalar fields: overwritten when present in the layer. fn merge_layer(&mut self, layer: PartialSecretsConfig) { // files if let Some(f) = layer.files { if let Some(patterns) = f.patterns { self.files.patterns = patterns; } } // keys (accumulate) if let Some(k) = layer.keys { if let Some(inc) = k.include { self.keys.include.extend(inc); } if let Some(exc) = k.exclude { self.keys.exclude.extend(exc); } } // redaction if let Some(r) = layer.redaction { if let Some(style) = r.style { self.redaction.style = style; } } // heuristic if let Some(h) = layer.heuristic { if let Some(enabled) = h.enabled { self.heuristic.enabled = enabled; } if let Some(mode) = h.mode { self.heuristic.mode = mode; } if let Some(patterns) = h.patterns { self.heuristic.patterns = patterns; } } // enforcement if let Some(e) = layer.enforcement { if let Some(mode) = e.mode { self.enforcement.mode = mode; } if let Some(action) = e.on_parse_error { self.enforcement.on_parse_error = action; } } // file overrides (replace) if let Some(overrides) = layer.file { self.file_overrides = overrides; } } } // --------------------------------------------------------------------------- // Loading & discovery // --------------------------------------------------------------------------- /// Return the user-global fermata config directory. /// `~/.config/fermata` on Unix, `%APPDATA%/fermata` on Windows. fn user_config_dir() -> Option { #[cfg(unix)] { std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config").join("fermata")) } #[cfg(windows)] { std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("fermata")) } } impl SecretsConfig { /// Load `.botsecrets` configuration for a project. /// /// Merges layers in order (most-specific wins): /// 1. Built-in defaults /// 2. `~/.config/fermata/.botsecrets` /// 3. `/.botsecrets` /// 4. `/.botsecrets.local` pub fn load(root: &Path) -> Result { let mut config = Self::default(); // Layer 2: user-global if let Some(user_dir) = user_config_dir() { let user_file = user_dir.join(".botsecrets"); if user_file.is_file() { let layer = Self::read_partial(&user_file)?; config.merge_layer(layer); } } // Layer 3: project root let project_file = root.join(".botsecrets"); if project_file.is_file() { let layer = Self::read_partial(&project_file)?; config.merge_layer(layer); } // Layer 4: local overrides let local_file = root.join(".botsecrets.local"); if local_file.is_file() { let layer = Self::read_partial(&local_file)?; config.merge_layer(layer); } Ok(config) } /// Parse a single `.botsecrets` file into a partial layer. fn read_partial(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| SecretsConfigError::Io { path: path.to_path_buf(), source: e, })?; toml::from_str(&text).map_err(|e| SecretsConfigError::Parse { path: path.to_path_buf(), source: e, }) } /// Load from a TOML string (useful for testing and embedding). pub fn from_toml(toml_str: &str) -> Result { toml::from_str(toml_str) } /// Returns the effective key-include patterns: built-in defaults + user /// `keys.include`, minus any pattern that appears in `keys.exclude`. pub fn effective_key_includes(&self) -> Vec { let mut patterns: Vec = BUILTIN_KEY_PATTERNS .iter() .map(|s| (*s).to_owned()) .collect(); patterns.extend(self.keys.include.iter().cloned()); // Remove excluded patterns (exact string match). if !self.keys.exclude.is_empty() { let exclude_set: std::collections::HashSet<&str> = self.keys.exclude.iter().map(|s| s.as_str()).collect(); patterns.retain(|p| !exclude_set.contains(p.as_str())); } patterns } /// Check whether `key` matches any of the effective key-include patterns. /// /// Matching is case-insensitive and uses glob semantics (`*` wildcards). pub fn key_matches(&self, key: &str) -> bool { let patterns = self.effective_key_includes(); let upper = key.to_ascii_uppercase(); for pat in &patterns { let pat_upper = pat.to_ascii_uppercase(); // Build a glob matcher. Patterns without path separators are // matched as plain globs against the key name. if let Ok(glob) = Glob::new(&pat_upper) { let matcher: GlobMatcher = glob.compile_matcher(); if matcher.is_match(&upper) { return true; } } } false } }