use anyhow::{Context, Result, bail}; use log::{debug, warn}; use std::collections::HashMap; use std::fs::{self, File}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use crate::defs; use crate::utils::ensure_dir_exists; #[allow(clippy::unreadable_literal)] const MODULE_CONFIG_MAGIC: u32 = 0x4b53554d; // "KSUM" const MODULE_CONFIG_VERSION: u32 = 1; // Validation limits pub const MAX_CONFIG_KEY_LEN: usize = 256; pub const MAX_CONFIG_VALUE_LEN: usize = 1024 * 1024; // 1MB pub const MAX_CONFIG_COUNT: usize = 32; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigType { Persist, Temp, } impl ConfigType { const fn filename(self) -> &'static str { match self { Self::Persist => defs::PERSIST_CONFIG_NAME, Self::Temp => defs::TEMP_CONFIG_NAME, } } } /// Parse a boolean config value /// Accepts "true", "1" (case-insensitive) as true, everything else as false pub fn parse_bool_config(value: &str) -> bool { let trimmed = value.trim(); trimmed.eq_ignore_ascii_case("true") || trimmed == "1" } /// Validate config key /// Rejects keys with control characters, newlines, or excessive length pub fn validate_config_key(key: &str) -> Result<()> { if key.is_empty() { bail!("Config key cannot be empty"); } if key.len() > MAX_CONFIG_KEY_LEN { bail!( "Config key too long: {} bytes (max: {})", key.len(), MAX_CONFIG_KEY_LEN ); } // Check for control characters and newlines for ch in key.chars() { if ch.is_control() { bail!( "Config key contains control character: {:?} (U+{:04X})", ch, ch as u32 ); } } // Reject keys with path separators to prevent path traversal if key.contains('/') || key.contains('\\') { bail!("Config key cannot contain path separators"); } Ok(()) } /// Validate config value /// Rejects values with control characters (except tab), newlines, or excessive length pub fn validate_config_value(value: &str) -> Result<()> { if value.len() > MAX_CONFIG_VALUE_LEN { bail!( "Config value too long: {} bytes (max: {})", value.len(), MAX_CONFIG_VALUE_LEN ); } // Check for control characters (allow tab but reject newlines and others) for ch in value.chars() { if ch.is_control() && ch != '\t' { bail!( "Config value contains invalid control character: {:?} (U+{:04X})", ch, ch as u32 ); } } Ok(()) } /// Validate config count fn validate_config_count(config: &HashMap) -> Result<()> { if config.len() > MAX_CONFIG_COUNT { bail!( "Too many config entries: {} (max: {})", config.len(), MAX_CONFIG_COUNT ); } Ok(()) } /// Get the config directory path for a module fn get_config_dir(module_id: &str) -> PathBuf { Path::new(defs::MODULE_CONFIG_DIR).join(module_id) } /// Get the config file path for a module fn get_config_path(module_id: &str, config_type: ConfigType) -> PathBuf { get_config_dir(module_id).join(config_type.filename()) } /// Ensure the config directory exists fn ensure_config_dir(module_id: &str) -> Result { let dir = get_config_dir(module_id); ensure_dir_exists(&dir)?; Ok(dir) } /// Load config from binary file pub fn load_config(module_id: &str, config_type: ConfigType) -> Result> { crate::module::validate_module_id(module_id)?; let config_path = get_config_path(module_id, config_type); if !config_path.exists() { debug!("Config file not found: {}", config_path.display()); return Ok(HashMap::new()); } let mut file = File::open(&config_path) .with_context(|| format!("Failed to open config file: {}", config_path.display()))?; // Read magic let mut magic_buf = [0u8; 4]; file.read_exact(&mut magic_buf) .with_context(|| "Failed to read magic")?; let magic = u32::from_le_bytes(magic_buf); if magic != MODULE_CONFIG_MAGIC { bail!( "Invalid config magic: expected 0x{:08x}, got 0x{:08x}", MODULE_CONFIG_MAGIC, magic ); } // Read version let mut version_buf = [0u8; 4]; file.read_exact(&mut version_buf) .with_context(|| "Failed to read version")?; let version = u32::from_le_bytes(version_buf); if version != MODULE_CONFIG_VERSION { bail!("Unsupported config version: expected {MODULE_CONFIG_VERSION}, got {version}"); } // Read count let mut count_buf = [0u8; 4]; file.read_exact(&mut count_buf) .with_context(|| "Failed to read count")?; let count = u32::from_le_bytes(count_buf); // Read entries let mut config = HashMap::new(); for i in 0..count { // Read key length let mut key_len_buf = [0u8; 4]; file.read_exact(&mut key_len_buf) .with_context(|| format!("Failed to read key length for entry {i}"))?; let key_len = u32::from_le_bytes(key_len_buf) as usize; // Read key data let mut key_buf = vec![0u8; key_len]; file.read_exact(&mut key_buf) .with_context(|| format!("Failed to read key data for entry {i}"))?; let key = String::from_utf8(key_buf) .with_context(|| format!("Invalid UTF-8 in key for entry {i}"))?; // Read value length let mut value_len_buf = [0u8; 4]; file.read_exact(&mut value_len_buf) .with_context(|| format!("Failed to read value length for entry {i}"))?; let value_len = u32::from_le_bytes(value_len_buf) as usize; // Read value data let mut value_buf = vec![0u8; value_len]; file.read_exact(&mut value_buf) .with_context(|| format!("Failed to read value data for entry {i}"))?; let value = String::from_utf8(value_buf) .with_context(|| format!("Invalid UTF-8 in value for entry {i}"))?; config.insert(key, value); } debug!( "Loaded {} entries from {}", config.len(), config_path.display() ); Ok(config) } /// Save config to binary file pub fn save_config( module_id: &str, config_type: ConfigType, config: &HashMap, ) -> Result<()> { crate::module::validate_module_id(module_id)?; // Validate config count validate_config_count(config)?; // Validate all keys and values for (key, value) in config { validate_config_key(key).with_context(|| format!("Invalid config key: '{key}'"))?; validate_config_value(value) .with_context(|| format!("Invalid config value for key '{key}'"))?; } ensure_config_dir(module_id)?; let config_path = get_config_path(module_id, config_type); let temp_path = config_path.with_extension("tmp"); // Write to temporary file first let mut file = File::create(&temp_path) .with_context(|| format!("Failed to create temp config file: {}", temp_path.display()))?; // Write magic file.write_all(&MODULE_CONFIG_MAGIC.to_le_bytes()) .with_context(|| "Failed to write magic")?; // Write version file.write_all(&MODULE_CONFIG_VERSION.to_le_bytes()) .with_context(|| "Failed to write version")?; // Write count let count = config.len() as u32; file.write_all(&count.to_le_bytes()) .with_context(|| "Failed to write count")?; // Write entries for (key, value) in config { // Write key length let key_bytes = key.as_bytes(); let key_len = key_bytes.len() as u32; file.write_all(&key_len.to_le_bytes()) .with_context(|| format!("Failed to write key length for '{key}'"))?; // Write key data file.write_all(key_bytes) .with_context(|| format!("Failed to write key data for '{key}'"))?; // Write value length let value_bytes = value.as_bytes(); let value_len = value_bytes.len() as u32; file.write_all(&value_len.to_le_bytes()) .with_context(|| format!("Failed to write value length for '{key}'"))?; // Write value data file.write_all(value_bytes) .with_context(|| format!("Failed to write value data for '{key}'"))?; } file.sync_all() .with_context(|| "Failed to sync config file")?; // Atomic rename fs::rename(&temp_path, &config_path).with_context(|| { format!( "Failed to rename config file: {} -> {}", temp_path.display(), config_path.display() ) })?; debug!( "Saved {} entries to {}", config.len(), config_path.display() ); Ok(()) } /// Get a single config value #[allow(dead_code)] pub fn get_config_value( module_id: &str, key: &str, config_type: ConfigType, ) -> Result> { let config = load_config(module_id, config_type)?; Ok(config.get(key).cloned()) } /// Set a single config value pub fn set_config_value( module_id: &str, key: &str, value: &str, config_type: ConfigType, ) -> Result<()> { // Validate input early for better error messages validate_config_key(key)?; validate_config_value(value)?; let mut config = load_config(module_id, config_type)?; config.insert(key.to_string(), value.to_string()); // Note: save_config will also validate, but this provides earlier feedback save_config(module_id, config_type, &config)?; Ok(()) } /// Delete a single config value pub fn delete_config_value(module_id: &str, key: &str, config_type: ConfigType) -> Result<()> { let mut config = load_config(module_id, config_type)?; if config.remove(key).is_none() { bail!("Key '{key}' not found in config"); } save_config(module_id, config_type, &config)?; Ok(()) } /// Clear all config values pub fn clear_config(module_id: &str, config_type: ConfigType) -> Result<()> { let config_path = get_config_path(module_id, config_type); if config_path.exists() { fs::remove_file(&config_path) .with_context(|| format!("Failed to remove config file: {}", config_path.display()))?; debug!("Cleared config: {}", config_path.display()); } Ok(()) } /// Merge persist and temp configs (temp takes priority) pub fn merge_configs(module_id: &str) -> Result> { crate::module::validate_module_id(module_id)?; let mut merged = match load_config(module_id, ConfigType::Persist) { Ok(config) => config, Err(e) => { warn!("Failed to load persist config for module '{module_id}': {e}"); HashMap::new() } }; let temp = match load_config(module_id, ConfigType::Temp) { Ok(config) => config, Err(e) => { warn!("Failed to load temp config for module '{module_id}': {e}"); HashMap::new() } }; // Temp config overrides persist config for (key, value) in temp { merged.insert(key, value); } Ok(merged) } /// Get all module configs (for iteration) /// Loads all configs in a single pass to minimize I/O overhead pub fn get_all_module_configs() -> Result>> { let config_root = Path::new(defs::MODULE_CONFIG_DIR); if !config_root.exists() { return Ok(HashMap::new()); } let mut all_configs = HashMap::new(); for entry in fs::read_dir(config_root) .with_context(|| format!("Failed to read config directory: {}", config_root.display()))? { let entry = entry?; let path = entry.path(); if !path.is_dir() { continue; } if let Some(module_id) = path.file_name().and_then(|n| n.to_str()) { match merge_configs(module_id) { Ok(config) => { if !config.is_empty() { all_configs.insert(module_id.to_string(), config); } } Err(e) => { warn!("Failed to load config for module '{module_id}': {e}"); // Continue processing other modules } } } } Ok(all_configs) } /// Clear all temporary configs (called during post-fs-data) pub fn clear_all_temp_configs() -> Result<()> { let config_root = Path::new(defs::MODULE_CONFIG_DIR); if !config_root.exists() { debug!("Config directory does not exist, nothing to clear"); return Ok(()); } let mut cleared_count = 0; for entry in fs::read_dir(config_root) .with_context(|| format!("Failed to read config directory: {}", config_root.display()))? { let entry = entry?; let path = entry.path(); if !path.is_dir() { continue; } let temp_config = path.join(defs::TEMP_CONFIG_NAME); if temp_config.exists() { match fs::remove_file(&temp_config) { Ok(()) => { debug!("Cleared temp config: {}", temp_config.display()); cleared_count += 1; } Err(e) => { warn!("Failed to clear temp config {}: {e}", temp_config.display()); } } } } if cleared_count > 0 { debug!("Cleared {cleared_count} temp config file(s)"); } Ok(()) } /// Clear all configs for a module (called during uninstall) pub fn clear_module_configs(module_id: &str) -> Result<()> { crate::module::validate_module_id(module_id)?; let config_dir = get_config_dir(module_id); if config_dir.exists() { fs::remove_dir_all(&config_dir).with_context(|| { format!( "Failed to remove config directory: {}", config_dir.display() ) })?; debug!("Cleared all configs for module: {module_id}"); } Ok(()) }