Skip to content

Commit fbf4140

Browse files
authored
fix: validate nested configuration value graphs (#365)
## Summary - Validate string keys throughout nested mapping/list values before merge or provenance traversal. - Reject cycles and excessive depth/traversal with source-aware ConfigurationError messages while permitting shared aliases. - Cover first insertion, overlays, native/attached human/JSON boundaries, cycles, and depth. Closes #359 ## Validation - `uv run --extra dev --extra typer --extra quality python -m pytest tests/test_batteries_included_config.py tests/test_explicit_config_validation.py -q` - `uv run --extra dev --extra typer --extra quality python -m mypy --strict lib/python/base_cli/config.py` - Ruff check/format and `git diff --check`
1 parent 9438ecf commit fbf4140

5 files changed

Lines changed: 185 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ and versions are tracked in the repo-root `VERSION` file.
2323

2424
### Fixed
2525

26+
- Validate nested configuration mappings before merge/provenance traversal,
27+
reject recursive or excessively deep values with source-aware errors, and
28+
continue to accept shared YAML aliases.
2629
- Reject recursive and concurrent in-process `run_app()` calls before they can
2730
replace another invocation's stdout or logging handlers.
2831
- Rotate bounded byte-retention size walks across invocations with a persisted

‎docs/consumer-profiles.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ explicit, project, or user base `environment` value is used, falling back to
144144
lower-precedence value. `Context.config_provenance` records the winning source
145145
for each dotted key.
146146

147+
All mapping keys must be strings, including keys nested inside sequences.
148+
Recursive configuration values are rejected, nesting is limited to 64 levels,
149+
and shared YAML aliases are accepted when they do not form a cycle. Errors name
150+
the configuration source and relevant nested path.
151+
147152
The reserved framework keys `environment`, `log_level`, and `keep_temp` are
148153
validated into `Context.framework_config` and are excluded from the consumer
149154
configuration dictionary. All other keys remain consumer-owned and are exposed

‎lib/python/base_cli/config.py‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
_LOG_LEVELS = frozenset({"debug", "info", "warning", "error", "critical"})
2727
_SAFE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*\Z")
2828
_SAFE_FILENAME = re.compile(r"(?:[A-Za-z0-9][A-Za-z0-9_.-]*|\.[A-Za-z0-9][A-Za-z0-9_.-]*)\Z")
29+
_CONFIG_MAX_DEPTH = 64
30+
_CONFIG_MAX_NODES = 100_000
2931

3032

3133
@dataclass(frozen=True)
@@ -100,21 +102,74 @@ def _leaf_provenance(
100102
return {prefix: source} if prefix else {}
101103

102104

105+
def _validate_config_graph(value: Mapping[str, Any], *, source: str) -> None:
106+
"""Validate nested mapping keys, cycles, depth, and traversal cost."""
107+
108+
active: set[int] = set()
109+
stack: list[tuple[bool, Any, str, int]] = [(False, value, "", 0)]
110+
visited_nodes = 0
111+
while stack:
112+
exiting, current, path, depth = stack.pop()
113+
identity = id(current)
114+
if exiting:
115+
active.remove(identity)
116+
continue
117+
visited_nodes += 1
118+
if visited_nodes > _CONFIG_MAX_NODES:
119+
raise ConfigurationError(
120+
f"Configuration source {source} exceeds the maximum of {_CONFIG_MAX_NODES} nested values."
121+
)
122+
if not isinstance(current, (Mapping, list, tuple)):
123+
continue
124+
if identity in active:
125+
location = path or "<root>"
126+
raise ConfigurationError(f"Configuration source {source} contains a recursive value at '{location}'.")
127+
if depth > _CONFIG_MAX_DEPTH:
128+
location = path or "<root>"
129+
raise ConfigurationError(
130+
f"Configuration source {source} exceeds the maximum nesting depth of {_CONFIG_MAX_DEPTH} at "
131+
f"'{location}'."
132+
)
133+
active.add(identity)
134+
stack.append((True, current, path, depth))
135+
if isinstance(current, Mapping):
136+
children = list(current.items())
137+
for key, child in reversed(children):
138+
if not isinstance(key, str):
139+
location = path or "<root>"
140+
raise ConfigurationError(f"Configuration source {source} has a non-string key under '{location}'.")
141+
child_path = f"{path}.{key}" if path else key
142+
stack.append((False, child, child_path, depth + 1))
143+
else:
144+
for index, child in reversed(tuple(enumerate(current))):
145+
stack.append((False, child, f"{path}[{index}]", depth + 1))
146+
147+
103148
def _merge_mapping(
104149
target: dict[str, Any],
105150
provenance: dict[str, str],
106151
incoming: Mapping[str, Any],
107152
source: str,
108153
*,
109154
prefix: str = "",
155+
) -> None:
156+
_validate_config_graph(incoming, source=source)
157+
_merge_mapping_validated(target, provenance, incoming, source, prefix=prefix)
158+
159+
160+
def _merge_mapping_validated(
161+
target: dict[str, Any],
162+
provenance: dict[str, str],
163+
incoming: Mapping[str, Any],
164+
source: str,
165+
*,
166+
prefix: str = "",
110167
) -> None:
111168
for key, value in incoming.items():
112-
if not isinstance(key, str):
113-
raise ConfigurationError("Configuration keys must be strings.")
114169
path = f"{prefix}.{key}" if prefix else key
115170
previous = target.get(key)
116171
if isinstance(previous, Mapping) and isinstance(value, Mapping):
117-
_merge_mapping(target[key], provenance, value, source, prefix=path)
172+
_merge_mapping_validated(target[key], provenance, value, source, prefix=path)
118173
continue
119174
for existing_path in tuple(provenance):
120175
if existing_path == path or existing_path.startswith(f"{path}."):
@@ -267,10 +322,15 @@ def load_yaml_file(path: Path, *, required: bool = False) -> dict[str, Any]:
267322
raise ConfigurationError(f"Unable to read config file '{path}': {exc}") from exc
268323
try:
269324
data = yaml.safe_load(contents)
325+
except RecursionError as exc:
326+
raise ConfigurationError(
327+
f"Config file '{path}' exceeds the maximum nesting depth of {_CONFIG_MAX_DEPTH}."
328+
) from exc
270329
except yaml.YAMLError as exc:
271330
raise ConfigurationError(f"Config file '{path}' contains invalid YAML: {exc}") from exc
272331
if data is None:
273332
return {}
274333
if not isinstance(data, dict):
275334
raise ConfigurationError(f"Config file '{path}' must contain a YAML mapping.")
335+
_validate_config_graph(data, source=f"Config file '{path}'")
276336
return data

‎tests/test_batteries_included_config.py‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

3+
import json
34
import tempfile
45
import unittest
56
from pathlib import Path
7+
from typing import Any
68
from unittest.mock import patch
79

810
import base_cli
@@ -15,6 +17,17 @@ def _write_yaml(path: Path, contents: str) -> None:
1517
path.write_text(contents, encoding="utf-8")
1618

1719

20+
def _combined_output(result: Any) -> str:
21+
"""Return human-readable output across Click's split/combined result APIs."""
22+
23+
output = result.output
24+
try:
25+
stderr = result.stderr
26+
except ValueError:
27+
stderr = ""
28+
return output if not stderr or stderr in output else output + stderr
29+
30+
1831
class BatteriesIncludedConfigTests(unittest.TestCase):
1932
def test_nested_merge_provenance_keeps_repeated_leaf_names_scoped(self) -> None:
2033
values: dict[str, object] = {}
@@ -29,6 +42,96 @@ def test_nested_merge_provenance_keeps_repeated_leaf_names_scoped(self) -> None:
2942
{"host": "user", "db.host": "project", "db.tls.enabled": "project"},
3043
)
3144

45+
def test_nested_non_string_keys_are_rejected_before_first_insert_or_overlay(self) -> None:
46+
for initial in ({}, {"nested": {"keep": True}}):
47+
with self.subTest(initial=initial):
48+
values = dict(initial)
49+
provenance: dict[str, str] = {"existing": "prior"}
50+
original_values = dict(values)
51+
original_provenance = dict(provenance)
52+
with self.assertRaisesRegex(base_cli.ConfigurationError, "nested"):
53+
_merge_mapping(values, provenance, {"nested": {2: "invalid"}}, "explicit")
54+
self.assertEqual(values, original_values)
55+
self.assertEqual(provenance, original_provenance)
56+
57+
def test_shared_mapping_alias_is_valid_but_recursive_alias_is_rejected_with_path(self) -> None:
58+
shared = {"answer": 42}
59+
valid = {"left": shared, "right": shared}
60+
values: dict[str, object] = {}
61+
provenance: dict[str, str] = {}
62+
_merge_mapping(values, provenance, valid, "user")
63+
self.assertEqual(values, {"left": shared, "right": shared})
64+
self.assertEqual(provenance, {"left.answer": "user", "right.answer": "user"})
65+
66+
with tempfile.TemporaryDirectory() as tmpdir:
67+
path = Path(tmpdir) / "recursive.yaml"
68+
_write_yaml(path, "nested: &node\n child: *node\n")
69+
with self.assertRaisesRegex(base_cli.ConfigurationError, "recursive.yaml.*recursive value.*nested.child"):
70+
BatteriesIncludedConfigLoader(user_config_dir=Path(tmpdir) / "user").load(None, path)
71+
72+
def test_mapping_depth_is_bounded_before_recursive_merge_or_provenance(self) -> None:
73+
nested: dict[str, object] = {"value": 1}
74+
for index in range(65):
75+
nested = {f"level{index}": nested}
76+
77+
with self.assertRaisesRegex(base_cli.ConfigurationError, "maximum nesting depth of 64"):
78+
_merge_mapping({}, {}, nested, "explicit")
79+
80+
def test_scalar_nodes_count_toward_configuration_graph_limit(self) -> None:
81+
with self.assertRaisesRegex(base_cli.ConfigurationError, "maximum of 100000 nested values"):
82+
_merge_mapping({}, {}, {"values": [0] * 100_000}, "explicit")
83+
84+
def test_nested_invalid_yaml_shape_is_usage_error_in_human_and_json_modes(self) -> None:
85+
import click
86+
87+
with tempfile.TemporaryDirectory() as tmpdir:
88+
root = Path(tmpdir)
89+
config_path = root / "invalid.yaml"
90+
_write_yaml(config_path, "nested:\n 2: invalid\n")
91+
profile = base_cli.CliProfile.batteries_included(
92+
"invalid-nested-config",
93+
user_config_dir=root / "user",
94+
)
95+
app = base_cli.App(
96+
name="invalid-nested-config",
97+
profile=profile,
98+
log_to_file=False,
99+
lifecycle_options=base_cli.LifecycleOptions(json=base_cli.LifecycleOption("--json")),
100+
)
101+
102+
@app.command()
103+
def main(ctx: base_cli.Context) -> None:
104+
del ctx
105+
106+
@click.command(name="invalid-nested-config")
107+
def attached_command() -> None:
108+
pass
109+
110+
attached_app = base_cli.App(
111+
name="invalid-nested-config",
112+
profile=base_cli.CliProfile.batteries_included(
113+
"invalid-nested-config",
114+
user_config_dir=root / "user",
115+
),
116+
log_to_file=False,
117+
lifecycle_options=base_cli.LifecycleOptions(json=base_cli.LifecycleOption("--json")),
118+
)
119+
attached = attached_app.attach(attached_command)
120+
targets = (("native", app), ("attached", attached))
121+
for target_name, target in targets:
122+
for args in (["--config", str(config_path)], ["--json", "--config", str(config_path)]):
123+
with self.subTest(target=target_name, json="--json" in args):
124+
result = invoke(target, list(args), home=root / f"home-{target_name}-{len(args)}")
125+
output = _combined_output(result)
126+
self.assertEqual(result.exit_code, 2, output)
127+
self.assertNotIn("RecursionError", output)
128+
if "--json" in args:
129+
payload = json.loads(result.stdout)
130+
self.assertEqual(payload["code"], "usage_error")
131+
self.assertIn(str(config_path), payload["message"])
132+
else:
133+
self.assertIn(str(config_path), output)
134+
32135
def test_layered_loader_merges_in_documented_order_and_records_provenance(self) -> None:
33136
with tempfile.TemporaryDirectory() as tmpdir:
34137
root = Path(tmpdir)

‎tests/test_optional_yaml_dependency.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ def test_yaml_config_explains_optional_install_when_yaml_is_missing(self) -> Non
6767
with self.assertRaisesRegex(ConfigurationError, r"base-cli\[yaml\]"):
6868
load_yaml_file(path, required=True)
6969

70+
def test_yaml_config_converts_parser_recursion_error(self) -> None:
71+
with tempfile.TemporaryDirectory() as tmpdir:
72+
path = Path(tmpdir) / "config.yaml"
73+
path.write_text("answer: 42\n", encoding="utf-8")
74+
yaml = mock.Mock()
75+
yaml.safe_load.side_effect = RecursionError("parser recursion")
76+
yaml.YAMLError = type("YAMLError", (Exception,), {})
77+
with mock.patch("base_cli.config.require_yaml", return_value=yaml):
78+
with self.assertRaisesRegex(ConfigurationError, r"maximum nesting depth of 64"):
79+
load_yaml_file(path, required=True)
80+
7081
def test_core_facade_import_does_not_import_yaml(self) -> None:
7182
self.assertIn("base_cli", sys.modules)
7283
self.assertTrue(hasattr(base_cli, "App"))

0 commit comments

Comments
 (0)