diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 0773dbb..d503028 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -17,29 +17,13 @@ jobs: DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-venv pipx - name: Install Poetry - env: - PYTHON_BIN: ${{ matrix.python }} - POETRY_VERSION: "2.4.1" run: | - set -eux - if ! command -v pipx >/dev/null 2>&1; then - "${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}" + pipx install poetry==1.8.3 + /root/.local/bin/poetry --version 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) - env: - PYTHON_BIN: ${{ matrix.python }} run: | - poetry env use "${PYTHON_BIN}" poetry install --with dev - name: Run test script diff --git a/README.md b/README.md index 8423cb7..26730fb 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,9 @@ ERB mode the file is intended to be Puppet Hiera data rather than Ansible role defaults. JinjaTurtle does **not** generate Puppet classes or `file` resources. A Puppet -module, should still declare the class parameters and call the template, for -example with Puppet's `template()` function. +module, or another tool such as Enroll, should still declare the class +parameters and call the template, for example with Puppet's `template()` +function. ## Using `--puppet-class` @@ -339,53 +340,15 @@ JinjaTurtle also templates some common bespoke config formats: For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content 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 -real systems, where some content may be influenced by an untrusted party (a -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! +JinjaTurtle can be used directly, but it is also useful as a helper for tools +that generate configuration-management code. +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? diff --git a/poetry.lock b/poetry.lock index 9bfef74..bc7ba3d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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]] name = "certifi" @@ -6,7 +6,6 @@ version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["dev"] files = [ {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, {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." optional = false python-versions = ">=3.7" -groups = ["dev"] 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-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." optional = false 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 = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -166,110 +162,124 @@ files = [ [[package]] name = "coverage" -version = "7.14.3" +version = "7.14.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ - {file = "coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e"}, - {file = "coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d"}, - {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.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e"}, - {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.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137"}, - {file = "coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18"}, - {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647"}, - {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803"}, - {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27"}, - {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640"}, - {file = "coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7"}, - {file = "coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b"}, - {file = "coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61"}, - {file = "coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3"}, - {file = "coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305"}, - {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.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700"}, - {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.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2"}, - {file = "coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb"}, - {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a"}, - {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab"}, - {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7"}, - {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9"}, - {file = "coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc"}, - {file = "coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8"}, - {file = "coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2"}, - {file = "coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4"}, - {file = "coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24"}, - {file = "coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665"}, - {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.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727"}, - {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.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c"}, - {file = "coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf"}, - {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f"}, - {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205"}, - {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c"}, - {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef"}, - {file = "coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd"}, - {file = "coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35"}, - {file = "coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d"}, - {file = "coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336"}, - {file = "coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c"}, - {file = "coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a"}, - {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.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73"}, - {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.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de"}, - {file = "coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd"}, - {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5"}, - {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb"}, - {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f"}, - {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498"}, - {file = "coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0"}, - {file = "coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37"}, - {file = "coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994"}, - {file = "coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150"}, - {file = "coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc"}, - {file = "coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7"}, - {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.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5"}, - {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.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501"}, - {file = "coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e"}, - {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3"}, - {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5"}, - {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845"}, - {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027"}, - {file = "coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b"}, - {file = "coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965"}, - {file = "coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3"}, - {file = "coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92"}, - {file = "coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949"}, - {file = "coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891"}, - {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.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784"}, - {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.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5"}, - {file = "coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26"}, - {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889"}, - {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d"}, - {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e"}, - {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7"}, - {file = "coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635"}, - {file = "coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc"}, - {file = "coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda"}, - {file = "coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f"}, - {file = "coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8"}, - {file = "coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f"}, + {file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"}, + {file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"}, + {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.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"}, + {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.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"}, + {file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"}, + {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"}, + {file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"}, + {file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"}, + {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"}, + {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"}, + {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.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"}, + {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.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"}, + {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"}, + {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"}, + {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"}, + {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"}, + {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"}, + {file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"}, + {file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"}, + {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.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"}, + {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.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"}, + {file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"}, + {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"}, + {file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"}, + {file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"}, + {file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"}, + {file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"}, + {file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"}, + {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.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"}, + {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.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"}, + {file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"}, + {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"}, + {file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"}, + {file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"}, + {file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"}, + {file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"}, + {file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"}, + {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.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"}, + {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.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"}, + {file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"}, + {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"}, + {file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"}, + {file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"}, + {file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"}, + {file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"}, + {file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"}, + {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.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"}, + {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.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"}, + {file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"}, + {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"}, + {file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"}, + {file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"}, + {file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"}, + {file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"}, + {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] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +toml = ["tomli"] [[package]] name = "defusedxml" @@ -277,7 +287,6 @@ version = "0.7.1" description = "XML bomb protection for Python stdlib modules" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["main"] files = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {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" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"}, {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)" optional = false python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, @@ -323,7 +329,6 @@ version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, @@ -338,7 +343,6 @@ version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {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." optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {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." optional = false python-versions = ">=3.9" -groups = ["main"] 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_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, @@ -467,7 +469,6 @@ version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {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" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {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." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -510,7 +509,6 @@ version = "4.2" description = "Generate AppImages from your Python projects" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"}, {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" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -551,7 +548,6 @@ version = "5.0.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, {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" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {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"}, @@ -653,7 +648,6 @@ version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, @@ -675,7 +669,6 @@ version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] 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_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.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] -markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} [[package]] name = "typing-extensions" @@ -733,8 +725,6 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.10\"" files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {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." optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] [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)"] 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] -lock-version = "2.1" -python-versions = ">=3.10,<4.0" -content-hash = "161dfd9b44e1063656bacea8e8fb08a2614361a8262d8f665240e4f391732e1b" +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "026c4acd254e889b70bb8c25ffb5e6323eee86380f54f2d8ef02f59ae9307529" diff --git a/pyproject.toml b/pyproject.toml index 108a7b2..cbc30c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,40 +1,36 @@ -[project] +[tool.poetry] name = "jinjaturtle" version = "0.5.5" description = "Convert config files into Ansible defaults and Jinja2 templates." -authors = [ - { name = "Miguel Jacq", email = "mig@mig5.net" }, -] +authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" readme = "README.md" -requires-python = ">=3.10,<4.0" -keywords = ["ansible", "jinja2", "config", "toml", "ini", "yaml", "json", "devops"] -dependencies = [ - "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'", -] +packages = [{ include = "jinjaturtle", from = "src" }] + +keywords = ["ansible", "jinja2", "config", "toml", "ini", "yaml", "json", "devops"] -[project.urls] homepage = "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" -[tool.poetry] -packages = [{ include = "jinjaturtle", from = "src" }] - -[tool.poetry.group.dev.dependencies] -pytest = "^8" -pytest-cov = "^5" -pyproject-appimage = "^4.2" - [build-system] -requires = ["poetry-core>=2.0.0"] +requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.pyproject-appimage] script = "jinjaturtle" output = "JinjaTurtle.AppImage" + +[tool.poetry.dev-dependencies] +pytest = "^8" +pytest-cov = "^5" +pyproject-appimage = "^4.2" diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 0c403fa..7658bfb 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -199,8 +199,4 @@ def main() -> None: """ Console-script entry point. """ - sys.exit(_main(sys.argv[1:])) - - -if __name__ == "__main__": - main() + _main(sys.argv[1:]) diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index 1faefca..673aad0 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -412,6 +412,8 @@ def generate_puppet_hiera_yaml( ``role_prefix`` remains the source variable prefix used by JinjaTurtle while ``puppet_class`` is the Puppet class/Hiera namespace. In the normal case 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) diff --git a/src/jinjaturtle/erb.py b/src/jinjaturtle/erb.py index 6eb26ad..445ec51 100644 --- a/src/jinjaturtle/erb.py +++ b/src/jinjaturtle/erb.py @@ -2,8 +2,6 @@ from __future__ import annotations import re -from .escape import escape_erb_literal - def _safe_name(raw: str, *, fallback: str = "var") -> str: 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 parameter ``memory_limit`` and Hiera key ``php::memory_limit``. - When ``puppet_class`` differs from ``role_prefix`` we keep the full - generated variable name as the local parameter and only use ``puppet_class`` - as the Hiera namespace. + Enroll sometimes needs a file-specific variable prefix to avoid collisions + inside a generated module. When ``puppet_class`` differs from + ``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") @@ -72,33 +71,7 @@ class ErbTranslator: self.loop_stack: list[tuple[str, str, str]] = [] 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: - # 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) out: list[str] = [] for token in parts: @@ -113,7 +86,11 @@ class ErbTranslator: out.append(self.statement_to_erb(stmt)) continue 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: return puppet_local_var_name( diff --git a/src/jinjaturtle/escape.py b/src/jinjaturtle/escape.py deleted file mode 100644 index 3b8575e..0000000 --- a/src/jinjaturtle/escape.py +++ /dev/null @@ -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) diff --git a/src/jinjaturtle/handlers/ini.py b/src/jinjaturtle/handlers/ini.py index 9f50839..1aa5e22 100644 --- a/src/jinjaturtle/handlers/ini.py +++ b/src/jinjaturtle/handlers/ini.py @@ -6,7 +6,6 @@ from typing import Any from . import BaseHandler from .. import j2 -from ..escape import escape_jinja_literal class IniHandler(BaseHandler): @@ -85,18 +84,16 @@ class IniHandler(BaseHandler): line = raw_line stripped = line.lstrip() - # Blank or pure comment: keep formatting, but neutralise any - # template metacharacters so attacker-controlled comment text cannot - # become live template code in the output. + # Blank or pure comment: keep as-is if not stripped or stripped[0] in {"#", ";"}: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Section header if stripped.startswith("[") and "]" in stripped: header_inner = stripped[1 : stripped.index("]")] current_section = header_inner.strip() - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Work without newline so we can re-attach it exactly @@ -111,9 +108,8 @@ class IniHandler(BaseHandler): eq_index = content.find("=") if eq_index == -1: - # Not a simple key=value line: leave content intact but escape - # any template metacharacters. - out_lines.append(escape_jinja_literal(raw_line)) + # Not a simple key=value line: leave untouched + out_lines.append(raw_line) continue before_eq = content[:eq_index] @@ -121,7 +117,7 @@ class IniHandler(BaseHandler): key = before_eq.strip() if not key: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Whitespace after '=' @@ -150,16 +146,8 @@ class IniHandler(BaseHandler): else: 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 = ( - escape_jinja_literal(before_eq) - + "=" - + leading_ws - + replacement_value - + escape_jinja_literal(comment_part) + before_eq + "=" + leading_ws + replacement_value + comment_part ) out_lines.append(new_content + newline) diff --git a/src/jinjaturtle/handlers/postfix.py b/src/jinjaturtle/handlers/postfix.py index f6a1974..e2e442a 100644 --- a/src/jinjaturtle/handlers/postfix.py +++ b/src/jinjaturtle/handlers/postfix.py @@ -5,7 +5,6 @@ from typing import Any from . import BaseHandler from .. import j2 -from ..escape import escape_jinja_literal class PostfixMainHandler(BaseHandler): @@ -109,16 +108,16 @@ class PostfixMainHandler(BaseHandler): stripped = content.strip() if not stripped: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) i += 1 continue if stripped.startswith("#"): - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) i += 1 continue if "=" not in content: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) i += 1 continue @@ -128,7 +127,7 @@ class PostfixMainHandler(BaseHandler): key = before_eq.strip() if not key: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) i += 1 continue @@ -163,21 +162,16 @@ class PostfixMainHandler(BaseHandler): var = self.make_var_name(role_prefix, (key,)) 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 {'"', "'"} if quoted: replacement = ( - f"{safe_before}={leading_ws}{j2.quoted_variable(var)}" - f"{safe_comment}{newline}" + f"{before_eq}={leading_ws}{j2.quoted_variable(var)}" + f"{comment_part}{newline}" ) else: replacement = ( - f"{safe_before}={leading_ws}{j2.variable(var)}" - f"{safe_comment}{newline}" + f"{before_eq}={leading_ws}{j2.variable(var)}" + f"{comment_part}{newline}" ) out_lines.append(replacement) diff --git a/src/jinjaturtle/handlers/ssh.py b/src/jinjaturtle/handlers/ssh.py index f588a33..a6c0d2f 100644 --- a/src/jinjaturtle/handlers/ssh.py +++ b/src/jinjaturtle/handlers/ssh.py @@ -7,7 +7,6 @@ from typing import Any from . import BaseHandler from .. import j2 -from ..escape import escape_jinja_literal _SECTION_KEYWORDS = {"host", "match"} @@ -256,12 +255,12 @@ class SshConfigHandler(BaseHandler): out_lines: list[str] = [] for ln in parsed.lines: if ln.kind != "kv": - out_lines.append(escape_jinja_literal(ln.raw)) + out_lines.append(ln.raw) continue path = self._path_for_line(ln) if not path: - out_lines.append(escape_jinja_literal(ln.raw)) + out_lines.append(ln.raw) continue var = self.make_var_name(role_prefix, path) @@ -271,12 +270,9 @@ class SshConfigHandler(BaseHandler): else: replacement_value = j2.variable(var) - # ``before_value`` (keyword + spacing) and ``comment`` are - # source-derived; escape around the safe placeholder. rendered = ( - f"{escape_jinja_literal(ln.before_value)}{replacement_value}" - f"{ln.whitespace_before_comment}" - f"{escape_jinja_literal(ln.comment)}{ln.newline}" + f"{ln.before_value}{replacement_value}" + f"{ln.whitespace_before_comment}{ln.comment}{ln.newline}" ) out_lines.append(rendered) diff --git a/src/jinjaturtle/handlers/systemd.py b/src/jinjaturtle/handlers/systemd.py index d28a8c5..8118808 100644 --- a/src/jinjaturtle/handlers/systemd.py +++ b/src/jinjaturtle/handlers/systemd.py @@ -6,7 +6,6 @@ from typing import Any from . import BaseHandler from .. import j2 -from ..escape import escape_jinja_literal @dataclass @@ -158,13 +157,7 @@ class SystemdUnitHandler(BaseHandler): out_lines: list[str] = [] for ln in parsed.lines: if ln.kind != "kv" or not ln.section or not ln.key: - # Verbatim lines (blank/comment/section/unrecognised "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)) + out_lines.append(ln.raw) continue path: tuple[str, ...] = (ln.section, ln.key) @@ -173,18 +166,16 @@ class SystemdUnitHandler(BaseHandler): var = self.make_var_name(role_prefix, path) 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 {'"', "'"} if quoted: repl = ( - f"{safe_before}={ln.leading_ws_after_eq}" - f"{j2.quoted_variable(var)}{safe_comment}" + f"{ln.before_eq}={ln.leading_ws_after_eq}" + f"{j2.quoted_variable(var)}{ln.comment}" ) else: repl = ( - f"{safe_before}={ln.leading_ws_after_eq}" - f"{j2.variable(var)}{safe_comment}" + f"{ln.before_eq}={ln.leading_ws_after_eq}" + f"{j2.variable(var)}{ln.comment}" ) newline = "\n" if ln.raw.endswith("\n") else "" diff --git a/src/jinjaturtle/handlers/toml.py b/src/jinjaturtle/handlers/toml.py index 6d25cff..15d5a2a 100644 --- a/src/jinjaturtle/handlers/toml.py +++ b/src/jinjaturtle/handlers/toml.py @@ -5,7 +5,6 @@ from typing import Any from . import DictLikeHandler from .. import j2 -from ..escape import escape_jinja_literal from ..loop_analyzer import LoopCandidate try: @@ -78,25 +77,19 @@ class TomlHandler(DictLikeHandler): def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: var_name = self.make_var_name(role_prefix, path + (key,)) if isinstance(value, str): - lines.append( - f"{escape_jinja_literal(str(key))} = {self._toml_quoted_expr(var_name)}" - ) + lines.append(f"{key} = {self._toml_quoted_expr(var_name)}") elif isinstance(value, bool): # Booleans need | lower filter (Python True/False → TOML true/false) - lines.append( - f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}" - ) + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") else: - lines.append( - f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}" - ) + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") 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)} nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} if path: - header = ".".join(escape_jinja_literal(str(p)) for p in path) + header = ".".join(path) lines.append(f"[{header}]") 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: var_name = self.make_var_name(role_prefix, path + (key,)) if isinstance(value, str): - lines.append( - f"{escape_jinja_literal(str(key))} = {self._toml_quoted_expr(var_name)}" - ) + lines.append(f"{key} = {self._toml_quoted_expr(var_name)}") elif isinstance(value, bool): # Booleans need | lower filter (Python True/False → TOML true/false) - lines.append( - f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}" - ) + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") elif isinstance(value, list): # Check if this list is a loop candidate if path + (key,) in loop_paths: @@ -168,26 +157,19 @@ class TomlHandler(DictLikeHandler): elif candidate.item_schema in ("simple_dict", "nested"): # Dict list loop - TOML array of tables # This is complex for TOML, using simplified approach - lines.append( - f"{escape_jinja_literal(str(key))} = " - f"{j2.to_json(var_name)}" - ) + lines.append(f"{key} = " f"{j2.to_json(var_name)}") else: # Not a loop, treat as regular variable - lines.append( - f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}" - ) + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") else: - lines.append( - f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}" - ) + lines.append(f"{key} = {self._toml_value_expr(var_name, value)}") 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)} nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} if path: - header = ".".join(escape_jinja_literal(str(p)) for p in path) + header = ".".join(path) lines.append(f"[{header}]") for key, val in scalar_items.items(): @@ -235,7 +217,7 @@ class TomlHandler(DictLikeHandler): # Blank or pure comment if not stripped or stripped.startswith("#"): - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Table header: [server] or [server.tls] or [[array.of.tables]] @@ -248,7 +230,7 @@ class TomlHandler(DictLikeHandler): inner = inner.strip("[]") # handle [[table]] as well parts = [p.strip() for p in inner.split(".") if p.strip()] current_table = tuple(parts) - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Try key = value @@ -263,7 +245,7 @@ class TomlHandler(DictLikeHandler): eq_index = content.find("=") if eq_index == -1: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue before_eq = content[:eq_index] @@ -271,7 +253,7 @@ class TomlHandler(DictLikeHandler): key = before_eq.strip() if not key: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Whitespace after '=' @@ -307,23 +289,19 @@ class TomlHandler(DictLikeHandler): nested_var = self.make_var_name(role_prefix, nested_path) if isinstance(sub_val, str): 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): 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: 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) + " }" new_content = ( - escape_jinja_literal(before_eq) - + "=" - + leading_ws - + replacement_value - + escape_jinja_literal(comment_part) + before_eq + "=" + leading_ws + replacement_value + comment_part ) out_lines.append(new_content + newline) continue @@ -349,11 +327,7 @@ class TomlHandler(DictLikeHandler): replacement_value = j2.variable(var_name) new_content = ( - escape_jinja_literal(before_eq) - + "=" - + leading_ws - + replacement_value - + escape_jinja_literal(comment_part) + before_eq + "=" + leading_ws + replacement_value + comment_part ) out_lines.append(new_content + newline) @@ -381,7 +355,7 @@ class TomlHandler(DictLikeHandler): if not stripped or stripped.startswith("#"): # Only output if we're not skipping if not skip_until_next_table: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Table header: [server] or [server.tls] or [[array.of.tables]] @@ -430,9 +404,7 @@ class TomlHandler(DictLikeHandler): out_lines.append( f"{j2.for_start(item_var, collection_var)}\n" ) - out_lines.append( - f"[[{'.'.join(escape_jinja_literal(str(p)) for p in table_path)}]]\n" - ) + out_lines.append(f"[[{'.'.join(table_path)}]]\n") # Add fields from sample item for key, value in sample_item.items(): @@ -440,17 +412,17 @@ class TomlHandler(DictLikeHandler): continue if isinstance(value, str): out_lines.append( - f"{escape_jinja_literal(str(key))} = " + f"{key} = " f"{self._toml_quoted_expr(f'{item_var}.{key}')}\n" ) elif isinstance(value, bool): out_lines.append( - f"{escape_jinja_literal(str(key))} = " + f"{key} = " f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n" ) else: out_lines.append( - f"{escape_jinja_literal(str(key))} = " + f"{key} = " f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n" ) @@ -465,7 +437,7 @@ class TomlHandler(DictLikeHandler): skip_until_next_table = False current_table = table_path - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # If we're inside a skipped array-of-tables section, skip this line @@ -484,7 +456,7 @@ class TomlHandler(DictLikeHandler): eq_index = content.find("=") if eq_index == -1: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue before_eq = content[:eq_index] @@ -492,7 +464,7 @@ class TomlHandler(DictLikeHandler): key = before_eq.strip() if not key: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Whitespace after '=' @@ -529,11 +501,7 @@ class TomlHandler(DictLikeHandler): replacement_value = j2.to_json(collection_var) new_content = ( - escape_jinja_literal(before_eq) - + "=" - + leading_ws - + replacement_value - + escape_jinja_literal(comment_part) + before_eq + "=" + leading_ws + replacement_value + comment_part ) out_lines.append(new_content + newline) continue @@ -558,23 +526,19 @@ class TomlHandler(DictLikeHandler): nested_var = self.make_var_name(role_prefix, nested_path) if isinstance(sub_val, str): 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): 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: 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) + " }" new_content = ( - escape_jinja_literal(before_eq) - + "=" - + leading_ws - + replacement_value - + escape_jinja_literal(comment_part) + before_eq + "=" + leading_ws + replacement_value + comment_part ) out_lines.append(new_content + newline) continue @@ -600,11 +564,7 @@ class TomlHandler(DictLikeHandler): replacement_value = j2.variable(var_name) new_content = ( - escape_jinja_literal(before_eq) - + "=" - + leading_ws - + replacement_value - + escape_jinja_literal(comment_part) + before_eq + "=" + leading_ws + replacement_value + comment_part ) out_lines.append(new_content + newline) diff --git a/src/jinjaturtle/handlers/xml.py b/src/jinjaturtle/handlers/xml.py index 9fde3ec..51c7a73 100644 --- a/src/jinjaturtle/handlers/xml.py +++ b/src/jinjaturtle/handlers/xml.py @@ -7,7 +7,6 @@ import xml.etree.ElementTree as ET # nosec from .base import BaseHandler from .. import j2 -from ..escape import escape_jinja_literal from ..loop_analyzer import LoopCandidate @@ -231,37 +230,6 @@ class XmlHandler(BaseHandler): 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 ```` 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: """Generate scalar-only Jinja2 template.""" prolog, body = self._split_xml_prolog(text) @@ -272,16 +240,12 @@ class XmlHandler(BaseHandler): 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) if indent is not None: indent(root, space=" ") # type: ignore[arg-type] 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( self, @@ -301,11 +265,6 @@ class XmlHandler(BaseHandler): # Apply Jinja transformations (including loop markers) 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 indent = getattr(ET, "indent", None) if indent is not None: @@ -316,7 +275,7 @@ class XmlHandler(BaseHandler): # Post-process to replace loop markers with actual Jinja loops 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( self, diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index d8131b0..1ad9bf3 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -6,7 +6,6 @@ from typing import Any from .dict import DictLikeHandler from .. import j2 -from ..escape import escape_jinja_literal from ..loop_analyzer import LoopCandidate @@ -103,7 +102,7 @@ class YamlHandler(DictLikeHandler): indent = len(raw_line) - len(stripped) if not stripped or stripped.startswith("#"): - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue while stack and indent < stack[-1][0]: @@ -113,7 +112,7 @@ class YamlHandler(DictLikeHandler): key_part, rest = stripped.split(":", 1) key = key_part.strip() if not key: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue rest_stripped = rest.lstrip(" \t") @@ -126,7 +125,7 @@ class YamlHandler(DictLikeHandler): stack.append((indent, path, "map")) if not has_value: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue value_part, comment_part = self._split_inline_comment( @@ -148,8 +147,8 @@ class YamlHandler(DictLikeHandler): replacement = self._yaml_scalar_expr(var_name, raw_value) leading = rest[: len(rest) - len(rest.lstrip(" \t"))] - new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}" - new_stripped = f"{escape_jinja_literal(key)}:{new_rest}" + new_rest = f"{leading}{replacement}{comment_part}" + new_stripped = f"{key}:{new_rest}" out_lines.append( " " * indent + new_stripped @@ -186,7 +185,7 @@ class YamlHandler(DictLikeHandler): else: 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( " " * indent + new_stripped @@ -194,7 +193,7 @@ class YamlHandler(DictLikeHandler): ) continue - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) return "".join(out_lines) @@ -276,7 +275,7 @@ class YamlHandler(DictLikeHandler): next_line = next_significant_line(line_index) if next_line is None: skip_until_indent = None - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) else: next_indent, next_stripped = next_line still_in_collection = next_indent > skip_until_indent or ( @@ -285,12 +284,12 @@ class YamlHandler(DictLikeHandler): ) if not still_in_collection: skip_until_indent = None - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue if is_comment: if indent <= skip_until_indent: skip_until_indent = None - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) # Comments/blank lines indented beneath the replaced # collection are considered part of that collection and # cannot be placed safely inside a generated loop. @@ -304,7 +303,7 @@ class YamlHandler(DictLikeHandler): # Blank or comment lines if is_blank or is_comment: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Adjust stack based on indent @@ -316,7 +315,7 @@ class YamlHandler(DictLikeHandler): key_part, rest = stripped.split(":", 1) key = key_part.strip() if not key: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue rest_stripped = rest.lstrip(" \t") @@ -354,7 +353,7 @@ class YamlHandler(DictLikeHandler): continue if not has_value: - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) continue # Scalar value - replace with variable @@ -377,8 +376,8 @@ class YamlHandler(DictLikeHandler): replacement = self._yaml_scalar_expr(var_name, raw_value) leading = rest[: len(rest) - len(rest.lstrip(" \t"))] - new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}" - new_stripped = f"{escape_jinja_literal(key)}:{new_rest}" + new_rest = f"{leading}{replacement}{comment_part}" + new_stripped = f"{key}:{new_rest}" out_lines.append( " " * indent + new_stripped @@ -440,7 +439,7 @@ class YamlHandler(DictLikeHandler): else: 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( " " * indent + new_stripped @@ -448,7 +447,7 @@ class YamlHandler(DictLikeHandler): ) continue - out_lines.append(escape_jinja_literal(raw_line)) + out_lines.append(raw_line) return "".join(out_lines) @@ -481,7 +480,7 @@ class YamlHandler(DictLikeHandler): lines: list[str] = [] if not is_list: 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] = [] if candidate.items: @@ -550,16 +549,12 @@ class YamlHandler(DictLikeHandler): if first_key and is_list_item: # First key gets the list marker value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) - lines.append( - f"{indent_str}- {escape_jinja_literal(str(key))}: {value_expr}" - ) + lines.append(f"{indent_str}- {key}: {value_expr}") first_key = False else: # Subsequent keys are indented sub_indent = indent + 2 if is_list_item else indent value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) - lines.append( - f"{' ' * sub_indent}{escape_jinja_literal(str(key))}: {value_expr}" - ) + lines.append(f"{' ' * sub_indent}{key}: {value_expr}") return lines diff --git a/tests/test_injection_security.py b/tests/test_injection_security.py deleted file mode 100644 index 642c0b8..0000000 --- a/tests/test_injection_security.py +++ /dev/null @@ -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", - "\n" - " \n" - ' ok\n' - "\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.