From 3573e8e7501c6a888732bc5f812f4bc12f93e8bc Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 29 Jun 2026 20:46:07 +1000 Subject: [PATCH] Make var name collapse underscores --- src/jinjaturtle/handlers/base.py | 19 +++++++++++++-- tests/test_core_utils.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/jinjaturtle/handlers/base.py b/src/jinjaturtle/handlers/base.py index 14aaec7..6be91f0 100644 --- a/src/jinjaturtle/handlers/base.py +++ b/src/jinjaturtle/handlers/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path from typing import Any, Iterable @@ -57,8 +58,20 @@ class BaseHandler: role_prefix_section_subsection_key Sanitises parts to lowercase [a-z0-9_] and strips extras. + + Consecutive separators are collapsed to a single underscore. This is + required for correctness, not just aesthetics: a source key such as + ``log..level`` or ``cache--size`` would otherwise sanitise to a name + containing a double underscore (``log__level``). The output safety gate + in ``safety.py`` deliberately rejects *any* ``__`` in a generated + identifier because ``__`` is the gateway to every Jinja2 SSTI gadget + (``__class__``/``__globals__``/...). Emitting a dunder here would make + JinjaTurtle's own gate reject JinjaTurtle's own placeholder, aborting + generation on entirely benign config. Collapsing runs keeps every + generated name a plain single-underscore-delimited identifier that the + gate accepts. """ - role_prefix = role_prefix.strip().lower() + role_prefix = re.sub(r"_+", "_", role_prefix.strip().lower()) clean_parts: list[str] = [] for part in path: @@ -70,7 +83,9 @@ class BaseHandler: cleaned_chars.append(c.lower()) else: cleaned_chars.append("_") - cleaned_part = "".join(cleaned_chars).strip("_") + # Collapse runs of underscores (from adjacent separators) to a + # single "_" so the result can never contain a forbidden "__". + cleaned_part = re.sub(r"_+", "_", "".join(cleaned_chars)).strip("_") if cleaned_part: clean_parts.append(cleaned_part) diff --git a/tests/test_core_utils.py b/tests/test_core_utils.py index c8e41e1..fda6dd0 100644 --- a/tests/test_core_utils.py +++ b/tests/test_core_utils.py @@ -191,3 +191,43 @@ def test_flatten_config_unsupported_format(): flatten_config("bogusfmt", parsed=None) assert "Unsupported format" in str(exc.value) + + +def test_make_var_name_collapses_adjacent_separators(): + """Regression: adjacent separators must not produce a forbidden ``__``. + + A source key such as ``log..level`` or ``cache--size`` previously sanitised + to a name containing a double underscore (``..._log__level``). The output + safety gate in safety.py rejects *any* ``__`` in a generated identifier + (it is the gateway to Jinja2 SSTI gadgets), so emitting one made JinjaTurtle + reject its own placeholder and abort generation on entirely benign config. + make_var_name now collapses runs of underscores to a single ``_``. + """ + for raw_key in ("log..level", "cache--size", "a...b", "x.-.y", "a..b--c"): + name = make_var_name("role", ("main", raw_key)) + assert "__" not in name, f"{raw_key!r} produced {name!r}" + # Still a valid Ansible/Jinja identifier. + assert name.replace("_", "").isalnum() or name == "role" + + # The double-underscore-free collapse is stable and predictable. + assert make_var_name("role", ("main", "log..level")) == "role_main_log_level" + assert make_var_name("role", ("main", "cache--size")) == "role_main_cache_size" + # A leading-digit-free prefix with its own repeated separators is collapsed too. + assert make_var_name("My__Role", ("k",)) == "my_role_k" + + +def test_make_var_name_collapsed_names_pass_output_safety_gate(): + """The names make_var_name emits must be accepted by the safety gate. + + This binds the two modules together: whatever identifier make_var_name + produces for a hostile-looking key must lex as the JinjaTurtle subset, so a + real template built from it is not refused. + """ + from jinjaturtle.safety import verify_jinja2_template_safe + + for raw_key in ("log..level", "cache--size", "a...b", "weird..key..name"): + var = make_var_name("demo", ("section", raw_key)) + # Build the kind of expression a handler would emit for this variable. + template = "{{ " + var + " }}" + # Must not raise TemplateSafetyError. + verify_jinja2_template_safe(template)