config.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import re
  5. import shutil
  6. import tempfile
  7. from pathlib import Path
  8. from typing import Any, Dict, Optional, Union
  9. import yaml
  10. from rich.console import Console
  11. from .variable import Variable
  12. from .section import VariableSection
  13. from .collection import VariableCollection
  14. from .exceptions import ConfigError, ConfigValidationError, YAMLParseError
  15. logger = logging.getLogger(__name__)
  16. console = Console()
  17. # Valid Python identifier pattern for variable names
  18. VALID_IDENTIFIER_PATTERN = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
  19. # Valid path pattern - prevents path traversal attempts
  20. VALID_PATH_PATTERN = re.compile(r'^[^\x00-\x1f<>:"|?*]+$')
  21. # Maximum allowed string lengths to prevent DOS attacks
  22. MAX_STRING_LENGTH = 1000
  23. MAX_PATH_LENGTH = 4096
  24. MAX_LIST_LENGTH = 100
  25. class ConfigManager:
  26. """Manages configuration for the CLI application."""
  27. def __init__(self, config_path: Optional[Union[str, Path]] = None) -> None:
  28. """Initialize the configuration manager.
  29. Args:
  30. config_path: Path to the configuration file. If None, uses default location.
  31. """
  32. if config_path is None:
  33. # Default to ~/.config/boilerplates/config.yaml
  34. config_dir = Path.home() / ".config" / "boilerplates"
  35. config_dir.mkdir(parents=True, exist_ok=True)
  36. self.config_path = config_dir / "config.yaml"
  37. else:
  38. self.config_path = Path(config_path)
  39. # Create default config if it doesn't exist
  40. if not self.config_path.exists():
  41. self._create_default_config()
  42. def _create_default_config(self) -> None:
  43. """Create a default configuration file."""
  44. default_config = {
  45. "defaults": {},
  46. "preferences": {
  47. "editor": "vim",
  48. "output_dir": None,
  49. "library_paths": []
  50. }
  51. }
  52. self._write_config(default_config)
  53. logger.info(f"Created default configuration at {self.config_path}")
  54. @staticmethod
  55. def _validate_string_length(value: str, field_name: str, max_length: int = MAX_STRING_LENGTH) -> None:
  56. """Validate string length to prevent DOS attacks.
  57. Args:
  58. value: String value to validate
  59. field_name: Name of the field for error messages
  60. max_length: Maximum allowed length
  61. Raises:
  62. ConfigValidationError: If string exceeds maximum length
  63. """
  64. if len(value) > max_length:
  65. raise ConfigValidationError(
  66. f"{field_name} exceeds maximum length of {max_length} characters "
  67. f"(got {len(value)} characters)"
  68. )
  69. @staticmethod
  70. def _validate_path_string(path: str, field_name: str) -> None:
  71. """Validate path string for security concerns.
  72. Args:
  73. path: Path string to validate
  74. field_name: Name of the field for error messages
  75. Raises:
  76. ConfigValidationError: If path contains invalid characters or patterns
  77. """
  78. # Check length
  79. if len(path) > MAX_PATH_LENGTH:
  80. raise ConfigValidationError(
  81. f"{field_name} exceeds maximum path length of {MAX_PATH_LENGTH} characters"
  82. )
  83. # Check for null bytes and control characters
  84. if '\x00' in path or any(ord(c) < 32 for c in path if c not in '\t\n\r'):
  85. raise ConfigValidationError(
  86. f"{field_name} contains invalid control characters"
  87. )
  88. # Check for path traversal attempts
  89. if '..' in path.split('/'):
  90. logger.warning(f"Path '{path}' contains '..' - potential path traversal attempt")
  91. @staticmethod
  92. def _validate_list_length(lst: list, field_name: str, max_length: int = MAX_LIST_LENGTH) -> None:
  93. """Validate list length to prevent DOS attacks.
  94. Args:
  95. lst: List to validate
  96. field_name: Name of the field for error messages
  97. max_length: Maximum allowed length
  98. Raises:
  99. ConfigValidationError: If list exceeds maximum length
  100. """
  101. if len(lst) > max_length:
  102. raise ConfigValidationError(
  103. f"{field_name} exceeds maximum length of {max_length} items (got {len(lst)} items)"
  104. )
  105. def _read_config(self) -> Dict[str, Any]:
  106. """Read configuration from file.
  107. Returns:
  108. Dictionary containing the configuration.
  109. Raises:
  110. YAMLParseError: If YAML parsing fails.
  111. ConfigValidationError: If configuration structure is invalid.
  112. ConfigError: If reading fails for other reasons.
  113. """
  114. try:
  115. with open(self.config_path, 'r') as f:
  116. config = yaml.safe_load(f) or {}
  117. # Validate config structure
  118. self._validate_config_structure(config)
  119. return config
  120. except yaml.YAMLError as e:
  121. logger.error(f"Failed to parse YAML configuration: {e}")
  122. raise YAMLParseError(str(self.config_path), e)
  123. except ConfigValidationError:
  124. # Re-raise validation errors as-is
  125. raise
  126. except (IOError, OSError) as e:
  127. logger.error(f"Failed to read configuration file: {e}")
  128. raise ConfigError(f"Failed to read configuration file '{self.config_path}': {e}")
  129. def _write_config(self, config: Dict[str, Any]) -> None:
  130. """Write configuration to file atomically using temp file + rename pattern.
  131. This prevents config file corruption if write operation fails partway through.
  132. Args:
  133. config: Dictionary containing the configuration to write.
  134. Raises:
  135. ConfigValidationError: If configuration structure is invalid.
  136. ConfigError: If writing fails for any reason.
  137. """
  138. tmp_path = None
  139. try:
  140. # Validate config structure before writing
  141. self._validate_config_structure(config)
  142. # Ensure parent directory exists
  143. self.config_path.parent.mkdir(parents=True, exist_ok=True)
  144. # Write to temporary file in same directory for atomic rename
  145. with tempfile.NamedTemporaryFile(
  146. mode='w',
  147. delete=False,
  148. dir=self.config_path.parent,
  149. prefix='.config_',
  150. suffix='.tmp'
  151. ) as tmp_file:
  152. yaml.dump(config, tmp_file, default_flow_style=False)
  153. tmp_path = tmp_file.name
  154. # Atomic rename (overwrites existing file on POSIX systems)
  155. shutil.move(tmp_path, self.config_path)
  156. logger.debug(f"Configuration written atomically to {self.config_path}")
  157. except ConfigValidationError:
  158. # Re-raise validation errors as-is
  159. if tmp_path:
  160. Path(tmp_path).unlink(missing_ok=True)
  161. raise
  162. except (IOError, OSError, yaml.YAMLError) as e:
  163. # Clean up temp file if it exists
  164. if tmp_path:
  165. try:
  166. Path(tmp_path).unlink(missing_ok=True)
  167. except (IOError, OSError):
  168. logger.warning(f"Failed to clean up temporary file: {tmp_path}")
  169. logger.error(f"Failed to write configuration file: {e}")
  170. raise ConfigError(f"Failed to write configuration to '{self.config_path}': {e}")
  171. def _validate_config_structure(self, config: Dict[str, Any]) -> None:
  172. """Validate the configuration structure with comprehensive checks.
  173. Args:
  174. config: Configuration dictionary to validate.
  175. Raises:
  176. ConfigValidationError: If configuration structure is invalid.
  177. """
  178. if not isinstance(config, dict):
  179. raise ConfigValidationError("Configuration must be a dictionary")
  180. # Check top-level structure
  181. if "defaults" in config and not isinstance(config["defaults"], dict):
  182. raise ConfigValidationError("'defaults' must be a dictionary")
  183. if "preferences" in config and not isinstance(config["preferences"], dict):
  184. raise ConfigValidationError("'preferences' must be a dictionary")
  185. # Validate defaults structure
  186. if "defaults" in config:
  187. for module_name, module_defaults in config["defaults"].items():
  188. if not isinstance(module_name, str):
  189. raise ConfigValidationError(f"Module name must be a string, got {type(module_name).__name__}")
  190. # Validate module name length
  191. self._validate_string_length(module_name, "Module name", max_length=100)
  192. if not isinstance(module_defaults, dict):
  193. raise ConfigValidationError(f"Defaults for module '{module_name}' must be a dictionary")
  194. # Validate number of defaults per module
  195. self._validate_list_length(
  196. list(module_defaults.keys()),
  197. f"Defaults for module '{module_name}'"
  198. )
  199. # Validate variable names are valid Python identifiers
  200. for var_name, var_value in module_defaults.items():
  201. if not isinstance(var_name, str):
  202. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  203. # Validate variable name length
  204. self._validate_string_length(var_name, "Variable name", max_length=100)
  205. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  206. raise ConfigValidationError(
  207. f"Invalid variable name '{var_name}' in module '{module_name}'. "
  208. f"Variable names must be valid Python identifiers (letters, numbers, underscores, "
  209. f"cannot start with a number)"
  210. )
  211. # Validate variable value types and lengths
  212. if isinstance(var_value, str):
  213. self._validate_string_length(
  214. var_value,
  215. f"Value for '{module_name}.{var_name}'"
  216. )
  217. elif isinstance(var_value, list):
  218. self._validate_list_length(
  219. var_value,
  220. f"Value for '{module_name}.{var_name}'"
  221. )
  222. elif var_value is not None and not isinstance(var_value, (bool, int, float)):
  223. raise ConfigValidationError(
  224. f"Invalid value type for '{module_name}.{var_name}': "
  225. f"must be string, number, boolean, list, or null (got {type(var_value).__name__})"
  226. )
  227. # Validate preferences structure and types
  228. if "preferences" in config:
  229. preferences = config["preferences"]
  230. # Validate known preference types
  231. if "editor" in preferences:
  232. if not isinstance(preferences["editor"], str):
  233. raise ConfigValidationError("Preference 'editor' must be a string")
  234. self._validate_string_length(preferences["editor"], "Preference 'editor'", max_length=100)
  235. if "output_dir" in preferences:
  236. output_dir = preferences["output_dir"]
  237. if output_dir is not None:
  238. if not isinstance(output_dir, str):
  239. raise ConfigValidationError("Preference 'output_dir' must be a string or null")
  240. self._validate_path_string(output_dir, "Preference 'output_dir'")
  241. if "library_paths" in preferences:
  242. if not isinstance(preferences["library_paths"], list):
  243. raise ConfigValidationError("Preference 'library_paths' must be a list")
  244. self._validate_list_length(preferences["library_paths"], "Preference 'library_paths'")
  245. for i, path in enumerate(preferences["library_paths"]):
  246. if not isinstance(path, str):
  247. raise ConfigValidationError(f"Library path must be a string, got {type(path).__name__}")
  248. self._validate_path_string(path, f"Library path at index {i}")
  249. def get_config_path(self) -> Path:
  250. """Get the path to the configuration file.
  251. Returns:
  252. Path to the configuration file.
  253. """
  254. return self.config_path
  255. def get_defaults(self, module_name: str) -> Dict[str, Any]:
  256. """Get default variable values for a module.
  257. Returns defaults in a flat format:
  258. {
  259. "var_name": "value",
  260. "var2_name": "value2"
  261. }
  262. Args:
  263. module_name: Name of the module
  264. Returns:
  265. Dictionary of default values (flat key-value pairs)
  266. """
  267. config = self._read_config()
  268. defaults = config.get("defaults", {})
  269. return defaults.get(module_name, {})
  270. def set_defaults(self, module_name: str, defaults: Dict[str, Any]) -> None:
  271. """Set default variable values for a module with comprehensive validation.
  272. Args:
  273. module_name: Name of the module
  274. defaults: Dictionary of defaults (flat key-value pairs):
  275. {"var_name": "value", "var2_name": "value2"}
  276. Raises:
  277. ConfigValidationError: If module name or variable names are invalid.
  278. """
  279. # Validate module name
  280. if not isinstance(module_name, str) or not module_name:
  281. raise ConfigValidationError("Module name must be a non-empty string")
  282. self._validate_string_length(module_name, "Module name", max_length=100)
  283. # Validate defaults dictionary
  284. if not isinstance(defaults, dict):
  285. raise ConfigValidationError("Defaults must be a dictionary")
  286. # Validate number of defaults
  287. self._validate_list_length(list(defaults.keys()), "Defaults dictionary")
  288. # Validate variable names and values
  289. for var_name, var_value in defaults.items():
  290. if not isinstance(var_name, str):
  291. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  292. self._validate_string_length(var_name, "Variable name", max_length=100)
  293. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  294. raise ConfigValidationError(
  295. f"Invalid variable name '{var_name}'. Variable names must be valid Python identifiers "
  296. f"(letters, numbers, underscores, cannot start with a number)"
  297. )
  298. # Validate value types and lengths
  299. if isinstance(var_value, str):
  300. self._validate_string_length(var_value, f"Value for '{var_name}'")
  301. elif isinstance(var_value, list):
  302. self._validate_list_length(var_value, f"Value for '{var_name}'")
  303. elif var_value is not None and not isinstance(var_value, (bool, int, float)):
  304. raise ConfigValidationError(
  305. f"Invalid value type for '{var_name}': "
  306. f"must be string, number, boolean, list, or null (got {type(var_value).__name__})"
  307. )
  308. config = self._read_config()
  309. if "defaults" not in config:
  310. config["defaults"] = {}
  311. config["defaults"][module_name] = defaults
  312. self._write_config(config)
  313. logger.info(f"Updated defaults for module '{module_name}'")
  314. def set_default_value(self, module_name: str, var_name: str, value: Any) -> None:
  315. """Set a single default variable value with comprehensive validation.
  316. Args:
  317. module_name: Name of the module
  318. var_name: Name of the variable
  319. value: Default value to set
  320. Raises:
  321. ConfigValidationError: If module name or variable name is invalid.
  322. """
  323. # Validate inputs
  324. if not isinstance(module_name, str) or not module_name:
  325. raise ConfigValidationError("Module name must be a non-empty string")
  326. self._validate_string_length(module_name, "Module name", max_length=100)
  327. if not isinstance(var_name, str):
  328. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  329. self._validate_string_length(var_name, "Variable name", max_length=100)
  330. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  331. raise ConfigValidationError(
  332. f"Invalid variable name '{var_name}'. Variable names must be valid Python identifiers "
  333. f"(letters, numbers, underscores, cannot start with a number)"
  334. )
  335. # Validate value type and length
  336. if isinstance(value, str):
  337. self._validate_string_length(value, f"Value for '{var_name}'")
  338. elif isinstance(value, list):
  339. self._validate_list_length(value, f"Value for '{var_name}'")
  340. elif value is not None and not isinstance(value, (bool, int, float)):
  341. raise ConfigValidationError(
  342. f"Invalid value type for '{var_name}': "
  343. f"must be string, number, boolean, list, or null (got {type(value).__name__})"
  344. )
  345. defaults = self.get_defaults(module_name)
  346. defaults[var_name] = value
  347. self.set_defaults(module_name, defaults)
  348. logger.info(f"Set default for '{module_name}.{var_name}' = '{value}'")
  349. def get_default_value(self, module_name: str, var_name: str) -> Optional[Any]:
  350. """Get a single default variable value.
  351. Args:
  352. module_name: Name of the module
  353. var_name: Name of the variable
  354. Returns:
  355. Default value or None if not set
  356. """
  357. defaults = self.get_defaults(module_name)
  358. return defaults.get(var_name)
  359. def clear_defaults(self, module_name: str) -> None:
  360. """Clear all defaults for a module.
  361. Args:
  362. module_name: Name of the module
  363. """
  364. config = self._read_config()
  365. if "defaults" in config and module_name in config["defaults"]:
  366. del config["defaults"][module_name]
  367. self._write_config(config)
  368. logger.info(f"Cleared defaults for module '{module_name}'")
  369. def get_preference(self, key: str) -> Optional[Any]:
  370. """Get a user preference value.
  371. Args:
  372. key: Preference key (e.g., 'editor', 'output_dir', 'library_paths')
  373. Returns:
  374. Preference value or None if not set
  375. """
  376. config = self._read_config()
  377. preferences = config.get("preferences", {})
  378. return preferences.get(key)
  379. def set_preference(self, key: str, value: Any) -> None:
  380. """Set a user preference value with comprehensive validation.
  381. Args:
  382. key: Preference key
  383. value: Preference value
  384. Raises:
  385. ConfigValidationError: If key or value is invalid for known preference types.
  386. """
  387. # Validate key
  388. if not isinstance(key, str) or not key:
  389. raise ConfigValidationError("Preference key must be a non-empty string")
  390. self._validate_string_length(key, "Preference key", max_length=100)
  391. # Validate known preference types
  392. if key == "editor":
  393. if not isinstance(value, str):
  394. raise ConfigValidationError("Preference 'editor' must be a string")
  395. self._validate_string_length(value, "Preference 'editor'", max_length=100)
  396. elif key == "output_dir":
  397. if value is not None:
  398. if not isinstance(value, str):
  399. raise ConfigValidationError("Preference 'output_dir' must be a string or null")
  400. self._validate_path_string(value, "Preference 'output_dir'")
  401. elif key == "library_paths":
  402. if not isinstance(value, list):
  403. raise ConfigValidationError("Preference 'library_paths' must be a list")
  404. self._validate_list_length(value, "Preference 'library_paths'")
  405. for i, path in enumerate(value):
  406. if not isinstance(path, str):
  407. raise ConfigValidationError(f"Library path must be a string, got {type(path).__name__}")
  408. self._validate_path_string(path, f"Library path at index {i}")
  409. # For unknown preference keys, apply basic validation
  410. else:
  411. if isinstance(value, str):
  412. self._validate_string_length(value, f"Preference '{key}'")
  413. elif isinstance(value, list):
  414. self._validate_list_length(value, f"Preference '{key}'")
  415. config = self._read_config()
  416. if "preferences" not in config:
  417. config["preferences"] = {}
  418. config["preferences"][key] = value
  419. self._write_config(config)
  420. logger.info(f"Set preference '{key}' = '{value}'")
  421. def get_all_preferences(self) -> Dict[str, Any]:
  422. """Get all user preferences.
  423. Returns:
  424. Dictionary of all preferences
  425. """
  426. config = self._read_config()
  427. return config.get("preferences", {})