From 55f4ae531df558b80c3df4a4de883b04cef4521b Mon Sep 17 00:00:00 2001 From: X6 M Date: Fri, 6 Nov 2026 15:14:16 +0100 Subject: [PATCH] fix(config): prevent crashes on non-mapping root and non-string extends (fixes #119) --- src/profiles/core/config/diagnostics.py | 11 +++++++++-- src/profiles/core/config/validator.py | 20 ++++++++++++-------- tests/core/config/test_diagnostics.py | 13 +++++++++++++ tests/core/config/test_validator.py | 10 ++++++++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/profiles/core/config/diagnostics.py b/src/profiles/core/config/diagnostics.py index 102ec23..88c1cc3 100644 --- a/src/profiles/core/config/diagnostics.py +++ b/src/profiles/core/config/diagnostics.py @@ -39,10 +39,15 @@ def collect_config_issues(text: str) -> list[dict[str, str]]: raw = read_yaml_text(text) except Exception as exc: # noqa: BLE001 — any parser failure is report data return [{"severity": "error", "message": f"YAML syntax error: {exc}"}] - if not raw: + if not raw and not isinstance(raw, (int, float, bool)): return [{"severity": "error", "message": "configuration file is empty"}] + if not isinstance(raw, dict): + return [{"severity": "error", "message": "configuration root must be a mapping"}] - issues = [{"severity": "error", "message": str(error)} for error in collect_issues(raw)] + try: + issues = [{"severity": "error", "message": str(error)} for error in collect_issues(raw)] + except Exception as exc: # noqa: BLE001 + issues = [{"severity": "error", "message": f"semantic validation error: {exc}"}] try: AppConfigYaml.model_validate(raw) @@ -53,6 +58,8 @@ def collect_config_issues(text: str) -> list[dict[str, str]]: issues.append( {"severity": "error", "message": f"{location}: {message}" if location else message} ) + except Exception as exc: # noqa: BLE001 + issues.append({"severity": "error", "message": f"schema validation error: {exc}"}) return issues diff --git a/src/profiles/core/config/validator.py b/src/profiles/core/config/validator.py index f055617..bda5cb3 100644 --- a/src/profiles/core/config/validator.py +++ b/src/profiles/core/config/validator.py @@ -32,9 +32,12 @@ def collect_issues(raw: dict) -> list[ConfigError]: """ errors: list[ConfigError] = [] + if not isinstance(raw, dict): + return [ConfigError("", "configuration root must be a mapping")] + for key in raw: - if key not in _KNOWN_TOP_LEVEL: - errors.append(ConfigError(key, f"unknown top-level key '{key}'")) + if not isinstance(key, str) or key not in _KNOWN_TOP_LEVEL: + errors.append(ConfigError(str(key), f"unknown top-level key '{key}'")) configs = raw.get("configs") or {} if not isinstance(configs, dict): @@ -46,7 +49,7 @@ def collect_issues(raw: dict) -> list[ConfigError]: extends = cfg.get("extends") if extends is None: continue - if extends not in configs: + if not isinstance(extends, str) or extends not in configs: errors.append( ConfigError( f"configs.{name}.extends", @@ -76,13 +79,14 @@ def visit(name: str, stack: tuple[str, ...]) -> None: return visiting.add(name) extends = configs[name].get("extends") - if extends is not None: + if isinstance(extends, str): visit(extends, (*stack, name)) visiting.discard(name) visited.add(name) for name in configs: - visit(name, (name,)) + if isinstance(name, str): + visit(name, (name,)) return errors @@ -100,9 +104,9 @@ def validate(raw: dict) -> None: ConfigError: On unknown top-level keys, unknown ``extends`` references, or inheritance cycles. """ - errors = collect_issues(raw) - if errors: - raise errors[0] + issues = collect_issues(raw) + if issues: + raise issues[0] __all__ = ["collect_issues", "validate"] diff --git a/tests/core/config/test_diagnostics.py b/tests/core/config/test_diagnostics.py index 60d5938..630fd8c 100644 --- a/tests/core/config/test_diagnostics.py +++ b/tests/core/config/test_diagnostics.py @@ -69,3 +69,16 @@ def test_all_issues_carry_error_severity() -> None: issues = collect_config_issues(content) assert issues assert all(issue["severity"] == "error" for issue in issues) + + +def test_non_mapping_root_reports_error() -> None: + issues_scalar = collect_config_issues("42\n") + assert any("root must be a mapping" in i["message"] for i in issues_scalar) + + issues_list = collect_config_issues("- item1\n- item2\n") + assert any("root must be a mapping" in i["message"] for i in issues_list) + + +def test_non_hashable_extends_reports_error_without_crash() -> None: + issues = collect_config_issues("configs: {a: {extends: [b]}}\n") + assert any("configs.a.extends" in i["message"] for i in issues) diff --git a/tests/core/config/test_validator.py b/tests/core/config/test_validator.py index ec985db..98d4dd4 100644 --- a/tests/core/config/test_validator.py +++ b/tests/core/config/test_validator.py @@ -84,3 +84,13 @@ def test_valid_content_collects_nothing(self) -> None: "configs": {"prod": {"extends": "base"}, "base": {}}, } assert collect_issues(raw) == [] + + def test_non_mapping_root(self) -> None: + errors = collect_issues(42) # type: ignore[arg-type] + assert len(errors) == 1 + assert "root must be a mapping" in errors[0].message + + def test_non_string_extends(self) -> None: + raw = {"configs": {"a": {"extends": ["b"]}}} + errors = collect_issues(raw) + assert any(e.path == "configs.a.extends" for e in errors)