Make var name collapse underscores

This commit is contained in:
Miguel Jacq 2026-06-29 20:46:07 +10:00
parent 5ae85ad11e
commit 3573e8e750
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
2 changed files with 57 additions and 2 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@ -57,8 +58,20 @@ class BaseHandler:
role_prefix_section_subsection_key role_prefix_section_subsection_key
Sanitises parts to lowercase [a-z0-9_] and strips extras. 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] = [] clean_parts: list[str] = []
for part in path: for part in path:
@ -70,7 +83,9 @@ class BaseHandler:
cleaned_chars.append(c.lower()) cleaned_chars.append(c.lower())
else: else:
cleaned_chars.append("_") 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: if cleaned_part:
clean_parts.append(cleaned_part) clean_parts.append(cleaned_part)

View file

@ -191,3 +191,43 @@ def test_flatten_config_unsupported_format():
flatten_config("bogusfmt", parsed=None) flatten_config("bogusfmt", parsed=None)
assert "Unsupported format" in str(exc.value) 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)