Compare commits

..

No commits in common. "15770c8e3d3129d23a20024cce3c89f31953af41" and "f5de32b778503f6056069762211db685d1af34e6" have entirely different histories.

16 changed files with 236 additions and 806 deletions

View file

@ -17,29 +17,13 @@ jobs:
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-venv pipx DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-venv pipx
- name: Install Poetry - name: Install Poetry
env:
PYTHON_BIN: ${{ matrix.python }}
POETRY_VERSION: "2.4.1"
run: | run: |
set -eux pipx install poetry==1.8.3
if ! command -v pipx >/dev/null 2>&1; then /root/.local/bin/poetry --version
"${PYTHON_BIN}" -m pip install --user pipx
fi
PIPX_BIN="$(command -v pipx || true)"
if [ -z "${PIPX_BIN}" ]; then
PIPX_BIN="${HOME}/.local/bin/pipx"
fi
"${PIPX_BIN}" install --python "${PYTHON_BIN}" "poetry==${POETRY_VERSION}"
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
poetry --version
poetry --version | grep -E "Poetry \(version 2\."
- name: Install project deps (including test extras) - name: Install project deps (including test extras)
env:
PYTHON_BIN: ${{ matrix.python }}
run: | run: |
poetry env use "${PYTHON_BIN}"
poetry install --with dev poetry install --with dev
- name: Run test script - name: Run test script

View file

@ -115,8 +115,9 @@ ERB mode the file is intended to be Puppet Hiera data rather than Ansible role
defaults. defaults.
JinjaTurtle does **not** generate Puppet classes or `file` resources. A Puppet JinjaTurtle does **not** generate Puppet classes or `file` resources. A Puppet
module, should still declare the class parameters and call the template, for module, or another tool such as Enroll, should still declare the class
example with Puppet's `template()` function. parameters and call the template, for example with Puppet's `template()`
function.
## Using `--puppet-class` ## Using `--puppet-class`
@ -339,53 +340,15 @@ JinjaTurtle also templates some common bespoke config formats:
For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content
sniffing. You can always force a specific handler with `--format`. sniffing. You can always force a specific handler with `--format`.
## Security model ## Relationship with Enroll
JinjaTurtle is frequently pointed at config files that were *harvested* from JinjaTurtle can be used directly, but it is also useful as a helper for tools
real systems, where some content may be influenced by an untrusted party (a that generate configuration-management code.
hostname, a login banner, a `GECOS` comment, a "Managed by ..." note). It is
therefore designed so that source content cannot turn into executable template
code.
Two guarantees matter:
1. **Values are data, never code.** Every config *value* is replaced with a
`{{ variable }}` placeholder in the template, and the original value is stored
separately in the defaults/Hiera data. When the template is later rendered,
the placeholder prints the value as a literal string; Jinja2 does not
recursively render the *contents* of a variable, so a payload sitting inside
a value (for example `motd = {{ salt['cmd.run']('id') }}`) is inert.
2. **Verbatim text is neutralised.** To preserve formatting, JinjaTurtle copies
comments, blank lines, headers and any unrecognised lines from the source
into the template. Any template metacharacters in that copied text
(`{{ }}`, `{% %}`, `{# #}` for Jinja2; `<% %>` for ERB) are escaped so they
render as the literal characters the author wrote, rather than executing.
### Consumer responsibilities
The value guarantee above relies on the downstream renderer being single-pass,
which is the normal case:
- **Ansible**: rendering a template with `template:`/`ansible.builtin.template`
is single-pass. For defence in depth, treat the generated defaults as
untrusted input — Ansible already does not re-template variable *contents* by
default. If you build your own var structures from this data and pass them
through additional templating, mark untrusted values with the `!unsafe` tag so
they are never re-evaluated.
- **Salt**: use the generated file as a `file.managed` template
(`template: jinja`). Do **not** place JinjaTurtle output where Salt would
render it a second time as part of SLS/pillar rendering, which is a separate
Jinja pass and would re-evaluate any embedded expressions.
- **Puppet/ERB**: render with the standard `template()`/`epp()` flow.
In short: render JinjaTurtle output exactly once. Do not feed it back through
another templating pass.
**IMPORTANT**: Always review both the original config files, then the resulting
templates generated by JinjaTurtle, before integrating them into your config
management system!
Enroll can call JinjaTurtle when it is available on the `PATH`. In that setup,
Enroll decides where Puppet, Ansible, or Salt files belong, while JinjaTurtle
concentrates on converting harvested config files into templates plus variable
data.
## Found a bug, have a suggestion? ## Found a bug, have a suggestion?

239
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]] [[package]]
name = "certifi" name = "certifi"
@ -6,7 +6,6 @@ version = "2026.6.17"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
{file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
@ -18,7 +17,6 @@ version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
files = [ files = [
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
@ -157,8 +155,6 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@ -166,110 +162,124 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.14.3" version = "7.14.1"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"]
files = [ files = [
{file = "coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e"}, {file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"},
{file = "coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d"}, {file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"},
{file = "coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c"}, {file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"},
{file = "coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e"}, {file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"},
{file = "coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610"}, {file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"},
{file = "coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137"}, {file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"},
{file = "coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18"}, {file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"},
{file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647"}, {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"},
{file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803"}, {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"},
{file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27"}, {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"},
{file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640"}, {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"},
{file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7"}, {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"},
{file = "coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b"}, {file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"},
{file = "coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61"}, {file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"},
{file = "coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3"}, {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"},
{file = "coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305"}, {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"},
{file = "coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87"}, {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"},
{file = "coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700"}, {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"},
{file = "coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde"}, {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"},
{file = "coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2"}, {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"},
{file = "coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb"}, {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"},
{file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a"}, {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"},
{file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab"}, {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"},
{file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7"}, {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"},
{file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9"}, {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"},
{file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc"}, {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"},
{file = "coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8"}, {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"},
{file = "coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2"}, {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"},
{file = "coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4"}, {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"},
{file = "coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24"}, {file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"},
{file = "coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665"}, {file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"},
{file = "coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a"}, {file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"},
{file = "coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727"}, {file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"},
{file = "coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977"}, {file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"},
{file = "coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c"}, {file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"},
{file = "coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf"}, {file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"},
{file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f"}, {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"},
{file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205"}, {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"},
{file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c"}, {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"},
{file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef"}, {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"},
{file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd"}, {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"},
{file = "coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35"}, {file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"},
{file = "coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d"}, {file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"},
{file = "coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336"}, {file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"},
{file = "coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c"}, {file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"},
{file = "coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a"}, {file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"},
{file = "coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027"}, {file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"},
{file = "coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73"}, {file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"},
{file = "coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9"}, {file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"},
{file = "coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de"}, {file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"},
{file = "coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd"}, {file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"},
{file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5"}, {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"},
{file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb"}, {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"},
{file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f"}, {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"},
{file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498"}, {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"},
{file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0"}, {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"},
{file = "coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37"}, {file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"},
{file = "coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994"}, {file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"},
{file = "coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150"}, {file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"},
{file = "coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc"}, {file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"},
{file = "coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7"}, {file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"},
{file = "coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce"}, {file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"},
{file = "coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5"}, {file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"},
{file = "coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a"}, {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"},
{file = "coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501"}, {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"},
{file = "coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e"}, {file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"},
{file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3"}, {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"},
{file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5"}, {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"},
{file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845"}, {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"},
{file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027"}, {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"},
{file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b"}, {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"},
{file = "coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965"}, {file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"},
{file = "coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3"}, {file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"},
{file = "coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92"}, {file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"},
{file = "coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949"}, {file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"},
{file = "coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891"}, {file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"},
{file = "coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388"}, {file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"},
{file = "coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784"}, {file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"},
{file = "coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed"}, {file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"},
{file = "coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5"}, {file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"},
{file = "coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26"}, {file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"},
{file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889"}, {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"},
{file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d"}, {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"},
{file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e"}, {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"},
{file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7"}, {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"},
{file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635"}, {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"},
{file = "coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc"}, {file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"},
{file = "coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda"}, {file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"},
{file = "coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f"}, {file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"},
{file = "coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8"}, {file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"},
{file = "coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f"}, {file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"},
{file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"},
{file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"},
{file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"},
{file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"},
{file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"},
] ]
[package.dependencies] [package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras] [package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""] toml = ["tomli"]
[[package]] [[package]]
name = "defusedxml" name = "defusedxml"
@ -277,7 +287,6 @@ version = "0.7.1"
description = "XML bomb protection for Python stdlib modules" description = "XML bomb protection for Python stdlib modules"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["main"]
files = [ files = [
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
@ -289,7 +298,6 @@ version = "5.0"
description = "A library for working with .desktop files" description = "A library for working with .desktop files"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"]
files = [ files = [
{file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"}, {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"},
{file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"}, {file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"},
@ -304,8 +312,6 @@ version = "1.3.1"
description = "Backport of PEP 654 (exception groups)" description = "Backport of PEP 654 (exception groups)"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"]
markers = "python_version == \"3.10\""
files = [ files = [
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
@ -323,7 +329,6 @@ version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
@ -338,7 +343,6 @@ version = "2.3.0"
description = "brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"]
files = [ files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
@ -350,7 +354,6 @@ version = "3.1.6"
description = "A very fast and expressive template engine." description = "A very fast and expressive template engine."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"]
files = [ files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
@ -368,7 +371,6 @@ version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup." description = "Safely add untrusted strings to HTML/XML markup."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"]
files = [ files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
@ -467,7 +469,6 @@ version = "26.2"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
@ -479,7 +480,6 @@ version = "1.6.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@ -495,7 +495,6 @@ version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python." description = "Pygments is a syntax highlighting package written in Python."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
@ -510,7 +509,6 @@ version = "4.2"
description = "Generate AppImages from your Python projects" description = "Generate AppImages from your Python projects"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"}, {file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"},
{file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"}, {file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"},
@ -527,7 +525,6 @@ version = "8.4.2"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
files = [ files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
@ -551,7 +548,6 @@ version = "5.0.0"
description = "Pytest plugin for measuring coverage." description = "Pytest plugin for measuring coverage."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["dev"]
files = [ files = [
{file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
{file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
@ -570,7 +566,6 @@ version = "6.0.3"
description = "YAML parser and emitter for Python" description = "YAML parser and emitter for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"]
files = [ files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
@ -653,7 +648,6 @@ version = "2.34.2"
description = "Python HTTP for Humans." description = "Python HTTP for Humans."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"]
files = [ files = [
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
@ -675,7 +669,6 @@ version = "2.4.1"
description = "A lil' TOML parser" description = "A lil' TOML parser"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main", "dev"]
files = [ files = [
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
@ -725,7 +718,6 @@ files = [
{file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
{file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
] ]
markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""}
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
@ -733,8 +725,6 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"]
markers = "python_version == \"3.10\""
files = [ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
@ -746,19 +736,18 @@ version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["dev"]
files = [ files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
] ]
[package.extras] [package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
h2 = ["h2 (>=4,<5)"] h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] zstd = ["backports-zstd (>=1.0.0)"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.0"
python-versions = ">=3.10,<4.0" python-versions = "^3.10"
content-hash = "161dfd9b44e1063656bacea8e8fb08a2614361a8262d8f665240e4f391732e1b" content-hash = "026c4acd254e889b70bb8c25ffb5e6323eee86380f54f2d8ef02f59ae9307529"

View file

@ -1,40 +1,36 @@
[project] [tool.poetry]
name = "jinjaturtle" name = "jinjaturtle"
version = "0.5.5" version = "0.5.5"
description = "Convert config files into Ansible defaults and Jinja2 templates." description = "Convert config files into Ansible defaults and Jinja2 templates."
authors = [ authors = ["Miguel Jacq <mig@mig5.net>"]
{ name = "Miguel Jacq", email = "mig@mig5.net" },
]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10,<4.0" packages = [{ include = "jinjaturtle", from = "src" }]
keywords = ["ansible", "jinja2", "config", "toml", "ini", "yaml", "json", "devops"]
dependencies = [ keywords = ["ansible", "jinja2", "config", "toml", "ini", "yaml", "json", "devops"]
"PyYAML (>=6.0,<7.0)",
"defusedxml (>=0.7.1,<0.8.0)",
"jinja2 (>=3.1.6,<4.0.0)",
"tomli (>=2.0.0,<3.0.0) ; python_version < '3.11'",
]
[project.urls]
homepage = "https://git.mig5.net/mig5/jinjaturtle" homepage = "https://git.mig5.net/mig5/jinjaturtle"
repository = "https://git.mig5.net/mig5/jinjaturtle" repository = "https://git.mig5.net/mig5/jinjaturtle"
[project.scripts] [tool.poetry.dependencies]
python = "^3.10"
PyYAML = "^6.0"
tomli = { version = "^2.0.0", python = "<3.11" }
defusedxml = "^0.7.1"
jinja2 = "^3.1.6"
[tool.poetry.scripts]
jinjaturtle = "jinjaturtle.cli:main" jinjaturtle = "jinjaturtle.cli:main"
[tool.poetry]
packages = [{ include = "jinjaturtle", from = "src" }]
[tool.poetry.group.dev.dependencies]
pytest = "^8"
pytest-cov = "^5"
pyproject-appimage = "^4.2"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.pyproject-appimage] [tool.pyproject-appimage]
script = "jinjaturtle" script = "jinjaturtle"
output = "JinjaTurtle.AppImage" output = "JinjaTurtle.AppImage"
[tool.poetry.dev-dependencies]
pytest = "^8"
pytest-cov = "^5"
pyproject-appimage = "^4.2"

View file

@ -199,8 +199,4 @@ def main() -> None:
""" """
Console-script entry point. Console-script entry point.
""" """
sys.exit(_main(sys.argv[1:])) _main(sys.argv[1:])
if __name__ == "__main__":
main()

View file

@ -412,6 +412,8 @@ def generate_puppet_hiera_yaml(
``role_prefix`` remains the source variable prefix used by JinjaTurtle while ``role_prefix`` remains the source variable prefix used by JinjaTurtle while
``puppet_class`` is the Puppet class/Hiera namespace. In the normal case ``puppet_class`` is the Puppet class/Hiera namespace. In the normal case
they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``. they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``.
Enroll may pass a file-specific role prefix and a separate Puppet class to
avoid parameter-name collisions inside one generated Puppet module.
""" """
klass = puppet_class_name(puppet_class or role_prefix) klass = puppet_class_name(puppet_class or role_prefix)

View file

@ -2,8 +2,6 @@ from __future__ import annotations
import re import re
from .escape import escape_erb_literal
def _safe_name(raw: str, *, fallback: str = "var") -> str: def _safe_name(raw: str, *, fallback: str = "var") -> str:
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower() text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower()
@ -40,9 +38,10 @@ def puppet_local_var_name(
generated Jinja variable such as ``php_memory_limit`` becomes Puppet local generated Jinja variable such as ``php_memory_limit`` becomes Puppet local
parameter ``memory_limit`` and Hiera key ``php::memory_limit``. parameter ``memory_limit`` and Hiera key ``php::memory_limit``.
When ``puppet_class`` differs from ``role_prefix`` we keep the full Enroll sometimes needs a file-specific variable prefix to avoid collisions
generated variable name as the local parameter and only use ``puppet_class`` inside a generated module. When ``puppet_class`` differs from
as the Hiera namespace. ``role_prefix`` we keep the full generated variable name as the local
parameter and only use ``puppet_class`` as the Hiera namespace.
""" """
var_name = _safe_name(jinja_var_name, fallback="value") var_name = _safe_name(jinja_var_name, fallback="value")
@ -72,33 +71,7 @@ class ErbTranslator:
self.loop_stack: list[tuple[str, str, str]] = [] self.loop_stack: list[tuple[str, str, str]] = []
self.needs_json = False self.needs_json = False
# Matches a JinjaTurtle ``{% raw %} ... {% endraw %}`` block (non-greedy).
# JinjaTurtle emits raw blocks only to carry verbatim, security-escaped
# source text (comments and unrecognised lines), so the *contents* must be
# treated as literal output, never translated as Jinja tokens.
_RAW_BLOCK_RE = re.compile(r"{%\s*raw\s*%}(.*?){%\s*endraw\s*%}", re.S)
def translate(self, template_text: str) -> str: def translate(self, template_text: str) -> str:
# Split out raw blocks first. Their inner text is literal and must be
# carried through as literal ERB (with ERB delimiters re-escaped), rather
# than tokenised -- otherwise an escaped Jinja payload inside a comment
# would be "re-animated" into live ERB during translation.
segments = self._RAW_BLOCK_RE.split(template_text)
out: list[str] = []
# re.split with one capture group yields: [text, raw_inner, text, ...].
for idx, segment in enumerate(segments):
if idx % 2 == 1:
# Captured raw-block contents: emit as literal ERB text.
out.append(escape_erb_literal(segment))
else:
out.append(self._translate_tokens(segment))
rendered = "".join(out)
if self.needs_json and "require 'json'" not in rendered:
rendered = "<% require 'json' -%>\n" + rendered
return rendered
def _translate_tokens(self, template_text: str) -> str:
parts = self._TOKEN_RE.split(template_text) parts = self._TOKEN_RE.split(template_text)
out: list[str] = [] out: list[str] = []
for token in parts: for token in parts:
@ -113,7 +86,11 @@ class ErbTranslator:
out.append(self.statement_to_erb(stmt)) out.append(self.statement_to_erb(stmt))
continue continue
out.append(token) out.append(token)
return "".join(out)
rendered = "".join(out)
if self.needs_json and "require 'json'" not in rendered:
rendered = "<% require 'json' -%>\n" + rendered
return rendered
def local_var(self, name: str) -> str: def local_var(self, name: str) -> str:
return puppet_local_var_name( return puppet_local_var_name(

View file

@ -1,148 +0,0 @@
from __future__ import annotations
"""Neutralise template metacharacters in text copied verbatim from source files.
JinjaTurtle preserves formatting by copying parts of the *original* config file
straight into the generated template: comments, blank lines, section headers,
and any line it does not recognise as ``key = value``. Config *values* are
always replaced with ``{{ var }}`` placeholders and parked in the defaults data,
so a payload inside a value is inert. Verbatim text is different: if the source
contains ``{{ ... }}``, ``{% ... %}`` or ``{# ... #}`` (Jinja2), or ``<%= %>`` /
``<% %>`` (ERB), that text becomes *live template code* in the output and is
executed when Salt/Ansible/Puppet later renders the template.
Because JinjaTurtle is frequently fed harvested, attacker-influenceable config
(hostnames, banners, GECOS-derived comments, "Managed by" notes), this is a
template-injection / SSTI vector that can lead to remote code execution on the
configuration-management control node.
The functions here render those metacharacters as literal text so they survive a
later template render as the characters the source author actually wrote, rather
than as executable template syntax.
Design notes:
* We only ever escape text that originates from the *source file*. We never
pass JinjaTurtle's own generated placeholders (``{{ role_var }}``) through
these helpers, so the placeholders keep working.
* Jinja2 literal text is wrapped in a single ``{% raw %} ... {% endraw %}``
block. ``raw`` disables *all* tag interpretation inside it -- expressions,
statements and ``{# #}`` comments alike -- so one wrap neutralises every
Jinja construct. The only way to break out of a raw block is a literal
``{% endraw %}`` in the source, so we defang the token ``endraw`` (in any
internal spacing) before wrapping.
* The ERB translator (``erb.py``) is raw-aware: it copies the *contents* of a
JinjaTurtle raw block through as literal ERB text and re-escapes any ERB
delimiters found there. That keeps a Jinja-escaped comment inert after the
Jinja2 -> ERB translation step, instead of the payload being "re-animated"
as ERB.
* ``escape_erb_literal`` exists for completeness / direct ERB emission: it
rewrites each ERB delimiter into an ERB expression that prints the delimiter
characters literally.
"""
import re
# Jinja2 delimiters we must neutralise. Engine-default; JinjaTurtle never
# configures custom delimiters.
_JINJA_MARKERS = ("{{", "}}", "{%", "%}", "{#", "#}")
# ERB delimiters. Longer markers first so "<%=" matches before "<%".
_ERB_OPEN_MARKERS = ("<%=", "<%-", "<%#", "<%")
_ERB_CLOSE_MARKERS = ("-%>", "%>")
# Matches a Jinja2 endraw tag in any internal spacing, e.g. "{%endraw%}",
# "{% endraw %}", "{%- endraw -%}".
_ENDRAW_RE = re.compile(r"{%-?\s*endraw\s*-?%}")
# Sentinel inserted between "end" and "raw" to break the endraw keyword without
# changing the visible characters. We use a Jinja comment-free approach: insert
# the two halves across a raw boundary so the literal text still reads "endraw"
# to a human but is never a valid tag. See escape_jinja_literal for usage.
def contains_jinja_markup(text: str) -> bool:
"""Return True if *text* contains any Jinja2 delimiter."""
return any(m in text for m in _JINJA_MARKERS)
def contains_erb_markup(text: str) -> bool:
"""Return True if *text* contains any ERB delimiter."""
return any(m in text for m in (*_ERB_OPEN_MARKERS, *_ERB_CLOSE_MARKERS))
def _defang_endraw(text: str) -> str:
"""Rewrite any literal ``{% endraw %}`` so it cannot close our raw wrapper.
We turn each endraw tag into ``{% endraw %}{{ '{% endraw %}' }}{% raw %}``...
no -- that would re-introduce live tags. Instead we keep everything literal:
we break the keyword by emitting the tag's text in two raw segments split
inside the word ``endraw``. The result, when later rendered, reproduces the
exact original characters ``{% endraw %}`` while never being a parseable tag.
"""
def _replace(match: re.Match[str]) -> str:
tag = match.group(0)
# Split the keyword "endraw" as "end" + "raw"; close and reopen the raw
# block between them. Each half is plain text inside a raw block, so the
# reconstructed output is byte-identical to the original tag, but at no
# point does the token "{% endraw %}" exist contiguously to close raw.
idx = tag.lower().index("endraw")
head = tag[: idx + 3] # up to and including "end"
tail = tag[idx + 3 :] # "raw...%}"
return f"{head}{{% endraw %}}{{% raw %}}{tail}"
return _ENDRAW_RE.sub(_replace, text)
def escape_jinja_literal(text: str) -> str:
"""Make *text* render as literal characters under a later Jinja2 render.
Text with no Jinja metacharacters is returned unchanged so the common case
stays byte-for-byte identical to the source. Otherwise the text is wrapped
in a single ``{% raw %}`` block, with any embedded ``endraw`` defanged.
"""
if not text or not contains_jinja_markup(text):
return text
return "{% raw %}" + _defang_endraw(text) + "{% endraw %}"
def escape_erb_literal(text: str) -> str:
"""Make *text* render as literal characters under a later ERB render.
ERB has no ``raw`` block, so each opening/closing delimiter is rewritten as
an ERB expression that prints the delimiter literally. Text with no ERB
metacharacters is returned unchanged.
"""
if not text or not contains_erb_markup(text):
return text
result: list[str] = []
i = 0
n = len(text)
while i < n:
matched = None
for marker in (*_ERB_CLOSE_MARKERS, *_ERB_OPEN_MARKERS):
if text.startswith(marker, i):
matched = marker
break
if matched is not None:
escaped = matched.replace("\\", "\\\\").replace('"', '\\"')
result.append('<%= "' + escaped + '" %>')
i += len(matched)
else:
result.append(text[i])
i += 1
return "".join(result)
def escape_literal(text: str, *, engine: str = "jinja2") -> str:
"""Escape verbatim *text* for the target template *engine*.
``engine`` is ``"jinja2"`` (default) or ``"erb"``. Unknown engines fall back
to Jinja2 escaping. In JinjaTurtle's pipeline ERB output is produced by
translating Jinja2 output, and the translator is raw-aware, so handlers can
always Jinja-escape and rely on the translator to keep the literal inert.
"""
if engine == "erb":
return escape_erb_literal(text)
return escape_jinja_literal(text)

View file

@ -6,7 +6,6 @@ from typing import Any
from . import BaseHandler from . import BaseHandler
from .. import j2 from .. import j2
from ..escape import escape_jinja_literal
class IniHandler(BaseHandler): class IniHandler(BaseHandler):
@ -85,18 +84,16 @@ class IniHandler(BaseHandler):
line = raw_line line = raw_line
stripped = line.lstrip() stripped = line.lstrip()
# Blank or pure comment: keep formatting, but neutralise any # Blank or pure comment: keep as-is
# template metacharacters so attacker-controlled comment text cannot
# become live template code in the output.
if not stripped or stripped[0] in {"#", ";"}: if not stripped or stripped[0] in {"#", ";"}:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Section header # Section header
if stripped.startswith("[") and "]" in stripped: if stripped.startswith("[") and "]" in stripped:
header_inner = stripped[1 : stripped.index("]")] header_inner = stripped[1 : stripped.index("]")]
current_section = header_inner.strip() current_section = header_inner.strip()
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Work without newline so we can re-attach it exactly # Work without newline so we can re-attach it exactly
@ -111,9 +108,8 @@ class IniHandler(BaseHandler):
eq_index = content.find("=") eq_index = content.find("=")
if eq_index == -1: if eq_index == -1:
# Not a simple key=value line: leave content intact but escape # Not a simple key=value line: leave untouched
# any template metacharacters. out_lines.append(raw_line)
out_lines.append(escape_jinja_literal(raw_line))
continue continue
before_eq = content[:eq_index] before_eq = content[:eq_index]
@ -121,7 +117,7 @@ class IniHandler(BaseHandler):
key = before_eq.strip() key = before_eq.strip()
if not key: if not key:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Whitespace after '=' # Whitespace after '='
@ -150,16 +146,8 @@ class IniHandler(BaseHandler):
else: else:
replacement_value = j2.variable(var_name) replacement_value = j2.variable(var_name)
# ``before_eq`` (key + surrounding whitespace) and ``comment_part``
# both originate from the source file and may carry template
# metacharacters; escape each independently so the safe
# ``replacement_value`` placeholder between them is preserved.
new_content = ( new_content = (
escape_jinja_literal(before_eq) before_eq + "=" + leading_ws + replacement_value + comment_part
+ "="
+ leading_ws
+ replacement_value
+ escape_jinja_literal(comment_part)
) )
out_lines.append(new_content + newline) out_lines.append(new_content + newline)

View file

@ -5,7 +5,6 @@ from typing import Any
from . import BaseHandler from . import BaseHandler
from .. import j2 from .. import j2
from ..escape import escape_jinja_literal
class PostfixMainHandler(BaseHandler): class PostfixMainHandler(BaseHandler):
@ -109,16 +108,16 @@ class PostfixMainHandler(BaseHandler):
stripped = content.strip() stripped = content.strip()
if not stripped: if not stripped:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
i += 1 i += 1
continue continue
if stripped.startswith("#"): if stripped.startswith("#"):
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
i += 1 i += 1
continue continue
if "=" not in content: if "=" not in content:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
i += 1 i += 1
continue continue
@ -128,7 +127,7 @@ class PostfixMainHandler(BaseHandler):
key = before_eq.strip() key = before_eq.strip()
if not key: if not key:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
i += 1 i += 1
continue continue
@ -163,21 +162,16 @@ class PostfixMainHandler(BaseHandler):
var = self.make_var_name(role_prefix, (key,)) var = self.make_var_name(role_prefix, (key,))
v = value v = value
# ``before_eq`` (key) and ``comment_part`` are source-derived and may
# contain template metacharacters; escape each around the safe
# placeholder.
safe_before = escape_jinja_literal(before_eq)
safe_comment = escape_jinja_literal(comment_part)
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if quoted: if quoted:
replacement = ( replacement = (
f"{safe_before}={leading_ws}{j2.quoted_variable(var)}" f"{before_eq}={leading_ws}{j2.quoted_variable(var)}"
f"{safe_comment}{newline}" f"{comment_part}{newline}"
) )
else: else:
replacement = ( replacement = (
f"{safe_before}={leading_ws}{j2.variable(var)}" f"{before_eq}={leading_ws}{j2.variable(var)}"
f"{safe_comment}{newline}" f"{comment_part}{newline}"
) )
out_lines.append(replacement) out_lines.append(replacement)

View file

@ -7,7 +7,6 @@ from typing import Any
from . import BaseHandler from . import BaseHandler
from .. import j2 from .. import j2
from ..escape import escape_jinja_literal
_SECTION_KEYWORDS = {"host", "match"} _SECTION_KEYWORDS = {"host", "match"}
@ -256,12 +255,12 @@ class SshConfigHandler(BaseHandler):
out_lines: list[str] = [] out_lines: list[str] = []
for ln in parsed.lines: for ln in parsed.lines:
if ln.kind != "kv": if ln.kind != "kv":
out_lines.append(escape_jinja_literal(ln.raw)) out_lines.append(ln.raw)
continue continue
path = self._path_for_line(ln) path = self._path_for_line(ln)
if not path: if not path:
out_lines.append(escape_jinja_literal(ln.raw)) out_lines.append(ln.raw)
continue continue
var = self.make_var_name(role_prefix, path) var = self.make_var_name(role_prefix, path)
@ -271,12 +270,9 @@ class SshConfigHandler(BaseHandler):
else: else:
replacement_value = j2.variable(var) replacement_value = j2.variable(var)
# ``before_value`` (keyword + spacing) and ``comment`` are
# source-derived; escape around the safe placeholder.
rendered = ( rendered = (
f"{escape_jinja_literal(ln.before_value)}{replacement_value}" f"{ln.before_value}{replacement_value}"
f"{ln.whitespace_before_comment}" f"{ln.whitespace_before_comment}{ln.comment}{ln.newline}"
f"{escape_jinja_literal(ln.comment)}{ln.newline}"
) )
out_lines.append(rendered) out_lines.append(rendered)

View file

@ -6,7 +6,6 @@ from typing import Any
from . import BaseHandler from . import BaseHandler
from .. import j2 from .. import j2
from ..escape import escape_jinja_literal
@dataclass @dataclass
@ -158,13 +157,7 @@ class SystemdUnitHandler(BaseHandler):
out_lines: list[str] = [] out_lines: list[str] = []
for ln in parsed.lines: for ln in parsed.lines:
if ln.kind != "kv" or not ln.section or not ln.key: if ln.kind != "kv" or not ln.section or not ln.key:
# Verbatim lines (blank/comment/section/unrecognised "raw") out_lines.append(ln.raw)
# originate from the source file. Escape template
# metacharacters so they cannot become live template code.
# This is the only handler that emits unrecognised lines, which
# is where Jinja *statement* injection (``{% ... %}``) was
# possible, so escaping here is essential.
out_lines.append(escape_jinja_literal(ln.raw))
continue continue
path: tuple[str, ...] = (ln.section, ln.key) path: tuple[str, ...] = (ln.section, ln.key)
@ -173,18 +166,16 @@ class SystemdUnitHandler(BaseHandler):
var = self.make_var_name(role_prefix, path) var = self.make_var_name(role_prefix, path)
v = (ln.value or "").strip() v = (ln.value or "").strip()
safe_before = escape_jinja_literal(ln.before_eq)
safe_comment = escape_jinja_literal(ln.comment)
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"} quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if quoted: if quoted:
repl = ( repl = (
f"{safe_before}={ln.leading_ws_after_eq}" f"{ln.before_eq}={ln.leading_ws_after_eq}"
f"{j2.quoted_variable(var)}{safe_comment}" f"{j2.quoted_variable(var)}{ln.comment}"
) )
else: else:
repl = ( repl = (
f"{safe_before}={ln.leading_ws_after_eq}" f"{ln.before_eq}={ln.leading_ws_after_eq}"
f"{j2.variable(var)}{safe_comment}" f"{j2.variable(var)}{ln.comment}"
) )
newline = "\n" if ln.raw.endswith("\n") else "" newline = "\n" if ln.raw.endswith("\n") else ""

View file

@ -5,7 +5,6 @@ from typing import Any
from . import DictLikeHandler from . import DictLikeHandler
from .. import j2 from .. import j2
from ..escape import escape_jinja_literal
from ..loop_analyzer import LoopCandidate from ..loop_analyzer import LoopCandidate
try: try:
@ -78,25 +77,19 @@ class TomlHandler(DictLikeHandler):
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
var_name = self.make_var_name(role_prefix, path + (key,)) var_name = self.make_var_name(role_prefix, path + (key,))
if isinstance(value, str): if isinstance(value, str):
lines.append( lines.append(f"{key} = {self._toml_quoted_expr(var_name)}")
f"{escape_jinja_literal(str(key))} = {self._toml_quoted_expr(var_name)}"
)
elif isinstance(value, bool): elif isinstance(value, bool):
# Booleans need | lower filter (Python True/False → TOML true/false) # Booleans need | lower filter (Python True/False → TOML true/false)
lines.append( lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
)
else: else:
lines.append( lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
)
def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None: def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None:
scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)}
nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)}
if path: if path:
header = ".".join(escape_jinja_literal(str(p)) for p in path) header = ".".join(path)
lines.append(f"[{header}]") lines.append(f"[{header}]")
for key, val in scalar_items.items(): for key, val in scalar_items.items():
@ -137,14 +130,10 @@ class TomlHandler(DictLikeHandler):
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
var_name = self.make_var_name(role_prefix, path + (key,)) var_name = self.make_var_name(role_prefix, path + (key,))
if isinstance(value, str): if isinstance(value, str):
lines.append( lines.append(f"{key} = {self._toml_quoted_expr(var_name)}")
f"{escape_jinja_literal(str(key))} = {self._toml_quoted_expr(var_name)}"
)
elif isinstance(value, bool): elif isinstance(value, bool):
# Booleans need | lower filter (Python True/False → TOML true/false) # Booleans need | lower filter (Python True/False → TOML true/false)
lines.append( lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
)
elif isinstance(value, list): elif isinstance(value, list):
# Check if this list is a loop candidate # Check if this list is a loop candidate
if path + (key,) in loop_paths: if path + (key,) in loop_paths:
@ -168,26 +157,19 @@ class TomlHandler(DictLikeHandler):
elif candidate.item_schema in ("simple_dict", "nested"): elif candidate.item_schema in ("simple_dict", "nested"):
# Dict list loop - TOML array of tables # Dict list loop - TOML array of tables
# This is complex for TOML, using simplified approach # This is complex for TOML, using simplified approach
lines.append( lines.append(f"{key} = " f"{j2.to_json(var_name)}")
f"{escape_jinja_literal(str(key))} = "
f"{j2.to_json(var_name)}"
)
else: else:
# Not a loop, treat as regular variable # Not a loop, treat as regular variable
lines.append( lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
)
else: else:
lines.append( lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
)
def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None: def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None:
scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)}
nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)}
if path: if path:
header = ".".join(escape_jinja_literal(str(p)) for p in path) header = ".".join(path)
lines.append(f"[{header}]") lines.append(f"[{header}]")
for key, val in scalar_items.items(): for key, val in scalar_items.items():
@ -235,7 +217,7 @@ class TomlHandler(DictLikeHandler):
# Blank or pure comment # Blank or pure comment
if not stripped or stripped.startswith("#"): if not stripped or stripped.startswith("#"):
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Table header: [server] or [server.tls] or [[array.of.tables]] # Table header: [server] or [server.tls] or [[array.of.tables]]
@ -248,7 +230,7 @@ class TomlHandler(DictLikeHandler):
inner = inner.strip("[]") # handle [[table]] as well inner = inner.strip("[]") # handle [[table]] as well
parts = [p.strip() for p in inner.split(".") if p.strip()] parts = [p.strip() for p in inner.split(".") if p.strip()]
current_table = tuple(parts) current_table = tuple(parts)
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Try key = value # Try key = value
@ -263,7 +245,7 @@ class TomlHandler(DictLikeHandler):
eq_index = content.find("=") eq_index = content.find("=")
if eq_index == -1: if eq_index == -1:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
before_eq = content[:eq_index] before_eq = content[:eq_index]
@ -271,7 +253,7 @@ class TomlHandler(DictLikeHandler):
key = before_eq.strip() key = before_eq.strip()
if not key: if not key:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Whitespace after '=' # Whitespace after '='
@ -307,23 +289,19 @@ class TomlHandler(DictLikeHandler):
nested_var = self.make_var_name(role_prefix, nested_path) nested_var = self.make_var_name(role_prefix, nested_path)
if isinstance(sub_val, str): if isinstance(sub_val, str):
inner_bits.append( inner_bits.append(
f"{escape_jinja_literal(str(sub_key))} = {self._toml_quoted_expr(nested_var)}" f"{sub_key} = {self._toml_quoted_expr(nested_var)}"
) )
elif isinstance(sub_val, bool): elif isinstance(sub_val, bool):
inner_bits.append( inner_bits.append(
f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}" f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
) )
else: else:
inner_bits.append( inner_bits.append(
f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}" f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
) )
replacement_value = "{ " + ", ".join(inner_bits) + " }" replacement_value = "{ " + ", ".join(inner_bits) + " }"
new_content = ( new_content = (
escape_jinja_literal(before_eq) before_eq + "=" + leading_ws + replacement_value + comment_part
+ "="
+ leading_ws
+ replacement_value
+ escape_jinja_literal(comment_part)
) )
out_lines.append(new_content + newline) out_lines.append(new_content + newline)
continue continue
@ -349,11 +327,7 @@ class TomlHandler(DictLikeHandler):
replacement_value = j2.variable(var_name) replacement_value = j2.variable(var_name)
new_content = ( new_content = (
escape_jinja_literal(before_eq) before_eq + "=" + leading_ws + replacement_value + comment_part
+ "="
+ leading_ws
+ replacement_value
+ escape_jinja_literal(comment_part)
) )
out_lines.append(new_content + newline) out_lines.append(new_content + newline)
@ -381,7 +355,7 @@ class TomlHandler(DictLikeHandler):
if not stripped or stripped.startswith("#"): if not stripped or stripped.startswith("#"):
# Only output if we're not skipping # Only output if we're not skipping
if not skip_until_next_table: if not skip_until_next_table:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Table header: [server] or [server.tls] or [[array.of.tables]] # Table header: [server] or [server.tls] or [[array.of.tables]]
@ -430,9 +404,7 @@ class TomlHandler(DictLikeHandler):
out_lines.append( out_lines.append(
f"{j2.for_start(item_var, collection_var)}\n" f"{j2.for_start(item_var, collection_var)}\n"
) )
out_lines.append( out_lines.append(f"[[{'.'.join(table_path)}]]\n")
f"[[{'.'.join(escape_jinja_literal(str(p)) for p in table_path)}]]\n"
)
# Add fields from sample item # Add fields from sample item
for key, value in sample_item.items(): for key, value in sample_item.items():
@ -440,17 +412,17 @@ class TomlHandler(DictLikeHandler):
continue continue
if isinstance(value, str): if isinstance(value, str):
out_lines.append( out_lines.append(
f"{escape_jinja_literal(str(key))} = " f"{key} = "
f"{self._toml_quoted_expr(f'{item_var}.{key}')}\n" f"{self._toml_quoted_expr(f'{item_var}.{key}')}\n"
) )
elif isinstance(value, bool): elif isinstance(value, bool):
out_lines.append( out_lines.append(
f"{escape_jinja_literal(str(key))} = " f"{key} = "
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n" f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
) )
else: else:
out_lines.append( out_lines.append(
f"{escape_jinja_literal(str(key))} = " f"{key} = "
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n" f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
) )
@ -465,7 +437,7 @@ class TomlHandler(DictLikeHandler):
skip_until_next_table = False skip_until_next_table = False
current_table = table_path current_table = table_path
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# If we're inside a skipped array-of-tables section, skip this line # If we're inside a skipped array-of-tables section, skip this line
@ -484,7 +456,7 @@ class TomlHandler(DictLikeHandler):
eq_index = content.find("=") eq_index = content.find("=")
if eq_index == -1: if eq_index == -1:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
before_eq = content[:eq_index] before_eq = content[:eq_index]
@ -492,7 +464,7 @@ class TomlHandler(DictLikeHandler):
key = before_eq.strip() key = before_eq.strip()
if not key: if not key:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Whitespace after '=' # Whitespace after '='
@ -529,11 +501,7 @@ class TomlHandler(DictLikeHandler):
replacement_value = j2.to_json(collection_var) replacement_value = j2.to_json(collection_var)
new_content = ( new_content = (
escape_jinja_literal(before_eq) before_eq + "=" + leading_ws + replacement_value + comment_part
+ "="
+ leading_ws
+ replacement_value
+ escape_jinja_literal(comment_part)
) )
out_lines.append(new_content + newline) out_lines.append(new_content + newline)
continue continue
@ -558,23 +526,19 @@ class TomlHandler(DictLikeHandler):
nested_var = self.make_var_name(role_prefix, nested_path) nested_var = self.make_var_name(role_prefix, nested_path)
if isinstance(sub_val, str): if isinstance(sub_val, str):
inner_bits.append( inner_bits.append(
f"{escape_jinja_literal(str(sub_key))} = {self._toml_quoted_expr(nested_var)}" f"{sub_key} = {self._toml_quoted_expr(nested_var)}"
) )
elif isinstance(sub_val, bool): elif isinstance(sub_val, bool):
inner_bits.append( inner_bits.append(
f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}" f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
) )
else: else:
inner_bits.append( inner_bits.append(
f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}" f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
) )
replacement_value = "{ " + ", ".join(inner_bits) + " }" replacement_value = "{ " + ", ".join(inner_bits) + " }"
new_content = ( new_content = (
escape_jinja_literal(before_eq) before_eq + "=" + leading_ws + replacement_value + comment_part
+ "="
+ leading_ws
+ replacement_value
+ escape_jinja_literal(comment_part)
) )
out_lines.append(new_content + newline) out_lines.append(new_content + newline)
continue continue
@ -600,11 +564,7 @@ class TomlHandler(DictLikeHandler):
replacement_value = j2.variable(var_name) replacement_value = j2.variable(var_name)
new_content = ( new_content = (
escape_jinja_literal(before_eq) before_eq + "=" + leading_ws + replacement_value + comment_part
+ "="
+ leading_ws
+ replacement_value
+ escape_jinja_literal(comment_part)
) )
out_lines.append(new_content + newline) out_lines.append(new_content + newline)

View file

@ -7,7 +7,6 @@ import xml.etree.ElementTree as ET # nosec
from .base import BaseHandler from .base import BaseHandler
from .. import j2 from .. import j2
from ..escape import escape_jinja_literal
from ..loop_analyzer import LoopCandidate from ..loop_analyzer import LoopCandidate
@ -231,37 +230,6 @@ class XmlHandler(BaseHandler):
walk(root, ()) walk(root, ())
# Internal marker prefixes used by JinjaTurtle's own comment nodes. These
# must NOT be escaped (they are converted into real Jinja control structures
# downstream). Source-file comments have none of these prefixes.
_MARKER_PREFIXES = ("LOOP:", "IF:", "ENDIF:")
def _is_jt_marker(self, comment_text: str) -> bool:
stripped = (comment_text or "").lstrip()
return any(stripped.startswith(p) for p in self._MARKER_PREFIXES)
def _escape_source_comments(self, root: ET.Element) -> None:
"""Escape template metacharacters in comments preserved from the source.
XML comments are re-emitted verbatim by ``ET.tostring`` (the tree is
parsed with ``insert_comments=True``). Attacker-controlled comment text
such as ``<!-- {{ cmd.run('id') }} -->`` would otherwise become live
template code. Element/attribute *names* cannot carry Jinja delimiters
(XML naming rules forbid the characters and the parser rejects them), and
text/attribute *values* are already replaced with ``{{ var }}``
placeholders, so comments (and the prolog, handled separately) are the
only XML injection vector.
JinjaTurtle's own internal marker comments are left untouched so they can
be converted into real loops/conditionals later.
"""
# ET represents comments with a callable tag (ET.Comment). Iterate all
# descendants and escape comment text that is not one of our markers.
for elem in root.iter():
if elem.tag is ET.Comment:
if not self._is_jt_marker(elem.text or ""):
elem.text = escape_jinja_literal(elem.text or "")
def _generate_xml_template_from_text(self, role_prefix: str, text: str) -> str: def _generate_xml_template_from_text(self, role_prefix: str, text: str) -> str:
"""Generate scalar-only Jinja2 template.""" """Generate scalar-only Jinja2 template."""
prolog, body = self._split_xml_prolog(text) prolog, body = self._split_xml_prolog(text)
@ -272,16 +240,12 @@ class XmlHandler(BaseHandler):
self._apply_jinja_to_xml_tree(role_prefix, root) self._apply_jinja_to_xml_tree(role_prefix, root)
# Neutralise template metacharacters in any comments preserved from the
# source file before serialising.
self._escape_source_comments(root)
indent = getattr(ET, "indent", None) indent = getattr(ET, "indent", None)
if indent is not None: if indent is not None:
indent(root, space=" ") # type: ignore[arg-type] indent(root, space=" ") # type: ignore[arg-type]
xml_body = ET.tostring(root, encoding="unicode") xml_body = ET.tostring(root, encoding="unicode")
return escape_jinja_literal(prolog) + xml_body return prolog + xml_body
def _generate_xml_template_with_loops_from_text( def _generate_xml_template_with_loops_from_text(
self, self,
@ -301,11 +265,6 @@ class XmlHandler(BaseHandler):
# Apply Jinja transformations (including loop markers) # Apply Jinja transformations (including loop markers)
self._apply_jinja_to_xml_tree(role_prefix, root, loop_candidates) self._apply_jinja_to_xml_tree(role_prefix, root, loop_candidates)
# Escape comments preserved from the source. JinjaTurtle's own
# LOOP/IF/ENDIF marker comments are recognised and left intact so they
# can be converted into real Jinja control structures below.
self._escape_source_comments(root)
# Convert to string # Convert to string
indent = getattr(ET, "indent", None) indent = getattr(ET, "indent", None)
if indent is not None: if indent is not None:
@ -316,7 +275,7 @@ class XmlHandler(BaseHandler):
# Post-process to replace loop markers with actual Jinja loops # Post-process to replace loop markers with actual Jinja loops
xml_body = self._insert_xml_loops(xml_body, role_prefix, loop_candidates, root) xml_body = self._insert_xml_loops(xml_body, role_prefix, loop_candidates, root)
return escape_jinja_literal(prolog) + xml_body return prolog + xml_body
def _insert_xml_loops( def _insert_xml_loops(
self, self,

View file

@ -6,7 +6,6 @@ from typing import Any
from .dict import DictLikeHandler from .dict import DictLikeHandler
from .. import j2 from .. import j2
from ..escape import escape_jinja_literal
from ..loop_analyzer import LoopCandidate from ..loop_analyzer import LoopCandidate
@ -103,7 +102,7 @@ class YamlHandler(DictLikeHandler):
indent = len(raw_line) - len(stripped) indent = len(raw_line) - len(stripped)
if not stripped or stripped.startswith("#"): if not stripped or stripped.startswith("#"):
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
while stack and indent < stack[-1][0]: while stack and indent < stack[-1][0]:
@ -113,7 +112,7 @@ class YamlHandler(DictLikeHandler):
key_part, rest = stripped.split(":", 1) key_part, rest = stripped.split(":", 1)
key = key_part.strip() key = key_part.strip()
if not key: if not key:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
rest_stripped = rest.lstrip(" \t") rest_stripped = rest.lstrip(" \t")
@ -126,7 +125,7 @@ class YamlHandler(DictLikeHandler):
stack.append((indent, path, "map")) stack.append((indent, path, "map"))
if not has_value: if not has_value:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
value_part, comment_part = self._split_inline_comment( value_part, comment_part = self._split_inline_comment(
@ -148,8 +147,8 @@ class YamlHandler(DictLikeHandler):
replacement = self._yaml_scalar_expr(var_name, raw_value) replacement = self._yaml_scalar_expr(var_name, raw_value)
leading = rest[: len(rest) - len(rest.lstrip(" \t"))] leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}" new_rest = f"{leading}{replacement}{comment_part}"
new_stripped = f"{escape_jinja_literal(key)}:{new_rest}" new_stripped = f"{key}:{new_rest}"
out_lines.append( out_lines.append(
" " * indent " " * indent
+ new_stripped + new_stripped
@ -186,7 +185,7 @@ class YamlHandler(DictLikeHandler):
else: else:
replacement = self._yaml_scalar_expr(var_name, raw_value) replacement = self._yaml_scalar_expr(var_name, raw_value)
new_stripped = f"- {replacement}{escape_jinja_literal(comment_part)}" new_stripped = f"- {replacement}{comment_part}"
out_lines.append( out_lines.append(
" " * indent " " * indent
+ new_stripped + new_stripped
@ -194,7 +193,7 @@ class YamlHandler(DictLikeHandler):
) )
continue continue
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
return "".join(out_lines) return "".join(out_lines)
@ -276,7 +275,7 @@ class YamlHandler(DictLikeHandler):
next_line = next_significant_line(line_index) next_line = next_significant_line(line_index)
if next_line is None: if next_line is None:
skip_until_indent = None skip_until_indent = None
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
else: else:
next_indent, next_stripped = next_line next_indent, next_stripped = next_line
still_in_collection = next_indent > skip_until_indent or ( still_in_collection = next_indent > skip_until_indent or (
@ -285,12 +284,12 @@ class YamlHandler(DictLikeHandler):
) )
if not still_in_collection: if not still_in_collection:
skip_until_indent = None skip_until_indent = None
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
if is_comment: if is_comment:
if indent <= skip_until_indent: if indent <= skip_until_indent:
skip_until_indent = None skip_until_indent = None
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
# Comments/blank lines indented beneath the replaced # Comments/blank lines indented beneath the replaced
# collection are considered part of that collection and # collection are considered part of that collection and
# cannot be placed safely inside a generated loop. # cannot be placed safely inside a generated loop.
@ -304,7 +303,7 @@ class YamlHandler(DictLikeHandler):
# Blank or comment lines # Blank or comment lines
if is_blank or is_comment: if is_blank or is_comment:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Adjust stack based on indent # Adjust stack based on indent
@ -316,7 +315,7 @@ class YamlHandler(DictLikeHandler):
key_part, rest = stripped.split(":", 1) key_part, rest = stripped.split(":", 1)
key = key_part.strip() key = key_part.strip()
if not key: if not key:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
rest_stripped = rest.lstrip(" \t") rest_stripped = rest.lstrip(" \t")
@ -354,7 +353,7 @@ class YamlHandler(DictLikeHandler):
continue continue
if not has_value: if not has_value:
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
continue continue
# Scalar value - replace with variable # Scalar value - replace with variable
@ -377,8 +376,8 @@ class YamlHandler(DictLikeHandler):
replacement = self._yaml_scalar_expr(var_name, raw_value) replacement = self._yaml_scalar_expr(var_name, raw_value)
leading = rest[: len(rest) - len(rest.lstrip(" \t"))] leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}" new_rest = f"{leading}{replacement}{comment_part}"
new_stripped = f"{escape_jinja_literal(key)}:{new_rest}" new_stripped = f"{key}:{new_rest}"
out_lines.append( out_lines.append(
" " * indent " " * indent
+ new_stripped + new_stripped
@ -440,7 +439,7 @@ class YamlHandler(DictLikeHandler):
else: else:
replacement = self._yaml_scalar_expr(var_name, raw_value) replacement = self._yaml_scalar_expr(var_name, raw_value)
new_stripped = f"- {replacement}{escape_jinja_literal(comment_part)}" new_stripped = f"- {replacement}{comment_part}"
out_lines.append( out_lines.append(
" " * indent " " * indent
+ new_stripped + new_stripped
@ -448,7 +447,7 @@ class YamlHandler(DictLikeHandler):
) )
continue continue
out_lines.append(escape_jinja_literal(raw_line)) out_lines.append(raw_line)
return "".join(out_lines) return "".join(out_lines)
@ -481,7 +480,7 @@ class YamlHandler(DictLikeHandler):
lines: list[str] = [] lines: list[str] = []
if not is_list: if not is_list:
key = candidate.path[-1] if candidate.path else "items" key = candidate.path[-1] if candidate.path else "items"
lines.append(f"{indent_str}{escape_jinja_literal(str(key))}:") lines.append(f"{indent_str}{key}:")
item_lines: list[str] = [] item_lines: list[str] = []
if candidate.items: if candidate.items:
@ -550,16 +549,12 @@ class YamlHandler(DictLikeHandler):
if first_key and is_list_item: if first_key and is_list_item:
# First key gets the list marker # First key gets the list marker
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
lines.append( lines.append(f"{indent_str}- {key}: {value_expr}")
f"{indent_str}- {escape_jinja_literal(str(key))}: {value_expr}"
)
first_key = False first_key = False
else: else:
# Subsequent keys are indented # Subsequent keys are indented
sub_indent = indent + 2 if is_list_item else indent sub_indent = indent + 2 if is_list_item else indent
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
lines.append( lines.append(f"{' ' * sub_indent}{key}: {value_expr}")
f"{' ' * sub_indent}{escape_jinja_literal(str(key))}: {value_expr}"
)
return lines return lines

View file

@ -1,212 +0,0 @@
"""Security regression tests for template injection (SSTI).
JinjaTurtle copies parts of the source config (comments, unrecognised lines,
structural keys) verbatim into the generated template. If that text contains
Jinja2/ERB delimiters it must be neutralised, otherwise attacker-influenced
config content becomes live template code that executes when Salt/Ansible/Puppet
later renders the template.
These tests render the *generated* template the way a downstream tool would and
assert that an injected payload never executes. A tripwire object is exposed
under every name a payload might reference; if the rendered output ever contains
the tripwire sentinel, an injected expression executed and the test fails.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
import jinja2
import pytest
import yaml as pyyaml
from jinjaturtle.escape import escape_jinja_literal, escape_erb_literal
TRIP = "__TRIPWIRE_FIRED__"
class _Boom:
"""Returns the tripwire sentinel for any access/call an SSTI payload makes."""
def run(self, *a, **k):
return TRIP
def __call__(self, *a, **k):
return TRIP
def __getitem__(self, k):
return self
def __getattr__(self, n):
return _Boom()
def __str__(self):
return TRIP
def _render_jinja(template_text: str, defaults: dict) -> str:
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
env.filters.setdefault("to_json", lambda v, **k: __import__("json").dumps(v))
env.filters.setdefault("lower", lambda v: str(v).lower())
ctx = dict(defaults or {})
for name in ("salt", "cmd", "os", "subprocess", "cycler", "lipsum", "namespace"):
ctx.setdefault(name, _Boom())
return env.from_string(template_text).render(**ctx)
def _run_jinjaturtle(tmp_path: Path, source_name: str, body: str, fmt: str):
src = tmp_path / source_name
src.write_text(body, encoding="utf-8")
tpl = tmp_path / "out.tpl"
dfl = tmp_path / "defaults.yml"
res = subprocess.run(
[
sys.executable,
"-m",
"jinjaturtle.cli",
str(src),
"-f",
fmt,
"--role-name",
"role",
"-t",
str(tpl),
"-d",
str(dfl),
],
capture_output=True,
text=True,
)
assert res.returncode == 0, f"generation failed: {res.stderr}"
defaults = pyyaml.safe_load(dfl.read_text()) or {}
return tpl.read_text(), defaults
# A representative payload for each format, placed where the format allows
# attacker-controlled verbatim text (comments / unrecognised lines).
FORMAT_CASES = [
(
"ini",
"evil.ini",
"[s]\n"
"good = ok ; {{ salt['cmd.run']('id') }}\n"
"# {{ cmd.run('whoami') }}\n",
),
(
"yaml",
"evil.yaml",
"server:\n" " motd: ok\n" " # {{ salt['cmd.run']('id') }}\n",
),
(
"toml",
"evil.toml",
"[s]\n" 'good = "ok"\n' "# {{ cmd.run('id') }}\n",
),
(
"xml",
"evil.xml",
"<config>\n"
" <!-- {{ salt['cmd.run']('id') }} -->\n"
' <server name="ok"><motd>ok</motd></server>\n'
"</config>\n",
),
(
"postfix",
"main.cf",
"myhostname = mail.example.com\n" "# {{ salt['cmd.run']('id') }}\n",
),
(
"systemd",
"evil.service",
"[Unit]\n"
"Description=ok\n"
"# {{ cmd.run('id') }}\n"
"RawLineNoEquals {% for x in ().__class__.__bases__ %}\n"
"[Service]\n"
"ExecStart=/bin/true\n",
),
(
"ssh",
"sshd_config",
"# {{ salt['cmd.run']('id') }}\n" "Port 22\n" "PermitRootLogin no\n",
),
]
@pytest.mark.parametrize("fmt,name,body", FORMAT_CASES)
def test_comment_payload_does_not_execute(tmp_path, fmt, name, body):
template_text, defaults = _run_jinjaturtle(tmp_path, name, body, fmt)
rendered = _render_jinja(template_text, defaults)
assert TRIP not in rendered, f"injected payload executed for {fmt}:\n{rendered}"
@pytest.mark.parametrize("fmt,name,body", FORMAT_CASES)
def test_generated_template_is_renderable(tmp_path, fmt, name, body):
# A correct escape must still produce a syntactically valid template.
template_text, defaults = _run_jinjaturtle(tmp_path, name, body, fmt)
# Should not raise a TemplateSyntaxError.
_render_jinja(template_text, defaults)
# --- Unit-level guarantees for the escaper itself ---------------------------
SSTI_PAYLOADS = [
"{{ 7*7 }}",
"{{ salt['cmd.run']('id') }}",
"{% set x = cycler.__init__.__globals__ %}{{ x }}",
"{# comment payload #}",
"text {% endraw %} breakout {{ evil }}",
"nested {% endraw %} spacing {{ evil }}",
"{%- endraw -%}{{ evil }}",
"mixed {{ a }} and {% b %} and {# c #}",
"}}{{ orphan delimiters %}{%",
]
@pytest.mark.parametrize("payload", SSTI_PAYLOADS)
def test_escape_jinja_literal_renders_back_to_original(payload):
"""Escaped text must render to the exact original characters, inertly."""
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
escaped = escape_jinja_literal(payload)
rendered = env.from_string(escaped).render(evil="EVIL", x="X", a="A")
assert rendered == payload
assert TRIP not in rendered
def test_escape_jinja_literal_noop_on_plain_text():
for plain in ["", "hello world", "# a normal comment", "port = 8080", "key: value"]:
assert escape_jinja_literal(plain) == plain
def test_escape_jinja_literal_actually_blocks_execution():
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
payload = "{{ boom.run('x') }}"
escaped = escape_jinja_literal(payload)
rendered = env.from_string(escaped).render(boom=_Boom())
assert TRIP not in rendered
# Sanity: the *unescaped* payload would have fired the tripwire.
fired = env.from_string(payload).render(boom=_Boom())
assert TRIP in fired
@pytest.mark.parametrize(
"payload",
[
"<%= system('id') %>",
"<% require 'open3' %>",
"text <%= 1+1 %> more",
"-%> orphan <%",
],
)
def test_escape_erb_literal_removes_executable_tags(payload):
escaped = escape_erb_literal(payload)
# No raw executable ERB tag should survive that contains the original code.
live = re.findall(r"<%[-=#]?(.*?)-?%>", escaped, re.S)
for chunk in live:
assert "system" not in chunk
assert "require" not in chunk
# Numeric/expression payloads must be reduced to literal-string prints.