Skip to content

Commit dd5af05

Browse files
authored
fix: prevent silent failures on invalid placeholder syntax (#237)
* fix #233: strict syntax validation for configuration placeholders Signed-off-by: sushant-suse <[email protected]> * fix #233: ruff issues Signed-off-by: sushant-suse <[email protected]> * fix: removed irrelevant files Signed-off-by: sushant-suse <[email protected]> --------- Signed-off-by: sushant-suse <[email protected]>
1 parent 063f0a8 commit dd5af05

6 files changed

Lines changed: 96 additions & 6 deletions

File tree

changelog.d/233.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added strict syntax validation for configuration placeholders. The application will now explicitly report an error and halt when encountering malformed placeholders (e.g., missing or incorrectly ordered curly braces like ``{server.name``) instead of silently leaving them unresolved.

src/docbuild/config/app.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ class CircularReferenceError(ValueError):
2424
pass
2525

2626

27+
class PlaceholderSyntaxError(ValueError):
28+
"""Exception raised when a placeholder has invalid syntax."""
29+
30+
pass
31+
32+
2733
class PlaceholderResolver:
2834
"""Handles placeholder resolution in configuration data."""
2935

@@ -86,17 +92,43 @@ def _resolve_placeholder(self, match: re.Match) -> str:
8692
f"key '{placeholder}' not found in current section.",
8793
)
8894

95+
def validate_brace_syntax(self, text: str, original_text: str) -> None:
96+
"""Validate that curly braces are balanced and properly ordered."""
97+
brace_level = 0
98+
for char in text:
99+
if char == "{":
100+
brace_level += 1
101+
elif char == "}":
102+
brace_level -= 1
103+
104+
# If it ever drops below 0, we've hit a '}' before a '{'
105+
if brace_level < 0:
106+
break
107+
108+
# If the level isn't exactly 0 at the end, the syntax is broken
109+
if brace_level != 0:
110+
container_name = self._get_container_name()
111+
112+
if "{" in text and "}" not in text:
113+
msg = f"Missing end curly brace in placeholder in value: '{original_text}'"
114+
elif "}" in text and "{" not in text:
115+
msg = f"Missing start curly brace in placeholder in value: '{original_text}'"
116+
else:
117+
msg = f"Invalid placeholder syntax in value: '{original_text}'"
118+
119+
raise PlaceholderSyntaxError(f"In configuration key '{container_name}': {msg}")
120+
89121
def _resolve_string_placeholders(self, text: str) -> str:
90122
"""Resolve all placeholders in a string with recursion protection."""
123+
original_text = text
91124
count = 0
92125
cls = self.__class__
126+
127+
# 1. Resolve valid placeholders
93128
while count < self.max_recursion_depth:
94129
new_text = cls.PLACEHOLDER_PATTERN.sub(self._resolve_placeholder, text)
95-
96130
if new_text == text:
97-
# No more changes, we're done
98131
break
99-
100132
text = new_text
101133
count += 1
102134

@@ -106,7 +138,10 @@ def _resolve_string_placeholders(self, text: str) -> str:
106138
f"Too many nested placeholder expansions in key '{key_name}'."
107139
)
108140

109-
# Replace escaped braces with literal ones
141+
# 2. Syntax Validation using the helper method
142+
self.validate_brace_syntax(text, original_text)
143+
144+
# 3. Final cleanup of escapes
110145
return text.replace("{{", "{").replace("}}", "}")
111146

112147
def _get_container_name(self) -> str:
@@ -134,6 +169,7 @@ def replace(self) -> dict[str, Any]:
134169
:return: The configuration with all placeholders resolved.
135170
:raises PlaceholderResolutionError: If a placeholder cannot be resolved.
136171
:raises CircularReferenceError: If a circular reference is detected.
172+
:raises PlaceholderSyntaxError: If a placeholder has invalid syntax.
137173
"""
138174
# Use a stack to process all items iteratively
139175
# Stack items: (container, key, context) where container can be dict or list
@@ -183,6 +219,7 @@ def replace_placeholders(
183219
:return: A new dictionary with placeholders replaced.
184220
:raises PlaceholderResolutionError: If a placeholder cannot be resolved.
185221
:raises CircularReferenceError: If a circular reference is detected.
222+
:raises PlaceholderSyntaxError: If a placeholder has invalid syntax.
186223
"""
187224
if not isinstance(config, dict):
188225
return config

src/docbuild/models/config/app.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from docbuild.config.app import (
1111
CircularReferenceError,
1212
PlaceholderResolutionError,
13+
PlaceholderSyntaxError,
1314
replace_placeholders,
1415
)
1516
from docbuild.logging import DEFAULT_LOGGING_CONFIG
@@ -235,7 +236,12 @@ def _resolve_placeholders(cls, data: dict[str, Any]) -> dict[str, Any] | None:
235236
if isinstance(data, dict):
236237
try:
237238
return replace_placeholders(deepcopy(data))
238-
except (PlaceholderResolutionError, CircularReferenceError) as e:
239+
except (
240+
PlaceholderResolutionError,
241+
CircularReferenceError,
242+
PlaceholderSyntaxError
243+
) as e:
244+
# This wraps the error so Pydantic reports it as a validation failure
239245
raise ValueError(f"Configuration placeholder error: {e}") from e
240246
return data
241247

src/docbuild/models/config/env.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ...config.app import (
1818
CircularReferenceError,
1919
PlaceholderResolutionError,
20+
PlaceholderSyntaxError,
2021
replace_placeholders,
2122
)
2223
from ..language import LanguageCode
@@ -407,7 +408,11 @@ def _resolve_placeholders(cls, data: dict[str, Any]) -> dict[str, Any] | None:
407408
if isinstance(data, dict):
408409
try:
409410
return replace_placeholders(deepcopy(data))
410-
except (PlaceholderResolutionError, CircularReferenceError) as e:
411+
except (
412+
PlaceholderResolutionError,
413+
CircularReferenceError,
414+
PlaceholderSyntaxError
415+
) as e:
411416
raise ValueError(f"Configuration placeholder error: {e}") from e
412417
return data
413418

tests/config/app/test_app_replace_placeholders.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
CircularReferenceError,
99
PlaceholderResolutionError,
1010
PlaceholderResolver,
11+
PlaceholderSyntaxError,
1112
replace_placeholders,
1213
)
1314
from docbuild.models.config.app import AppConfig
@@ -331,3 +332,24 @@ def test_nested_list_processing_appends_stack_items():
331332
out = replace_placeholders(config)
332333
assert out["seq"][0]["inner"] == "VAL"
333334
assert out["seq"][1] == "VAL"
335+
336+
337+
def test_placeholder_syntax_missing_end():
338+
"""Test orphaned opening brace."""
339+
resolver = PlaceholderResolver({"key": "{server.name"})
340+
with pytest.raises(PlaceholderSyntaxError, match="Missing end curly brace"):
341+
resolver.replace()
342+
343+
344+
def test_placeholder_syntax_missing_start():
345+
"""Test orphaned closing brace."""
346+
resolver = PlaceholderResolver({"key": "server.name}"})
347+
with pytest.raises(PlaceholderSyntaxError, match="Missing start curly brace"):
348+
resolver.replace()
349+
350+
351+
def test_placeholder_syntax_swapped_escapes():
352+
"""Test the reviewer's edge case: }} hello {{."""
353+
resolver = PlaceholderResolver({"key": "}} hello {{"})
354+
with pytest.raises(PlaceholderSyntaxError, match="Invalid placeholder syntax"):
355+
resolver.replace()

tests/models/config/test_env.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,22 @@ def test_envconfig_with_non_dict_input():
295295
# Pydantic will then raise a ValidationError because the input is not a dict.
296296
with pytest.raises(ValidationError, match="Input should be a valid dictionary"):
297297
EnvConfig.model_validate("not a dict")
298+
299+
300+
def test_env_config_invalid_placeholder_syntax():
301+
"""Ensure EnvConfig raises ValueError for malformed placeholders."""
302+
invalid_data = {
303+
"server": {"name": "test", "role": "production", "host": "127.0.0.1", "enable_mail": False},
304+
"config": {"default_lang": "en-us", "languages": ["en-us"], "canonical_url_domain": "https://example.com"},
305+
"paths": {
306+
# ... other required paths ...
307+
"base_server_cache_dir": "{base_cache_dir}/{server.name" # THE BUG
308+
},
309+
"build": {
310+
"daps": {"command": "daps", "meta": "daps metadata"},
311+
"container": {"container": "some-image"}
312+
}
313+
}
314+
# We expect a ValueError because our model validator wraps PlaceholderSyntaxError
315+
with pytest.raises(ValueError, match="Configuration placeholder error"):
316+
EnvConfig.from_dict(invalid_data)

0 commit comments

Comments
 (0)