# Copyright (C) 2025 James Brotosky, Brandon Wickline # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import json import logging import os from pathlib import Path import sys from typing import Any, Callable, Optional, TypeVar T = TypeVar("T") logger = logging.getLogger(__name__) # System config keys - these are immutable and come from system_config.json (bundled in exe) SYSTEM_CONFIG_KEYS = [ "URL", "APPNAME", "LOG_LEVEL", "BAD_PATH_PARTS", "BAD_PUBLISHERS", "PUPS", "PATH_EXCLUSION_CONST", "MIN_FILES_FOR_PATH", "VT_THREAT_TOLERANCE", "POLICY_MAP_ENF_AUD", ] # User config keys - these can be changed by the end user USER_CONFIG_KEYS = [ "TELEMETRY", # User opt-in/out for telemetry "TELEM_URL", "TEXTUAL_THEME", # UI theme preference "EXTRAS", # Feature flags ] # In-memory config storage _system_config = {} _user_config = {} def get_system_config_path() -> Path: """ Get path to system_config.json. Priority: 1. Bundled in exe (_MEIPASS) 2. Next to this file (development) """ # Check inside bundled EXE directory first bundled_dir = Path(getattr(sys, "_MEIPASS", "")) bundled_path = bundled_dir / "system_config.json" if bundled_path.exists(): return bundled_path # Fallback to development location (next to this file) return Path(__file__).parent.parent / "system_config.json" def load_system_config() -> dict: """ Load system configuration from system_config.json. This should only be called once at startup. Returns the full system config dict. """ global _system_config try: config_path = get_system_config_path() with open(config_path, "r") as f: _system_config = json.load(f) logger.debug(f"✅ Loaded system config from {config_path}") except FileNotFoundError: logger.warning("⚠️ system_config.json not found. Using minimal defaults.") # Minimal defaults for development without system_config.json _system_config = { "APPNAME": "Loxide", "LOG_LEVEL": "INFO", "PATH_EXCLUSION_CONST": 4, "MIN_FILES_FOR_PATH": 4, "VT_THREAT_TOLERANCE": 4, "POLICY_MAP_ENF_AUD": {}, } return _system_config def get_system_value( key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None ) -> Optional[T]: """ Get a value from system config (immutable). Parameters: key: The config key to retrieve cast_type: Function to cast the value to desired type default: Default value if key not found Returns: The config value cast to the desired type, or default """ value = _system_config.get(key) if value is None: logger.warning(f"System config key '{key}' not found.") return default try: if isinstance(value, str): value = value.strip("'\"") return cast_type(value) except (ValueError, TypeError): logger.warning( f"Invalid value for system key '{key}': {value}. Expected type {cast_type.__name__}." ) return default def get_system_json(key: str, default: Optional[dict] = None) -> dict: """ Get a JSON/dict value from system config. Handles both dict values and JSON strings. """ if default is None: default = {} raw = _system_config.get(key, default) if isinstance(raw, dict): return raw try: return json.loads(raw) except (json.JSONDecodeError, TypeError) as e: logger.error(f"Failed to parse system JSON key '{key}': {e}") return default def get_system_list(key: str, default: Optional[list] = None) -> list: """ Get a list value from system config. Handles both list values and JSON strings. Parameters: key: The config key to retrieve default: Default value if key not found or parsing fails Returns: The list value or default """ if default is None: default = [] raw = _system_config.get(key, default) if isinstance(raw, list): return raw try: result = json.loads(raw) if isinstance(raw, str) else raw if isinstance(result, list): return result logger.warning(f"System config key '{key}' is not a list: {type(result)}") return default except (json.JSONDecodeError, TypeError) as e: logger.error(f"Failed to parse system list key '{key}': {e}") return default def load_user_config(config_dir: Path) -> dict: """ Load user configuration from user_config.json. Creates the file with defaults if it doesn't exist. Parameters: config_dir: Directory containing user_config.json Returns: The user config dict """ global _user_config user_config_path = config_dir / "user_config.json" if not user_config_path.exists(): # Create default user config default_user_config = { "TELEMETRY": False, "TELEM_URL": "", "TEXTUAL_THEME": "gruvbox", "EXTRAS": "NOTTODAY", } user_config_path.parent.mkdir(parents=True, exist_ok=True) with open(user_config_path, "w") as f: json.dump(default_user_config, f, indent=4) logger.debug(f"Created default user config at {user_config_path}") _user_config = default_user_config else: with open(user_config_path, "r") as f: _user_config = json.load(f) logger.debug(f"✅ Loaded user config from {user_config_path}") return _user_config def save_user_config(config_dir: Path, updates: dict) -> None: """ Save updates to user configuration. Only keys in USER_CONFIG_KEYS are allowed. Parameters: config_dir: Directory containing user_config.json updates: Dict of key-value pairs to update """ global _user_config # Validate that only user-configurable keys are being updated invalid_keys = [k for k in updates.keys() if k not in USER_CONFIG_KEYS] if invalid_keys: logger.error(f"Attempted to save invalid user config keys: {invalid_keys}") raise ValueError(f"Cannot modify system config keys: {invalid_keys}") # Update in-memory config _user_config.update(updates) # Write to file user_config_path = config_dir / "user_config.json" user_config_path.parent.mkdir(parents=True, exist_ok=True) with open(user_config_path, "w") as f: json.dump(_user_config, f, indent=4) logger.debug(f"✅ Saved user config to {user_config_path}: {updates}") def get_user_value( key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None ) -> Optional[T]: """ Get a value from user config (mutable). Parameters: key: The config key to retrieve cast_type: Function to cast the value to desired type default: Default value if key not found Returns: The config value cast to the desired type, or default """ value = _user_config.get(key) if value is None: logger.warning(f"User config key '{key}' not found.") return default try: if isinstance(value, str): value = value.strip("'\"") return cast_type(value) except (ValueError, TypeError): logger.warning( f"Invalid value for user key '{key}': {value}. Expected type {cast_type.__name__}." ) return default def load_env( key: str, cast_type: Callable[[str], T] = str, default: Optional[T] = None ) -> Optional[T]: """ Safely retrieves an environment variable from .env and casts it to the desired type. This should ONLY be used for runtime/dynamic values like WORKING_DIR. For system config, use get_system_value(). For user config, use get_user_value(). Parameters: key: The name of the environment variable cast_type: Function to cast the value. Defaults to str default: Default value if the variable is not set or invalid Returns: The casted value or the default """ value = os.getenv(key) if value is None: logger.debug(f"Environment variable '{key}' not set, using default.") return default try: value = value.strip("'\"") # Strip surrounding quotes return cast_type(value) except (ValueError, TypeError): logger.warning( f"Invalid value for env var '{key}': {value}. Expected type {cast_type.__name__}." ) return default def load_env_json(key: str, default: str = "[]") -> Any: """ Load a JSON value from environment or system config. DEPRECATED: This function is kept for backward compatibility. - For system config lists (BAD_PUBLISHERS, PUPS, BAD_PATH_PARTS), use get_system_list() - For system config dicts, use get_system_json() - For actual .env JSON values, parse manually This function automatically redirects known system config keys to system config. """ # Known system config list keys - redirect to system config system_list_keys = ["BAD_PUBLISHERS", "PUPS", "BAD_PATH_PARTS"] if key in system_list_keys: logger.debug(f"Redirecting load_env_json('{key}') to get_system_list()") return get_system_list(key, json.loads(default) if default else []) # Known system config dict keys - redirect to system config system_dict_keys = ["POLICY_MAP_ENF_AUD"] if key in system_dict_keys: logger.debug(f"Redirecting load_env_json('{key}') to get_system_json()") return get_system_json(key, json.loads(default) if default else {}) # Fall back to reading from .env (backward compatibility for unknown keys) raw = os.getenv(key, default) try: return json.loads(raw) except json.JSONDecodeError: try: escaped = raw.encode("unicode_escape").decode("utf-8") return json.loads(escaped) except Exception as e: logger.error(f"Failed to parse {key}: {e}") return json.loads(default) # Backwards compatibility aliases (deprecated - use get_system_value instead) get_protected_value = get_system_value get_protected_json = get_system_json load_protected_config = load_system_config PROTECTED_KEYS = SYSTEM_CONFIG_KEYS # For backwards compatibility