diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index d503028..67c69d3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -7,6 +7,14 @@ jobs: test: runs-on: docker + strategy: + fail-fast: false + matrix: + include: + - distro: debian + image: docker.io/library/debian:13 + python: python3 + steps: - name: Checkout uses: actions/checkout@v4 @@ -17,13 +25,29 @@ 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: | - pipx install poetry==1.8.3 - /root/.local/bin/poetry --version + 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}" 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/.gitignore b/.gitignore index dedc5da..5a5117f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ dist *.j2 *.toml regenerated_* +*.orig +*.rej diff --git a/Dockerfile.debbuild b/Dockerfile.debbuild index 8d185b1..35ffc7b 100644 --- a/Dockerfile.debbuild +++ b/Dockerfile.debbuild @@ -61,6 +61,23 @@ rsync -a --delete \ "${SRC}/" "${WORK}/" cd "${WORK}" + +# This project's pyproject.toml uses the PEP 621 [project] table, which needs +# poetry-core >= 2.0. Debian bookworm and Ubuntu jammy/noble ship an older +# poetry-core that cannot parse it. On those, swap in the legacy Poetry 1.x +# formatted metadata (kept in sync at pyproject.poetry1.toml) for the build. +# trixie and newer ship poetry-core 2.x and keep the PEP 621 file. +if [ -f pyproject.poetry1.toml ]; then + core_ver="$(python3 -c 'import poetry.core as c; print(c.__version__)' 2>/dev/null || echo 0)" + core_major="${core_ver%%.*}" + if [ "${core_major:-0}" -lt 2 ]; then + echo "poetry-core ${core_ver} < 2.0: using legacy pyproject.poetry1.toml" + cp pyproject.poetry1.toml pyproject.toml + else + echo "poetry-core ${core_ver} >= 2.0: using PEP 621 pyproject.toml" + fi +fi + if [ -n "${SUITE:-}" ]; then export DEBEMAIL="mig@mig5.net" export DEBFULLNAME="Miguel Jacq" diff --git a/README.md b/README.md index 0e0ad48..5e104f3 100644 --- a/README.md +++ b/README.md @@ -4,55 +4,134 @@ JinjaTurtle logo -JinjaTurtle is a command-line tool to help you generate Jinja2 templates and -Ansible inventory from a native configuration file (or files) of a piece of -software. +JinjaTurtle is a command-line tool that helps turn existing native +configuration files into reusable configuration-management templates. + +By default it generates: + +- a **Jinja2** template; and +- an **Ansible defaults YAML** file containing the variables used by that + template. + +JinjaTurtle does not try to replace configuration-management tools. Its job is +to speed up the boring first pass: take a real config file, discover the values +inside it, replace those values with variables, and write the corresponding +variable data beside the template. ## How it works - * The config file(s) is/are examined - * Parameter key names are generated based on the parameter names in the - config file. In keeping with Ansible best practices, you pass a prefix - for the key names, which should typically match the name of your Ansible - role. - * A Jinja2 file is generated from the file with those parameter key names - injected as the `{{ variable }}` names. - * An Ansible inventory YAML file is generated with those key names and the - *values* taken from the original config file as the default vars. +JinjaTurtle examines a source config file and keeps the original structure as +much as possible. -By default, the Jinja2 template and the Ansible inventory are printed to -stdout. However, it is possible to output the results to new files. +For the default Jinja2/Ansible mode: + +1. The config file is parsed. +2. Variable names are generated from the config keys and paths. +3. Those variable names are prefixed with `--role-name`, which should usually + match your Ansible role name. +4. A Jinja2 template is generated with values replaced by `{{ variable }}` + expressions. +5. An Ansible defaults YAML file is generated with those variables and the + original values. + +By default, the generated variable data and template are printed to stdout. Use +`--defaults-output` and `--template-output` to write them to files. + +## Jinja2 / Ansible example + +Say you have a `php.ini` file and you are inside an Ansible role with +`defaults/` and `templates/` directories: + +```shell +jinjaturtle php.ini \ + --role-name php \ + --defaults-output defaults/main.yml \ + --template-output templates/php.ini.j2 +``` + +Given a source value such as: + +```ini +memory_limit = 256M +``` + +JinjaTurtle will produce a template value like: + +```jinja2 +memory_limit = {{ php_memory_limit }} +``` + +and defaults data like: + +```yaml +php_memory_limit: 256M +``` ## What sort of config files can it handle? -TOML, YAML, INI, JSON and XML-style config files should be okay. There are always -going to be some edge cases in very complex files that are difficult to work -with, though, so you may still find that you need to tweak the results. +JinjaTurtle supports common structured and semi-structured config formats: -For XML and YAML files, JinjaTurtle will attempt to generate 'for' loops -and lists in the Ansible yaml if the config file looks homogenous enough to -support it. However, if it lacks the confidence in this, it will fall back to -using scalar-style flattened attributes. +- TOML +- YAML +- INI-style files +- JSON +- XML +- Postfix `main.cf` +- systemd unit files, such as `*.service`, `*.socket`, `*.timer`, and related + unit types +- OpenSSH-style config files, including `ssh_config`, `sshd_config`, and common + `*.conf` snippets detected as SSH config -You may need or wish to tidy up the config to suit your needs. +For ambiguous extensions such as `*.conf`, JinjaTurtle uses lightweight content +sniffing. You can always force a handler with `--format`. -The goal here is really to *speed up* converting files into Ansible/Jinja2, -but not necessarily to make it perfect. +For YAML, XML, TOML, INI-style, and other supported structured files, +JinjaTurtle will attempt to generate loops when a repeated structure looks +homogeneous enough. If it is not confident, it falls back to flattened scalar +variables. + +Some very complex files will still need manual cleanup. The goal is to speed up +conversion into Jinja2 templates, not to guarantee a perfect final module without +review. + +## JSON, quoting, and type preservation + +JinjaTurtle tries to preserve rendered config types. + +For JSON, it uses JSON-aware expressions rather than plain string substitution. +This avoids generating invalid JSON such as: + +```json +{"enabled": True} +``` + +when the correct rendered JSON should be: + +```json +{"enabled": true} +``` + +This uses Ansible-style JSON filters. ## Can I convert multiple files at once? -Certainly! Pass the folder name instead of a specific file name, and JinjaTurtle -will convert any files it understands in that folder, storing all the various -vars in the destination defaults yaml file, and converting each file into a -Jinja2 template per file type. +Yes. Pass a directory instead of a single file and JinjaTurtle will convert the +files it understands in that directory. -If all the files had the same 'type', there'll be one Jinja2 template. +```shell +jinjaturtle ./config-dir \ + --role-name myrole \ + --defaults-output defaults/main.yml \ + --template-output templates/ +``` -You can also pass `--recursive` to recurse into subfolders. +Use `--recursive` to recurse into subdirectories. -Note: when using 'folder' mode and multiple files of the same type, their vars -will be listed under an 'items' parent key in the yaml, each with an `id` key. -You'll then want to use a `loop` in Ansible later, e.g: +In folder mode, variables for multiple files of the same type are grouped under +an `items`-style structure in the generated YAML so that the resulting templates +can be used with loops in Ansible. + +For example: ```yaml - name: Render configs @@ -93,9 +172,9 @@ sudo dnf upgrade --refresh sudo dnf install jinjaturtle ``` -### From PyPi +### From PyPI -``` +```bash pip install jinjaturtle ``` @@ -103,61 +182,97 @@ pip install jinjaturtle Clone the repo and then run inside the clone: -``` +```bash poetry install ``` -### AppImage - -Download the AppImage from the Releases and make it executable, and put it -on your `$PATH`. - -## How to run it - -Say you have a `php.ini` file and you are in a directory structure like an -Ansible role (with subfolders `defaults` and `templates`): - -```shell -jinjaturtle php.ini \ - --role-name php \ - --defaults-output defaults/main.yml \ - --template-output templates/php.ini.j2 -``` - ## Full usage info -``` -usage: jinjaturtle [-h] -r ROLE_NAME [-f {json,ini,toml,yaml,xml,postfix,systemd}] [-d DEFAULTS_OUTPUT] [-t TEMPLATE_OUTPUT] config +```text +usage: jinjaturtle [-h] [-r ROLE_NAME] [--recursive] + [-f {ini,json,toml,yaml,xml,postfix,systemd,ssh}] + [-d DEFAULTS_OUTPUT] [-t TEMPLATE_OUTPUT] + config -Convert a config file into Ansible inventory and a Jinja2 template. +Convert a config file into an Ansible defaults file and Jinja2 template. positional arguments: - config Path to the source configuration file. + config Path to a config file OR a folder containing supported + config files. Supported: .toml, .yaml/.yml, .json, + .ini/.cfg/.conf, .xml, ssh_config/sshd_config options: -h, --help show this help message and exit -r, --role-name ROLE_NAME - Ansible role name, used as variable prefix (e.g. cometbft). - -f, --format {ini,json,toml,xml} - Force config format instead of auto-detecting from filename. + Role name / variable prefix. In Jinja2 mode this is + usually the Ansible role name. Defaults to jinjaturtle. + --recursive When CONFIG is a folder, recurse into subfolders. + -f, --format {ini,json,toml,yaml,xml,postfix,systemd,ssh} + Force config format instead of auto-detecting from + filename. -d, --defaults-output DEFAULTS_OUTPUT - Path to write defaults/main.yml. If omitted, default vars are printed to stdout. + Path to write the generated variable YAML. If omitted, + it is printed to stdout. -t, --template-output TEMPLATE_OUTPUT - Path to write the Jinja2 config template. If omitted, template is printed to stdout. + Path to write the generated config template. If omitted, + it is printed to stdout. ``` ## Additional supported formats -JinjaTurtle can also template some common "bespoke" config formats: +JinjaTurtle also templates some common bespoke config formats: - **Postfix main.cf** (`main.cf`) → `--format postfix` - **systemd unit files** (`*.service`, `*.socket`, etc.) → `--format systemd` +- **OpenSSH config** (`ssh_config`, `sshd_config`, and detected snippets) → + `--format ssh` -For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content sniffing; you can always force a specific handler via `--format`. +For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content +sniffing. You can always force a specific handler with `--format`. + +## Security model + +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 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 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 + (`{{ }}`, `{% %}`, `{# #}` ) 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. + +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! ## Found a bug, have a suggestion? -You can e-mail me (see the pyproject.toml for details) or contact me on the Fediverse: - -https://goto.mig5.net/@mig5 +You can e-mail me; see `pyproject.toml` for details. diff --git a/debian/changelog b/debian/changelog index 42a966a..be6b123 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,52 @@ +jinjaturtle (0.7.0) unstable; urgency=medium + + * Much hardening. + + -- Miguel Jacq Sun, 5 Jul 2026 10:54:00 +1000 + +jinjaturtle (0.5.7) unstable; urgency=medium + + * More hardening measures + + -- Miguel Jacq Wed, 24 Jun 2026 16:13:00 +1000 + +jinjaturtle (0.5.6) unstable; urgency=medium + + * Try to prevent what could lead to execution of embedded jinja in original files when converting + + -- Miguel Jacq Tue, 23 Jun 2026 16:47:00 +1000 + +jinjaturtle (0.5.5) unstable; urgency=medium + + * erb support + + -- Miguel Jacq Sat, 20 Jun 2026 18:27:00 +1000 + +jinjaturtle (0.5.4) unstable; urgency=medium + + * Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. + * More test coverage + + -- Miguel Jacq Sat, 20 Jun 2026 15:29:00 +1000 + +jinjaturtle (0.5.3) unstable; urgency=medium + + * Fix loss of comments and True/False to true/false + + -- Miguel Jacq Fri, 19 Jun 2026 18:43:00 +1000 + +jinjaturtle (0.5.2) unstable; urgency=medium + + * Fix indentation problems with nested dicts + + -- Miguel Jacq Fri, 19 Jun 2026 18:33:00 +1000 + +jinjaturtle (0.5.1) unstable; urgency=medium + + * Empty dicts and lists are now emitted as leaf defaults. + + -- Miguel Jacq Fri, 19 Jun 2026 17:43:00 +1000 + jinjaturtle (0.5.0) unstable; urgency=medium * Support ssh configs diff --git a/poetry.lock b/poetry.lock index 2717000..9bfef74 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,15 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "certifi" -version = "2026.4.22" +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.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"}, - {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"}, + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] [[package]] @@ -17,6 +18,7 @@ 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"}, @@ -155,6 +157,8 @@ 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"}, @@ -162,124 +166,110 @@ files = [ [[package]] name = "coverage" -version = "7.14.0" +version = "7.14.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"}, - {file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"}, - {file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"}, - {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"}, - {file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"}, - {file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"}, - {file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"}, - {file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"}, - {file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"}, - {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"}, - {file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"}, - {file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"}, - {file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"}, - {file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"}, - {file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"}, - {file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"}, - {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"}, - {file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"}, - {file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"}, - {file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"}, - {file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"}, - {file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"}, - {file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"}, - {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"}, - {file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"}, - {file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"}, - {file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"}, - {file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"}, - {file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"}, - {file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"}, - {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"}, - {file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"}, - {file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"}, - {file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"}, - {file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"}, - {file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"}, - {file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"}, - {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"}, - {file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"}, - {file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"}, - {file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"}, - {file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"}, - {file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"}, - {file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"}, - {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"}, - {file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"}, - {file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"}, - {file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"}, - {file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"}, - {file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"}, + {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"}, ] [package.dependencies] tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "defusedxml" @@ -287,6 +277,7 @@ 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"}, @@ -298,6 +289,7 @@ 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"}, @@ -312,6 +304,8 @@ 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"}, @@ -325,13 +319,14 @@ test = ["pytest (>=6)"] [[package]] name = "idna" -version = "3.14" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69"}, - {file = "idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] @@ -343,6 +338,7 @@ 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"}, @@ -354,6 +350,7 @@ 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"}, @@ -371,6 +368,7 @@ 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"}, @@ -469,6 +467,7 @@ 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"}, @@ -480,6 +479,7 @@ 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,6 +495,7 @@ 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"}, @@ -509,6 +510,7 @@ 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"}, @@ -525,6 +527,7 @@ 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"}, @@ -548,6 +551,7 @@ 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"}, @@ -566,6 +570,7 @@ 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"}, @@ -644,13 +649,14 @@ files = [ [[package]] name = "requests" -version = "2.34.0" +version = "2.34.2" description = "Python HTTP for Humans." optional = false python-versions = ">=3.10" +groups = ["dev"] files = [ - {file = "requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60"}, - {file = "requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] @@ -669,6 +675,7 @@ 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"}, @@ -718,6 +725,7 @@ 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" @@ -725,6 +733,8 @@ 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"}, @@ -736,18 +746,19 @@ 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)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] -lock-version = "2.0" -python-versions = "^3.10" -content-hash = "026c4acd254e889b70bb8c25ffb5e6323eee86380f54f2d8ef02f59ae9307529" +lock-version = "2.1" +python-versions = ">=3.10,<4.0" +content-hash = "161dfd9b44e1063656bacea8e8fb08a2614361a8262d8f665240e4f391732e1b" diff --git a/pyproject.toml b/pyproject.toml index f932f55..7751303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,40 @@ -[tool.poetry] +[project] name = "jinjaturtle" -version = "0.5.0" +version = "0.7.0" description = "Convert config files into Ansible defaults and Jinja2 templates." -authors = ["Miguel Jacq "] +authors = [ + { name = "Miguel Jacq", email = "mig@mig5.net" }, +] license = "GPL-3.0-or-later" readme = "README.md" -packages = [{ include = "jinjaturtle", from = "src" }] - +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'", +] +[project.urls] homepage = "https://git.mig5.net/mig5/jinjaturtle" repository = "https://git.mig5.net/mig5/jinjaturtle" -[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] +[project.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>=1.0.0"] +requires = ["poetry-core>=2.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/release.sh b/release.sh index fcc84ed..e221cef 100755 --- a/release.sh +++ b/release.sh @@ -7,11 +7,6 @@ filedust -y . # Publish to Pypi poetry build -poetry publish - -# Make AppImage -poetry run pyproject-appimage -mv JinjaTurtle.AppImage dist/ # Sign packages for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done @@ -48,11 +43,10 @@ sudo apt-get -y install createrepo-c rpm BUILD_OUTPUT="${HOME}/git/jinjaturtle/dist" KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99" REPO_ROOT="${HOME}/git/repo_rpm" -REMOTE="letessier.mig5.net:/opt/repo_rpm" +REMOTE="ashpool.mig5.net:/opt/repo_rpm" DISTS=( fedora:43 - fedora:42 ) for dist in ${DISTS[@]}; do @@ -87,6 +81,9 @@ for dist in ${DISTS[@]}; do qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc" done +# If we got this far, we can publish to PyPI +poetry publish + echo "==> Syncing repo to server..." rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/" diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 9bb41bb..8c8d845 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.0 +%global upstream_version 0.7.0 Name: jinjaturtle Version: %{upstream_version} @@ -42,7 +42,24 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog -* Tue May 11 2026 Miguel Jacq - %{version}-%{release} +* Sun Jul 05 2026 Miguel Jacq - %{version}-%{release} +- Much hardening +* Wed Jun 24 2026 Miguel Jacq - %{version}-%{release} +- More hardening +* Tue Jun 23 2026 Miguel Jacq - %{version}-%{release} +- Try to prevent what could lead to execution of embedded jinja in original files when converting +* Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} +- erb support +* Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} +- Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. +- More test coverage +* Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} +- Fix loss of comments and True/False to true/false +* Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} +- Fix indentation problems with nested dicts +* Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} +- Empty dicts and lists are now emitted as leaf defaults. +* Mon May 11 2026 Miguel Jacq - %{version}-%{release} - Support ssh configs * Tue Jan 06 2026 Miguel Jacq - %{version}-%{release} - Support converting systemd files and postfix main.cf diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 82b6775..d901606 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -3,17 +3,22 @@ from __future__ import annotations import argparse import sys from defusedxml import defuse_stdlib +from defusedxml.common import DefusedXmlException from pathlib import Path +from . import j2 from .core import ( parse_config, analyze_loops, flatten_config, generate_ansible_yaml, generate_jinja2_template, + ConfigParseError, ) from .multi import process_directory +from .safety import TemplateSafetyError +from .output_safety import OutputPathError, ensure_safe_directory, write_text_safely def _build_arg_parser() -> argparse.ArgumentParser: @@ -53,12 +58,49 @@ def _build_arg_parser() -> argparse.ArgumentParser: ap.add_argument( "-t", "--template-output", - help="Path to write the Jinja2 config template. If omitted, template is printed to stdout.", + help="Path to write the generated config template. If omitted, template is printed to stdout.", ) return ap def _main(argv: list[str] | None = None) -> int: + try: + return _run(argv) + except TemplateSafetyError as exc: + # The output safety gate refused to emit a template because it contained + # a construct JinjaTurtle never produces -- i.e. attacker-influenced + # source text became live template code. Fail closed with a clear + # message and a non-zero exit code; never write the unsafe template. + print( + f"jinjaturtle: refusing to generate unsafe template: {exc}", file=sys.stderr + ) + return 2 + except OutputPathError as exc: + print(f"jinjaturtle: refusing unsafe output path: {exc}", file=sys.stderr) + return 2 + except DefusedXmlException as exc: + # defusedxml rejected the XML because it attempted a DTD, entity + # expansion, or external reference (XXE / billion-laughs class attack). + # This is a deliberately-blocked attack, not a benign malformed file, so + # core.parse_config lets it propagate unchanged rather than folding it + # into ConfigParseError. Report it as a refused unsafe input with a + # non-zero exit code instead of leaking an internal traceback. + print( + "jinjaturtle: refusing unsafe XML input: the document uses a DTD, " + f"entity expansion, or external reference ({exc.__class__.__name__}). " + "This is blocked to prevent XXE / entity-expansion attacks.", + file=sys.stderr, + ) + return 2 + except ConfigParseError as exc: + # The source file could not be parsed as its (detected or forced) + # format. This is expected for malformed/attacker-influenced input; + # fail cleanly with a non-zero exit code instead of a traceback. + print(f"jinjaturtle: {exc}", file=sys.stderr) + return 1 + + +def _run(argv: list[str] | None = None) -> int: defuse_stdlib() parser = _build_arg_parser() args = parser.parse_args(argv) @@ -73,25 +115,31 @@ def _main(argv: list[str] | None = None) -> int: # Write defaults if args.defaults_output: - Path(args.defaults_output).write_text(defaults_yaml, encoding="utf-8") + write_text_safely(Path(args.defaults_output), defaults_yaml) else: print("# defaults/main.yml") print(defaults_yaml, end="") + template_ext = j2.TEMPLATE_EXTENSION + # Write templates if args.template_output: out_path = Path(args.template_output) if len(outputs) == 1 and not out_path.is_dir(): - out_path.write_text(outputs[0].template, encoding="utf-8") + write_text_safely(out_path, outputs[0].template) else: - out_path.mkdir(parents=True, exist_ok=True) + ensure_safe_directory(out_path) for o in outputs: - (out_path / f"config.{o.fmt}.j2").write_text( - o.template, encoding="utf-8" + write_text_safely( + out_path / f"config.{o.fmt}.{template_ext}", o.template ) else: for o in outputs: - name = "config.j2" if len(outputs) == 1 else f"config.{o.fmt}.j2" + name = ( + f"config.{template_ext}" + if len(outputs) == 1 + else f"config.{o.fmt}.{template_ext}" + ) print(f"# {name}") print(o.template, end="") @@ -122,15 +170,15 @@ def _main(argv: list[str] | None = None) -> int: ) if args.defaults_output: - Path(args.defaults_output).write_text(ansible_yaml, encoding="utf-8") + write_text_safely(Path(args.defaults_output), ansible_yaml) else: print("# defaults/main.yml") print(ansible_yaml, end="") if args.template_output: - Path(args.template_output).write_text(template_str, encoding="utf-8") + write_text_safely(Path(args.template_output), template_str) else: - print("# config.j2") + print(f"# config.{j2.TEMPLATE_EXTENSION}") print(template_str, end="") return 0 @@ -140,4 +188,8 @@ def main() -> None: """ Console-script entry point. """ - _main(sys.argv[1:]) + sys.exit(_main(sys.argv[1:])) + + +if __name__ == "__main__": + main() diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index f854c1e..de7b552 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -8,6 +8,10 @@ import re import yaml from .loop_analyzer import LoopAnalyzer, LoopCandidate +from .safety import ( + verify_jinja2_template_safe, + verify_no_live_jinja_in_json_keys, +) from .handlers import ( BaseHandler, IniHandler, @@ -29,6 +33,22 @@ class QuotedString(str): pass +class AnsibleUnsafeString(str): + """Marker type emitted with Ansible's !unsafe YAML tag. + + Ansible recursively templates string values by default. Source-derived + config values that contain Jinja delimiters must therefore be marked + unsafe in defaults/main.yml, otherwise a harvested value such as + ``{{ lookup('pipe', 'id') }}`` becomes executable on the Ansible + controller when the generated role is applied. + """ + + pass + + +_JINJA_STARTS = ("{{", "{%", "{#") + + def _fallback_str_representer(dumper: yaml.SafeDumper, data: Any): """ Fallback for objects the dumper doesn't know about. @@ -48,7 +68,33 @@ def _quoted_str_representer(dumper: yaml.SafeDumper, data: QuotedString): return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"') +def _ansible_unsafe_str_representer(dumper: yaml.SafeDumper, data: AnsibleUnsafeString): + return dumper.represent_scalar("!unsafe", str(data), style="'") + + +def _needs_ansible_unsafe(value: str) -> bool: + return any(marker in value for marker in _JINJA_STARTS) + + +def _mark_ansible_unsafe_values(obj: Any) -> Any: + """Recursively mark mapping/list values containing Jinja as !unsafe. + + Mapping keys are intentionally left alone: they are variable names or YAML + structure, not Ansible-templated values. Values nested in folder-mode item + lists, including source-derived ``id`` values, are protected. + """ + + if isinstance(obj, dict): + return {k: _mark_ansible_unsafe_values(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_mark_ansible_unsafe_values(v) for v in obj] + if isinstance(obj, str) and _needs_ansible_unsafe(obj): + return AnsibleUnsafeString(obj) + return obj + + _TurtleDumper.add_representer(QuotedString, _quoted_str_representer) +_TurtleDumper.add_representer(AnsibleUnsafeString, _ansible_unsafe_str_representer) # Use our fallback for any unknown object types _TurtleDumper.add_representer(None, _fallback_str_representer) @@ -80,8 +126,9 @@ def dump_yaml(data: Any, *, sort_keys: bool = True) -> str: This is used by both the single-file and multi-file code paths. """ + safe_data = _mark_ansible_unsafe_values(data) return yaml.dump( - data, + safe_data, Dumper=_TurtleDumper, sort_keys=sort_keys, default_flow_style=False, @@ -258,6 +305,66 @@ def detect_format(path: Path, explicit: str | None = None) -> str: return "ini" +class ConfigParseError(Exception): + """Raised when a source config file cannot be parsed as its format. + + Each underlying parser (json, tomllib, PyYAML, defusedxml/ElementTree, + configparser) raises its own exception type on malformed input. Without a + single normalised error, a malformed file -- which is entirely expected when + JinjaTurtle is pointed at harvested, attacker-influenceable config -- would + escape as an unhandled traceback (e.g. ``xml.etree.ElementTree.ParseError`` + on an XML file whose element name is not well-formed). ``parse_config`` + converts every such failure into this one type so the CLI can fail closed + with a clean message and a non-zero exit code, and so library callers (such + as Enroll, which falls back to copying the raw file) have a single, stable + exception to catch. + + Note: defusedxml's *security* exceptions (``EntitiesForbidden``, + ``DTDForbidden``, ...) are intentionally NOT folded into this type. They + signal an attempted XXE/entity-expansion attack rather than a benign + malformed file, and must propagate unchanged so callers can tell the two + apart. + """ + + +def _build_malformed_config_errors() -> tuple[type[BaseException], ...]: + """Return the concrete "this file is malformed" exception types to catch. + + Deliberately specific. In particular we must avoid catching plain + ``ValueError``: defusedxml's ``EntitiesForbidden``/``DTDForbidden`` subclass + ``ValueError``, and those are security signals that must NOT be swallowed. + """ + + import configparser + import json + from xml.etree.ElementTree import ParseError as _XMLParseError # nosec + + import yaml as _yaml + + errs: list[type[BaseException]] = [ + _XMLParseError, + json.JSONDecodeError, + configparser.Error, + _yaml.YAMLError, + UnicodeDecodeError, + ] + try: + import tomllib + + errs.append(tomllib.TOMLDecodeError) + except ModuleNotFoundError: # pragma: no cover - Python < 3.11 fallback + try: + import tomli # type: ignore + + errs.append(tomli.TOMLDecodeError) + except ModuleNotFoundError: + pass + return tuple(errs) + + +_MALFORMED_CONFIG_ERRORS = _build_malformed_config_errors() + + def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: """ Parse config file into a Python object. @@ -266,9 +373,38 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: handler = _HANDLERS.get(fmt) if handler is None: raise ValueError(f"Unsupported config format: {fmt}") - parsed = handler.parse(path) - # Make sure datetime objects are treated as strings (TOML, YAML) - parsed = _stringify_timestamps(parsed) + try: + parsed = handler.parse(path) + # Make sure datetime objects are treated as strings (TOML, YAML). This + # walks the parsed object recursively, so keep it inside the try where a + # RecursionError from a pathological structure is normalised below. + parsed = _stringify_timestamps(parsed) + except ConfigParseError: + raise + except _MALFORMED_CONFIG_ERRORS as exc: + # Normalise the per-parser "this file is malformed" errors into one + # json.JSONDecodeError / tomllib.TOMLDecodeError (ValueError + # subclasses), PyYAML's YAMLError, configparser.Error, and + # xml.etree.ElementTree.ParseError (raised by defusedxml on XML whose + # structure/element name is not well-formed). A bad input file is + # expected when parsing harvested config, so fail closed with a clean + # error instead of an unhandled traceback. + # + # IMPORTANT: this deliberately does NOT catch defusedxml's security + # exceptions (EntitiesForbidden, DTDForbidden, ...). Those signal an + # attempted XXE/entity-expansion attack and must propagate unchanged so + # callers (and tests) can distinguish "malformed" from "malicious". + raise ConfigParseError(f"could not parse {path} as {fmt}: {exc}") from exc + except RecursionError as exc: + # A deeply-nested or self-referential structure (e.g. a recursive YAML + # anchor) can exhaust the Python stack while walking the parsed object. + # The YAML handler already rejects reference cycles up front; this is a + # format-agnostic backstop so any such input fails closed with a clean + # message instead of a stack-overflow traceback. + raise ConfigParseError( + f"could not parse {path} as {fmt}: input is too deeply nested " + "or self-referential" + ) from exc return fmt, parsed @@ -276,7 +412,15 @@ def parse_config(path: Path, fmt: str | None = None) -> tuple[str, Any]: def analyze_loops(fmt: str, parsed: Any) -> list[LoopCandidate]: """ Analyze parsed config to find loop opportunities. + + JSON files are intentionally kept scalar/index-based instead of being + collapsed into generated loops. JSON is commonly checked byte-for-byte by + configuration management tools, and preserving inline arrays/objects is + more valuable than reducing variable count. """ + if fmt == "json": + return [] + analyzer = LoopAnalyzer() candidates = analyzer.analyze(parsed, fmt) @@ -369,14 +513,28 @@ def generate_jinja2_template( # Check if handler supports loop-aware generation if hasattr(handler, "generate_jinja2_template_with_loops") and loop_candidates: - return handler.generate_jinja2_template_with_loops( + template = handler.generate_jinja2_template_with_loops( parsed, role_prefix, original_text, loop_candidates ) + else: + # Fallback to original scalar-only generation + template = handler.generate_jinja2_template( + parsed, role_prefix, original_text=original_text + ) - # Fallback to original scalar-only generation - return handler.generate_jinja2_template( - parsed, role_prefix, original_text=original_text - ) + # Defence in depth: independently verify that the finished template contains + # only JinjaTurtle-emitted constructs. If any handler failed to neutralise + # verbatim source text, the un-escaped payload shows up here as a live tag + # and generation aborts instead of emitting an injectable template. + verify_jinja2_template_safe(template) + + # Format-specific backstop: JinjaTurtle never emits Jinja inside a JSON object + # key, so a live construct in key position means source key text leaked into + # the template unescaped. This is independent of per-handler escaping. + if fmt == "json": + verify_no_live_jinja_in_json_keys(template) + + return template def _stringify_timestamps(obj: Any) -> Any: diff --git a/src/jinjaturtle/escape.py b/src/jinjaturtle/escape.py new file mode 100644 index 0000000..e55f416 --- /dev/null +++ b/src/jinjaturtle/escape.py @@ -0,0 +1,98 @@ +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 Ansible 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. +""" + +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 and with any +# whitespace-control marker on either side. Jinja2 accepts "-", "+", or no +# marker adjacent to the "%}"/"{%" of a block tag (e.g. "{%endraw%}", +# "{% endraw %}", "{%- endraw -%}", "{%+ endraw +%}"), and ALL of these close +# a raw block. The control marker must be matched so a "{%+ endraw %}" in +# attacker-influenced source text cannot survive defanging and break out of our +# {% raw %} wrapper. [-+]? appears on both sides accordingly. +_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 _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 %}" diff --git a/src/jinjaturtle/handlers/base.py b/src/jinjaturtle/handlers/base.py index 14aaec7..6be91f0 100644 --- a/src/jinjaturtle/handlers/base.py +++ b/src/jinjaturtle/handlers/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path from typing import Any, Iterable @@ -57,8 +58,20 @@ class BaseHandler: role_prefix_section_subsection_key Sanitises parts to lowercase [a-z0-9_] and strips extras. + + Consecutive separators are collapsed to a single underscore. This is + required for correctness, not just aesthetics: a source key such as + ``log..level`` or ``cache--size`` would otherwise sanitise to a name + containing a double underscore (``log__level``). The output safety gate + in ``safety.py`` deliberately rejects *any* ``__`` in a generated + identifier because ``__`` is the gateway to every Jinja2 SSTI gadget + (``__class__``/``__globals__``/...). Emitting a dunder here would make + JinjaTurtle's own gate reject JinjaTurtle's own placeholder, aborting + generation on entirely benign config. Collapsing runs keeps every + generated name a plain single-underscore-delimited identifier that the + gate accepts. """ - role_prefix = role_prefix.strip().lower() + role_prefix = re.sub(r"_+", "_", role_prefix.strip().lower()) clean_parts: list[str] = [] for part in path: @@ -70,7 +83,9 @@ class BaseHandler: cleaned_chars.append(c.lower()) else: cleaned_chars.append("_") - cleaned_part = "".join(cleaned_chars).strip("_") + # Collapse runs of underscores (from adjacent separators) to a + # single "_" so the result can never contain a forbidden "__". + cleaned_part = re.sub(r"_+", "_", "".join(cleaned_chars)).strip("_") if cleaned_part: clean_parts.append(cleaned_part) diff --git a/src/jinjaturtle/handlers/dict.py b/src/jinjaturtle/handlers/dict.py index eb8d926..2d5ef6b 100644 --- a/src/jinjaturtle/handlers/dict.py +++ b/src/jinjaturtle/handlers/dict.py @@ -19,9 +19,15 @@ class DictLikeHandler(BaseHandler): def _walk(obj: Any, path: tuple[str, ...] = ()) -> None: if isinstance(obj, dict): + if not obj: + items.append((path, obj)) + return for k, v in obj.items(): _walk(v, path + (str(k),)) elif isinstance(obj, list) and self.flatten_lists: + if not obj: + items.append((path, obj)) + return for i, v in enumerate(obj): _walk(v, path + (str(i),)) else: diff --git a/src/jinjaturtle/handlers/ini.py b/src/jinjaturtle/handlers/ini.py index ad92b72..9f50839 100644 --- a/src/jinjaturtle/handlers/ini.py +++ b/src/jinjaturtle/handlers/ini.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Any from . import BaseHandler +from .. import j2 +from ..escape import escape_jinja_literal class IniHandler(BaseHandler): @@ -63,9 +65,9 @@ class IniHandler(BaseHandler): var_name = self.make_var_name(role_prefix, path) value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: - lines.append(f'{key} = "{{{{ {var_name} }}}}"') + lines.append(f"{key} = {j2.quoted_variable(var_name)}") else: - lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append(f"{key} = {j2.variable(var_name)}") lines.append("") return "\n".join(lines).rstrip() + "\n" @@ -83,16 +85,18 @@ class IniHandler(BaseHandler): line = raw_line stripped = line.lstrip() - # Blank or pure comment: keep as-is + # Blank or pure comment: keep formatting, but neutralise any + # template metacharacters so attacker-controlled comment text cannot + # become live template code in the output. if not stripped or stripped[0] in {"#", ";"}: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(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(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Work without newline so we can re-attach it exactly @@ -107,8 +111,9 @@ class IniHandler(BaseHandler): eq_index = content.find("=") if eq_index == -1: - # Not a simple key=value line: leave untouched - out_lines.append(raw_line) + # Not a simple key=value line: leave content intact but escape + # any template metacharacters. + out_lines.append(escape_jinja_literal(raw_line)) continue before_eq = content[:eq_index] @@ -116,7 +121,7 @@ class IniHandler(BaseHandler): key = before_eq.strip() if not key: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Whitespace after '=' @@ -141,12 +146,20 @@ class IniHandler(BaseHandler): if use_quotes: quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" + replacement_value = j2.quoted_variable(var_name, quote_char) else: - replacement_value = f"{{{{ {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 = ( - before_eq + "=" + leading_ws + replacement_value + comment_part + escape_jinja_literal(before_eq) + + "=" + + leading_ws + + replacement_value + + escape_jinja_literal(comment_part) ) out_lines.append(new_content + newline) diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py index a0b0017..41699c9 100644 --- a/src/jinjaturtle/handlers/json.py +++ b/src/jinjaturtle/handlers/json.py @@ -6,7 +6,9 @@ from pathlib import Path from typing import Any from . import DictLikeHandler -from ..loop_analyzer import LoopCandidate +from .. import j2 +from ..escape import escape_jinja_literal +from ..loop_analyzer import LoopCandidate, is_safe_loop_field_key class JsonHandler(DictLikeHandler): @@ -23,13 +25,15 @@ class JsonHandler(DictLikeHandler): role_prefix: str, original_text: str | None = None, ) -> str: - """Original scalar-only template generation.""" + """Generate a scalar JSON template while preserving source formatting.""" if not isinstance(parsed, (dict, list)): raise TypeError("JSON parser result must be a dict or list") - # As before: ignore original_text and rebuild structurally + if original_text is not None: + return self._generate_json_template_from_text(role_prefix, original_text) return self._generate_json_template(role_prefix, parsed) JSON_INDENT = 2 + JSON_VALUE_FILTER = j2.JSON_VALUE_FILTER def _leading_indent(self, s: str, idx: int) -> int: """Return the number of leading spaces on the line containing idx.""" @@ -75,6 +79,137 @@ class JsonHandler(DictLikeHandler): role_prefix, parsed, loop_paths, loop_candidates ) + def _json_value_expr(self, var_name: str) -> str: + """Return a Jinja expression for a JSON value. + + Jinja's built-in ``tojson`` filter is HTML-safe and therefore escapes + characters such as ``<`` and ``>`` as ``\u003c``/``\u003e``. That is + useful in HTML, but noisy in configuration files. JinjaTurtle generates + Ansible templates, so use Ansible's ``to_json`` filter instead. + """ + return j2.filtered(var_name, self.JSON_VALUE_FILTER) + + def _generate_json_template_from_text(self, role_prefix: str, text: str) -> str: + """Replace JSON scalar values in-place, preserving original formatting. + + The older JSON path parsed the file and wrote it back with + ``json.dumps(indent=2)``, which caused cosmetic diffs such as changing + four-space indentation to two-space indentation and adding a final + newline to files that intentionally lacked one. This scanner walks the + original JSON source and only replaces scalar value tokens with Jinja2 + expressions; all whitespace, object/list indentation, key ordering, and + final newline state are left untouched. + """ + spans = self._collect_json_scalar_spans(text) + if spans is None: + # Should be rare because the caller has already parsed the JSON, but + # keep the structural fallback rather than failing template creation. + parsed = json.loads(text) + return self._generate_json_template(role_prefix, parsed) + + chunks: list[str] = [] + pos = 0 + for path, start, end in spans: + # Text between scalar values (object keys, structural punctuation, + # whitespace, and any comment-like trailing text) is copied verbatim + # from the source file. Like every other text-emitting handler, this + # verbatim text must be neutralised: if it contains Jinja2 markup it + # would otherwise become live template code at apply time. The value + # itself is replaced with a safe placeholder below. ``escape_jinja_literal`` + # is a no-op on text without Jinja markers, so benign JSON is unchanged + # byte-for-byte and a later render reproduces the original characters. + chunks.append(escape_jinja_literal(text[pos:start])) + chunks.append(self._json_value_expr(self.make_var_name(role_prefix, path))) + pos = end + chunks.append(escape_jinja_literal(text[pos:])) + return "".join(chunks) + + def _collect_json_scalar_spans( + self, text: str + ) -> list[tuple[tuple[str, ...], int, int]] | None: + """Return source spans for JSON scalar *values*. + + Keys are parsed to determine the current path but are not returned. + """ + decoder = json.JSONDecoder() + spans: list[tuple[tuple[str, ...], int, int]] = [] + + def skip_ws(i: int) -> int: + while i < len(text) and text[i] in " \t\r\n": + i += 1 + return i + + def raw_decode_at(i: int) -> tuple[Any, int]: + return decoder.raw_decode(text, i) + + def parse_value(i: int, path: tuple[str, ...]) -> int: + i = skip_ws(i) + if i >= len(text): + raise ValueError("unexpected end of JSON") + ch = text[i] + if ch == "{": + return parse_object(i, path) + if ch == "[": + return parse_array(i, path) + + _value, end = raw_decode_at(i) + spans.append((path, i, end)) + return end + + def parse_object(i: int, path: tuple[str, ...]) -> int: + i += 1 # { + i = skip_ws(i) + if i < len(text) and text[i] == "}": + return i + 1 + + while True: + i = skip_ws(i) + if i >= len(text) or text[i] != '"': + raise ValueError("expected JSON object key") + key, i = raw_decode_at(i) + if not isinstance(key, str): + raise ValueError("expected JSON object key string") + + i = skip_ws(i) + if i >= len(text) or text[i] != ":": + raise ValueError("expected ':' after JSON object key") + i = parse_value(i + 1, path + (key,)) + i = skip_ws(i) + + if i < len(text) and text[i] == ",": + i += 1 + continue + if i < len(text) and text[i] == "}": + return i + 1 + raise ValueError("expected ',' or '}' in JSON object") + + def parse_array(i: int, path: tuple[str, ...]) -> int: + i += 1 # [ + i = skip_ws(i) + if i < len(text) and text[i] == "]": + return i + 1 + + index = 0 + while True: + i = parse_value(i, path + (str(index),)) + index += 1 + i = skip_ws(i) + + if i < len(text) and text[i] == ",": + i += 1 + continue + if i < len(text) and text[i] == "]": + return i + 1 + raise ValueError("expected ',' or ']' in JSON array") + + try: + end = parse_value(0, ()) + if skip_ws(end) != len(text): + return None + return spans + except (json.JSONDecodeError, ValueError, TypeError): + return None + def _generate_json_template(self, role_prefix: str, data: Any) -> str: """ Generate a JSON Jinja2 template from parsed JSON data. @@ -82,25 +217,32 @@ class JsonHandler(DictLikeHandler): All scalar values are replaced with Jinja expressions whose names are derived from the path, similar to TOML/YAML. - Uses | tojson filter to preserve types (numbers, booleans, null). + Uses | to_json filter to preserve types (numbers, booleans, null). """ def _walk(obj: Any, path: tuple[str, ...] = ()) -> Any: if isinstance(obj, dict): - return {k: _walk(v, path + (str(k),)) for k, v in obj.items()} + # Keys are emitted verbatim into the template, so neutralise any + # Jinja markup in them (see _generate_json_template_from_text). + return { + escape_jinja_literal(str(k)): _walk(v, path + (str(k),)) + for k, v in obj.items() + } if isinstance(obj, list): return [_walk(v, path + (str(i),)) for i, v in enumerate(obj)] - # scalar - use marker that will be replaced with tojson + # scalar - use marker that will be replaced with to_json var_name = self.make_var_name(role_prefix, path) return f"__SCALAR__{var_name}__" templated = _walk(data) json_str = json.dumps(templated, indent=2, ensure_ascii=False) - # Replace scalar markers with Jinja expressions using tojson filter + # Replace scalar markers with Jinja expressions using to_json filter # This preserves types (numbers stay numbers, booleans stay booleans) json_str = re.sub( - r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', r"{{ \1 | tojson }}", json_str + r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', + lambda m: self._json_value_expr(m.group(1)), + json_str, ) return json_str + "\n" @@ -133,7 +275,12 @@ class JsonHandler(DictLikeHandler): return f"__LOOP_DICT__{collection_var}__{item_var}__" if isinstance(obj, dict): - return {k: _walk(v, current_path + (str(k),)) for k, v in obj.items()} + # Keys are emitted verbatim into the template, so neutralise any + # Jinja markup in them (see _generate_json_template_from_text). + return { + escape_jinja_literal(str(k)): _walk(v, current_path + (str(k),)) + for k, v in obj.items() + } if isinstance(obj, list): # Check if this list is a loop candidate if current_path in loop_paths: @@ -150,9 +297,11 @@ class JsonHandler(DictLikeHandler): # Convert to JSON string json_str = json.dumps(templated, indent=2, ensure_ascii=False) - # Replace scalar markers with Jinja expressions using tojson filter + # Replace scalar markers with Jinja expressions using to_json filter json_str = re.sub( - r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', r"{{ \1 | tojson }}", json_str + r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', + lambda m: self._json_value_expr(m.group(1)), + json_str, ) # Post-process to replace loop markers with actual Jinja loops (indent-aware) @@ -201,9 +350,10 @@ class JsonHandler(DictLikeHandler): # a blank line between iterations under default Jinja whitespace settings. return ( f"[\n" - f"{{% for {item_var} in {collection_var} %}}{inner}{{{{ {item_var} | tojson }}}}" - f"{{% if not loop.last %}},{{% endif %}}\n" - f"{{% endfor %}}{base}]" + f"{j2.for_start(item_var, collection_var)}" + f"{inner}{j2.to_json(item_var)}" + f"{j2.if_not_loop_last()},{j2.endif()}\n" + f"{j2.for_end()}{base}]" ) def _generate_json_dict_loop( @@ -233,16 +383,29 @@ class JsonHandler(DictLikeHandler): ] # first line has no indent; we prepend `inner` when emitting for i, key in enumerate(keys): comma = "," if i < len(keys) - 1 else "" + # Defence in depth: never interpolate a raw source key into an + # ``item_var.key`` reference. A key such as ``a }}{{ x`` would break + # out of the value placeholder and inject a live construct that the + # output-safety gate cannot distinguish from a legitimate variable. + if not is_safe_loop_field_key(key): + raise ValueError( + f"refusing to emit loop-item field reference for unsafe key: {key!r}" + ) + # The literal key text is emitted verbatim into the template; escape any + # Jinja markup in it. The value side ({item_var}.{key}) is constrained by + # the output safety gate's dotted-name allowlist, which fails closed on + # anything that is not a plain identifier path. dict_lines.append( - f'{field}"{key}": {{{{ {item_var}.{key} | tojson }}}}{comma}' + f'{field}"{escape_jinja_literal(str(key))}": ' + f"{j2.to_json(f'{item_var}.{key}')}{comma}" ) # Comma between *items* goes after the closing brace. - dict_lines.append(f"{inner}}}{{% if not loop.last %}},{{% endif %}}") + dict_lines.append(f"{inner}}}{j2.if_not_loop_last()},{j2.endif()}") dict_body = "\n".join(dict_lines) # Put the `{% for %}` at the start of the first item line to avoid blank lines. return ( f"[\n" - f"{{% for {item_var} in {collection_var} %}}{inner}{dict_body}\n" - f"{{% endfor %}}{base}]" + f"{j2.for_start(item_var, collection_var)}{inner}{dict_body}\n" + f"{j2.for_end()}{base}]" ) diff --git a/src/jinjaturtle/handlers/postfix.py b/src/jinjaturtle/handlers/postfix.py index 65f6be9..f6a1974 100644 --- a/src/jinjaturtle/handlers/postfix.py +++ b/src/jinjaturtle/handlers/postfix.py @@ -4,6 +4,8 @@ from pathlib import Path from typing import Any from . import BaseHandler +from .. import j2 +from ..escape import escape_jinja_literal class PostfixMainHandler(BaseHandler): @@ -92,7 +94,7 @@ class PostfixMainHandler(BaseHandler): lines: list[str] = [] for k, v in parsed.items(): var = self.make_var_name(role_prefix, (k,)) - lines.append(f"{k} = {{{{ {var} }}}}") + lines.append(f"{k} = {j2.variable(var)}") return "\n".join(lines).rstrip() + "\n" return self._generate_from_text(role_prefix, original_text) @@ -107,16 +109,16 @@ class PostfixMainHandler(BaseHandler): stripped = content.strip() if not stripped: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) i += 1 continue if stripped.startswith("#"): - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) i += 1 continue if "=" not in content: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) i += 1 continue @@ -126,7 +128,7 @@ class PostfixMainHandler(BaseHandler): key = before_eq.strip() if not key: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) i += 1 continue @@ -161,14 +163,21 @@ 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'{before_eq}={leading_ws}"{{{{ {var} }}}}"{comment_part}{newline}' + f"{safe_before}={leading_ws}{j2.quoted_variable(var)}" + f"{safe_comment}{newline}" ) else: replacement = ( - f"{before_eq}={leading_ws}{{{{ {var} }}}}{comment_part}{newline}" + f"{safe_before}={leading_ws}{j2.variable(var)}" + f"{safe_comment}{newline}" ) out_lines.append(replacement) diff --git a/src/jinjaturtle/handlers/ssh.py b/src/jinjaturtle/handlers/ssh.py index bedaa2a..f588a33 100644 --- a/src/jinjaturtle/handlers/ssh.py +++ b/src/jinjaturtle/handlers/ssh.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import Any from . import BaseHandler +from .. import j2 +from ..escape import escape_jinja_literal _SECTION_KEYWORDS = {"host", "match"} @@ -254,24 +256,27 @@ class SshConfigHandler(BaseHandler): out_lines: list[str] = [] for ln in parsed.lines: if ln.kind != "kv": - out_lines.append(ln.raw) + out_lines.append(escape_jinja_literal(ln.raw)) continue path = self._path_for_line(ln) if not path: - out_lines.append(ln.raw) + out_lines.append(escape_jinja_literal(ln.raw)) continue var = self.make_var_name(role_prefix, path) if ln.quoted and ln.value: quote_char = ln.value[0] - replacement_value = f"{quote_char}{{{{ {var} }}}}{quote_char}" + replacement_value = j2.quoted_variable(var, quote_char) else: - replacement_value = f"{{{{ {var} }}}}" + replacement_value = j2.variable(var) + # ``before_value`` (keyword + spacing) and ``comment`` are + # source-derived; escape around the safe placeholder. rendered = ( - f"{ln.before_value}{replacement_value}" - f"{ln.whitespace_before_comment}{ln.comment}{ln.newline}" + f"{escape_jinja_literal(ln.before_value)}{replacement_value}" + f"{ln.whitespace_before_comment}" + f"{escape_jinja_literal(ln.comment)}{ln.newline}" ) out_lines.append(rendered) diff --git a/src/jinjaturtle/handlers/systemd.py b/src/jinjaturtle/handlers/systemd.py index 044fd86..d28a8c5 100644 --- a/src/jinjaturtle/handlers/systemd.py +++ b/src/jinjaturtle/handlers/systemd.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Any from . import BaseHandler +from .. import j2 +from ..escape import escape_jinja_literal @dataclass @@ -156,7 +158,13 @@ class SystemdUnitHandler(BaseHandler): out_lines: list[str] = [] for ln in parsed.lines: if ln.kind != "kv" or not ln.section or not ln.key: - out_lines.append(ln.raw) + # 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)) continue path: tuple[str, ...] = (ln.section, ln.key) @@ -165,11 +173,19 @@ 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'{ln.before_eq}={ln.leading_ws_after_eq}"{{{{ {var} }}}}"{ln.comment}' + repl = ( + f"{safe_before}={ln.leading_ws_after_eq}" + f"{j2.quoted_variable(var)}{safe_comment}" + ) else: - repl = f"{ln.before_eq}={ln.leading_ws_after_eq}{{{{ {var} }}}}{ln.comment}" + repl = ( + f"{safe_before}={ln.leading_ws_after_eq}" + f"{j2.variable(var)}{safe_comment}" + ) newline = "\n" if ln.raw.endswith("\n") else "" out_lines.append(repl + newline) diff --git a/src/jinjaturtle/handlers/toml.py b/src/jinjaturtle/handlers/toml.py index fe071bd..b2aa6e5 100644 --- a/src/jinjaturtle/handlers/toml.py +++ b/src/jinjaturtle/handlers/toml.py @@ -4,7 +4,9 @@ from pathlib import Path from typing import Any from . import DictLikeHandler -from ..loop_analyzer import LoopCandidate +from .. import j2 +from ..escape import escape_jinja_literal +from ..loop_analyzer import LoopCandidate, is_safe_loop_field_key try: import tomllib @@ -16,6 +18,14 @@ class TomlHandler(DictLikeHandler): fmt = "toml" flatten_lists = False # keep lists as scalars + def _toml_value_expr(self, var_name: str, value: Any | None = None) -> str: + if isinstance(value, bool): + return j2.lower(var_name) + return j2.variable(var_name) + + def _toml_quoted_expr(self, var_name: str, quote: str = '"') -> str: + return j2.quoted_variable(var_name, quote) + def parse(self, path: Path) -> Any: if tomllib is None: raise RuntimeError( @@ -68,19 +78,25 @@ 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'{key} = "{{{{ {var_name} }}}}"') + lines.append( + f"{escape_jinja_literal(str(key))} = {self._toml_quoted_expr(var_name)}" + ) elif isinstance(value, bool): # Booleans need | lower filter (Python True/False → TOML true/false) - lines.append(f"{key} = {{{{ {var_name} | lower }}}}") + lines.append( + f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}" + ) else: - lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append( + f"{escape_jinja_literal(str(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(path) + header = ".".join(escape_jinja_literal(str(p)) for p in path) lines.append(f"[{header}]") for key, val in scalar_items.items(): @@ -121,10 +137,14 @@ 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'{key} = "{{{{ {var_name} }}}}"') + lines.append( + f"{escape_jinja_literal(str(key))} = {self._toml_quoted_expr(var_name)}" + ) elif isinstance(value, bool): # Booleans need | lower filter (Python True/False → TOML true/false) - lines.append(f"{key} = {{{{ {var_name} | lower }}}}") + lines.append( + f"{escape_jinja_literal(str(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: @@ -139,28 +159,35 @@ class TomlHandler(DictLikeHandler): # Scalar list loop lines.append( f"{key} = [" - f"{{% for {item_var} in {collection_var} %}}" - f"{{{{ {item_var} }}}}" - f"{{% if not loop.last %}}, {{% endif %}}" - f"{{% endfor %}}" + f"{j2.for_start(item_var, collection_var)}" + f"{j2.variable(item_var)}" + f"{j2.if_not_loop_last()}, {j2.endif()}" + f"{j2.for_end()}" f"]" ) 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"{key} = {{{{ {var_name} | tojson }}}}") + lines.append( + f"{escape_jinja_literal(str(key))} = " + f"{j2.to_json(var_name)}" + ) else: # Not a loop, treat as regular variable - lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append( + f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}" + ) else: - lines.append(f"{key} = {{{{ {var_name} }}}}") + lines.append( + f"{escape_jinja_literal(str(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(path) + header = ".".join(escape_jinja_literal(str(p)) for p in path) lines.append(f"[{header}]") for key, val in scalar_items.items(): @@ -208,7 +235,7 @@ class TomlHandler(DictLikeHandler): # Blank or pure comment if not stripped or stripped.startswith("#"): - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Table header: [server] or [server.tls] or [[array.of.tables]] @@ -221,7 +248,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(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Try key = value @@ -236,7 +263,7 @@ class TomlHandler(DictLikeHandler): eq_index = content.find("=") if eq_index == -1: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue before_eq = content[:eq_index] @@ -244,7 +271,7 @@ class TomlHandler(DictLikeHandler): key = before_eq.strip() if not key: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Whitespace after '=' @@ -279,16 +306,24 @@ class TomlHandler(DictLikeHandler): nested_path = path + (sub_key,) nested_var = self.make_var_name(role_prefix, nested_path) if isinstance(sub_val, str): - inner_bits.append(f'{sub_key} = "{{{{ {nested_var} }}}}"') + inner_bits.append( + f"{escape_jinja_literal(str(sub_key))} = {self._toml_quoted_expr(nested_var)}" + ) elif isinstance(sub_val, bool): inner_bits.append( - f"{sub_key} = {{{{ {nested_var} | lower }}}}" + f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}" ) else: - inner_bits.append(f"{sub_key} = {{{ {nested_var} }}}") + inner_bits.append( + f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}" + ) replacement_value = "{ " + ", ".join(inner_bits) + " }" new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part + escape_jinja_literal(before_eq) + + "=" + + leading_ws + + replacement_value + + escape_jinja_literal(comment_part) ) out_lines.append(new_content + newline) continue @@ -307,14 +342,18 @@ class TomlHandler(DictLikeHandler): if use_quotes: quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" + replacement_value = self._toml_quoted_expr(var_name, quote_char) elif is_bool: - replacement_value = f"{{{{ {var_name} | lower }}}}" + replacement_value = j2.lower(var_name) else: - replacement_value = f"{{{{ {var_name} }}}}" + replacement_value = j2.variable(var_name) new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part + escape_jinja_literal(before_eq) + + "=" + + leading_ws + + replacement_value + + escape_jinja_literal(comment_part) ) out_lines.append(new_content + newline) @@ -342,7 +381,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(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Table header: [server] or [server.tls] or [[array.of.tables]] @@ -389,24 +428,45 @@ class TomlHandler(DictLikeHandler): # Build loop out_lines.append( - f"{{% for {item_var} in {collection_var} %}}\n" + 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(): if key == "_key": continue + # Defence in depth: the loop analyzer refuses a + # dict-loop whose items contain a non-identifier + # key, so ``key`` is always a plain identifier + # here. Never interpolate a raw key into an + # ``item_var.key`` reference: a key such as + # ``a }}{{ x`` would break out of the placeholder + # and inject a live construct. + if not is_safe_loop_field_key(key): + raise ValueError( + "refusing to emit loop-item field " + f"reference for unsafe key: {key!r}" + ) if isinstance(value, str): out_lines.append( - f'{key} = "{{{{ {item_var}.{key} }}}}"\n' + f"{escape_jinja_literal(str(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"{self._toml_value_expr(f'{item_var}.{key}', value)}\n" ) else: out_lines.append( - f"{key} = {{{{ {item_var}.{key} }}}}\n" + f"{escape_jinja_literal(str(key))} = " + f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n" ) - out_lines.append("{% endfor %}\n") + out_lines.append(f"{j2.for_end()}\n") # Skip all content until the next different table skip_until_next_table = True @@ -417,7 +477,7 @@ class TomlHandler(DictLikeHandler): skip_until_next_table = False current_table = table_path - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # If we're inside a skipped array-of-tables section, skip this line @@ -436,7 +496,7 @@ class TomlHandler(DictLikeHandler): eq_index = content.find("=") if eq_index == -1: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue before_eq = content[:eq_index] @@ -444,7 +504,7 @@ class TomlHandler(DictLikeHandler): key = before_eq.strip() if not key: - out_lines.append(raw_line) + out_lines.append(escape_jinja_literal(raw_line)) continue # Whitespace after '=' @@ -470,18 +530,22 @@ class TomlHandler(DictLikeHandler): # Scalar list loop replacement_value = ( f"[" - f"{{% for {item_var} in {collection_var} %}}" - f"{{{{ {item_var} }}}}" - f"{{% if not loop.last %}}, {{% endif %}}" - f"{{% endfor %}}" + f"{j2.for_start(item_var, collection_var)}" + f"{j2.variable(item_var)}" + f"{j2.if_not_loop_last()}, {j2.endif()}" + f"{j2.for_end()}" f"]" ) else: - # Dict/nested loop - use tojson filter for complex arrays - replacement_value = f"{{{{ {collection_var} | tojson }}}}" + # Dict/nested loop - use to_json filter for complex arrays + replacement_value = j2.to_json(collection_var) new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part + escape_jinja_literal(before_eq) + + "=" + + leading_ws + + replacement_value + + escape_jinja_literal(comment_part) ) out_lines.append(new_content + newline) continue @@ -505,16 +569,24 @@ class TomlHandler(DictLikeHandler): nested_path = path + (sub_key,) nested_var = self.make_var_name(role_prefix, nested_path) if isinstance(sub_val, str): - inner_bits.append(f'{sub_key} = "{{{{ {nested_var} }}}}"') + inner_bits.append( + f"{escape_jinja_literal(str(sub_key))} = {self._toml_quoted_expr(nested_var)}" + ) elif isinstance(sub_val, bool): inner_bits.append( - f"{sub_key} = {{{{ {nested_var} | lower }}}}" + f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}" ) else: - inner_bits.append(f"{sub_key} = {{{{ {nested_var} }}}}") + inner_bits.append( + f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}" + ) replacement_value = "{ " + ", ".join(inner_bits) + " }" new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part + escape_jinja_literal(before_eq) + + "=" + + leading_ws + + replacement_value + + escape_jinja_literal(comment_part) ) out_lines.append(new_content + newline) continue @@ -533,14 +605,18 @@ class TomlHandler(DictLikeHandler): if use_quotes: quote_char = raw_value[0] - replacement_value = f"{quote_char}{{{{ {var_name} }}}}{quote_char}" + replacement_value = self._toml_quoted_expr(var_name, quote_char) elif is_bool: - replacement_value = f"{{{{ {var_name} | lower }}}}" + replacement_value = j2.lower(var_name) else: - replacement_value = f"{{{{ {var_name} }}}}" + replacement_value = j2.variable(var_name) new_content = ( - before_eq + "=" + leading_ws + replacement_value + comment_part + escape_jinja_literal(before_eq) + + "=" + + leading_ws + + replacement_value + + escape_jinja_literal(comment_part) ) out_lines.append(new_content + newline) diff --git a/src/jinjaturtle/handlers/xml.py b/src/jinjaturtle/handlers/xml.py index fed6aba..56e3fd7 100644 --- a/src/jinjaturtle/handlers/xml.py +++ b/src/jinjaturtle/handlers/xml.py @@ -3,9 +3,12 @@ from __future__ import annotations from collections import Counter, defaultdict from pathlib import Path from typing import Any -import xml.etree.ElementTree as ET # nosec +import xml.etree.ElementTree as ET # nosec B405 - safe trees only; parsing uses defusedxml +import defusedxml.ElementTree as DET from .base import BaseHandler +from .. import j2 +from ..escape import escape_jinja_literal from ..loop_analyzer import LoopCandidate @@ -18,11 +21,11 @@ class XmlHandler(BaseHandler): def parse(self, path: Path) -> ET.Element: text = path.read_text(encoding="utf-8") - parser = ET.XMLParser( - target=ET.TreeBuilder(insert_comments=False) - ) # nosec B314 - parser.feed(text) - root = parser.close() + # Security must live in the handler, not only in the CLI entry point: + # callers may import JinjaTurtle as a library and invoke parse_config() + # directly. defusedxml rejects DTD/entity abuse and also discards + # comments by default, matching the previous TreeBuilder behaviour. + root = DET.fromstring(text) return root def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: @@ -172,7 +175,7 @@ class XmlHandler(BaseHandler): for attr_name in list(elem.attrib.keys()): attr_path = path + (f"@{attr_name}",) var_name = self.make_var_name(role_prefix, attr_path) - elem.set(attr_name, f"{{{{ {var_name} }}}}") + elem.set(attr_name, j2.variable(var_name)) # Children children = [c for c in list(elem) if isinstance(c.tag, str)] @@ -185,7 +188,7 @@ class XmlHandler(BaseHandler): else: text_path = path + ("value",) var_name = self.make_var_name(role_prefix, text_path) - elem.text = f"{{{{ {var_name} }}}}" + elem.text = j2.variable(var_name) # Handle children - check for loops first counts = Counter(child.tag for child in children) @@ -229,6 +232,37 @@ 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) @@ -239,12 +273,16 @@ 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 prolog + xml_body + return escape_jinja_literal(prolog) + xml_body def _generate_xml_template_with_loops_from_text( self, @@ -264,6 +302,11 @@ 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: @@ -274,7 +317,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 prolog + xml_body + return escape_jinja_literal(prolog) + xml_body def _insert_xml_loops( self, @@ -339,12 +382,12 @@ class XmlHandler(BaseHandler): # Build loop result_lines.append( - f"{indent_str}{{% for {item_var} in {collection_var} %}}" + f"{indent_str}{j2.for_start(item_var, collection_var)}" ) # Add each line of the sample with proper indentation for sample_line in sample_lines: result_lines.append(f"{indent_str} {sample_line}") - result_lines.append(f"{indent_str}{{% endfor %}}") + result_lines.append(f"{indent_str}{j2.for_end()}") else: # Keep the marker if we can't find the candidate result_lines.append(line) @@ -360,11 +403,11 @@ class XmlHandler(BaseHandler): end = line.find("-->", start) condition = line[start:end] indent = len(line) - len(line.lstrip()) - final_lines.append(f"{' ' * indent}{{% if {condition} is defined %}}") + final_lines.append(f"{' ' * indent}{j2.if_defined(condition)}") # Replace with {% endif %} elif "\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) + + +# --- JSON object-key injection ------------------------------------------------ +# +# The JSON handler copies the text *between* scalar values (object keys, +# punctuation) verbatim. A key is never a value placeholder, so Jinja markup in a +# key can only come from attacker-influenced source text. The output gate +# (verify_no_live_jinja_in_json_keys) must fail closed on it -- including the +# "benign-looking name" form (e.g. ``{{ ansible_hostname }}``) that the generic +# allowlist would otherwise accept as an ordinary variable reference, and which +# could leak an in-scope variable's value into the rendered config at apply time. + +JSON_KEY_INJECTION_BODIES = [ + # benign-looking variable reference (the residual bypass: passes the generic + # allowlist but must still be rejected in *key* position) + '{ "{{ ansible_hostname }}": "v" }', + # dotted reference (e.g. dumping another host's vars) + '{ "{{ hostvars.localhost }}": 1 }', + # self-referencing a sibling-derived variable name + '{ "{{ role_port }}": "x", "port": 8080 }', + # classic gadget (already rejected historically; kept as a guard) + '{ "{{ cycler.__init__.__globals__ }}": 1 }', + # statement injection in a key + '{ "{% for x in y %}k{% endfor %}": 1 }', + # nested object key + '{ "ok": { "{{ ansible_hostname }}": 2 } }', +] + + +@pytest.mark.parametrize("body", JSON_KEY_INJECTION_BODIES) +def test_json_key_injection_fails_closed_cli(tmp_path, body): + src = tmp_path / "evil.json" + src.write_text(body, encoding="utf-8") + out = tmp_path / "out.j2" + res = subprocess.run( + [ + sys.executable, + "-m", + "jinjaturtle.cli", + str(src), + "-f", + "json", + "--role-name", + "role", + "-t", + str(out), + ], + capture_output=True, + text=True, + ) + assert res.returncode == 2, f"expected fail-closed, got rc={res.returncode}" + assert "refusing to generate unsafe template" in res.stderr + assert not out.exists(), "no template may be written when the gate refuses" + + +def test_json_benign_keys_still_generate(tmp_path): + src = tmp_path / "ok.json" + src.write_text('{ "host": "localhost", "port": 8080 }', encoding="utf-8") + out = tmp_path / "out.j2" + res = subprocess.run( + [ + sys.executable, + "-m", + "jinjaturtle.cli", + str(src), + "-f", + "json", + "--role-name", + "demo", + "-t", + str(out), + ], + capture_output=True, + text=True, + ) + assert res.returncode == 0, res.stderr + template_text = out.read_text() + # Keys stay literal; values become placeholders. + assert '"host":' in template_text + assert "demo_host" in template_text + + +# --- 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 }}", + # Whitespace-control markers: Jinja2 accepts "-", "+" or none adjacent to a + # tag's delimiters, and every variant closes a {% raw %} block. The "+" + # forms in particular were a raw-wrapper breakout vector (the defang regex + # historically only matched "-"), so all combinations must be neutralised. + "{%+ endraw %}{{ evil }}", + "{% endraw +%}{{ evil }}", + "{%+ endraw +%}{{ evil }}", + "{%- endraw +%}{{ evil }}", + "{%+ endraw -%}{{ evil }}", + # Full breakout attempt: close raw early, inject live code, re-open raw to + # swallow our trailing {% endraw %} so the template would otherwise compile. + "{%+ endraw %}{{ evil }}{%+ raw %}", + "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( + "endraw", + [ + "{% endraw %}", + "{% endraw -%}", + "{% endraw +%}", + "{%- endraw %}", + "{%- endraw -%}", + "{%- endraw +%}", + "{%+ endraw %}", + "{%+ endraw -%}", + "{%+ endraw +%}", + ], +) +def test_endraw_whitespace_control_cannot_break_out(endraw): + """Every whitespace-control form of endraw closes a {% raw %} block in + Jinja2, so each must be defanged. A payload that closes raw early, injects + a live tripwire call, then re-opens raw to balance the wrapper must still + render inertly back to its original characters.""" + env = jinja2.Environment(undefined=jinja2.ChainableUndefined) + payload = f"{endraw}{{{{ boom.run('x') }}}}{{%+ raw %}}" + escaped = escape_jinja_literal(payload) + rendered = env.from_string(escaped).render(boom=_Boom()) + assert TRIP not in rendered + assert rendered == payload diff --git a/tests/test_json_handler.py b/tests/test_json_handler.py index dd502b1..b2bbe75 100644 --- a/tests/test_json_handler.py +++ b/tests/test_json_handler.py @@ -1,9 +1,11 @@ from __future__ import annotations from pathlib import Path +import json import pytest import yaml +from jinja2 import Environment from jinjaturtle.core import ( parse_config, @@ -13,6 +15,7 @@ from jinjaturtle.core import ( generate_jinja2_template, ) from jinjaturtle.handlers.json import JsonHandler +from jinjaturtle.loop_analyzer import LoopCandidate SAMPLES_DIR = Path(__file__).parent / "samples" @@ -35,23 +38,136 @@ def test_json_roundtrip(): assert defaults["foobar_nested_a"] == 1 # Booleans are now preserved as booleans (not stringified) assert defaults["foobar_nested_b"] is True - # List should be a list (not flattened to scalars) - assert defaults["foobar_list"] == [10, 20] + # JSON stays scalar/index-based so source formatting can be preserved. + assert loop_candidates == [] + assert defaults["foobar_list_0"] == 10 + assert defaults["foobar_list_1"] == 20 - # Template generation with loops template = generate_jinja2_template("json", parsed, "foobar", None, loop_candidates) - # Template should use | tojson for type preservation - assert "{{ foobar_foo | tojson }}" in template - assert "{{ foobar_nested_a | tojson }}" in template - assert "{{ foobar_nested_b | tojson }}" in template + # Template should use Ansible | to_json for type preservation without + # HTML-safe escaping. + assert "{{ foobar_foo | to_json(ensure_ascii=False) }}" in template + assert "{{ foobar_nested_a | to_json(ensure_ascii=False) }}" in template + assert "{{ foobar_nested_b | to_json(ensure_ascii=False) }}" in template + assert "{{ foobar_list_0 | to_json(ensure_ascii=False) }}" in template + assert "{{ foobar_list_1 | to_json(ensure_ascii=False) }}" in template + assert "{% for" not in template - # List should use loop (not scalar indices) - assert "{% for" in template - assert "foobar_list" in template - # Should NOT have scalar indices - assert "foobar_list_0" not in template - assert "foobar_list_1" not in template + +def test_json_template_preserves_original_indentation_and_final_newline_state( + tmp_path: Path, +): + json_text = ( + "{\n" + ' "default-runtime": "runsc",\n' + ' "runtimes": {\n' + ' "runsc": {\n' + ' "path": "/usr/bin/runsc"\n' + " }\n" + " }\n" + "}" + ) + path = tmp_path / "daemon.json" + path.write_text(json_text, encoding="utf-8") + + fmt, parsed = parse_config(path) + template = generate_jinja2_template(fmt, parsed, "docker", json_text, []) + + assert ' "default-runtime"' in template + assert ' "runsc"' in template + assert ' "path"' in template + assert not template.endswith("\n") + + +def test_json_template_uses_non_html_safe_json_filter_for_angle_brackets( + tmp_path: Path, +): + json_text = '{"version_requirement": ">= 8.0.0 < 9.0.0"}\n' + path = tmp_path / "metadata.json" + path.write_text(json_text, encoding="utf-8") + + fmt, parsed = parse_config(path) + template = generate_jinja2_template(fmt, parsed, "puppet", json_text, []) + + assert "to_json(ensure_ascii=False)" in template + assert "tojson" not in template + + +def test_json_inline_object_array_roundtrips_without_expanding(tmp_path: Path): + json_text = ( + "{\n" + ' "description": "Gets the last boot time of a Linux or Windows system",\n' + ' "implementations": [\n' + ' {"name": "last_boot_time_nix.sh", "requirements": ["shell"]},\n' + ' {"name": "last_boot_time_win.ps1", "requirements": ["powershell"]}\n' + " ]\n" + "}\n" + ) + path = tmp_path / "metadata.json" + path.write_text(json_text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("fact", flat_items, loop_candidates) + ) + template = generate_jinja2_template(fmt, parsed, "fact", json_text, loop_candidates) + + env = Environment(keep_trailing_newline=True) + env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs) + rendered = env.from_string(template).render(**defaults) + + assert loop_candidates == [] + assert '{"name": {{ fact_implementations_0_name' in template + assert rendered == json_text + + +def test_json_direct_loop_generation_renders_valid_json_without_joined_objects(): + items = [ + {"name": "last_boot_time_nix.sh", "requirements": ["shell"]}, + {"name": "last_boot_time_win.ps1", "requirements": ["powershell"]}, + ] + parsed = {"implementations": items} + candidate = LoopCandidate( + path=("implementations",), + loop_var="implementation", + items=items, + item_schema="simple_dict", + ) + handler = JsonHandler() + template = handler.generate_jinja2_template_with_loops( + parsed, "fact", None, [candidate] + ) + + env = Environment(keep_trailing_newline=True) + env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs) + rendered = env.from_string(template).render(fact_implementations=items) + + assert "}, {" not in rendered + assert json.loads(rendered) == parsed + + +def test_json_scalar_span_collector_empty_containers_and_invalid_json(): + handler = JsonHandler() + + assert handler._collect_json_scalar_spans('{"empty": [], "obj": {}}') == [] + assert handler._collect_json_scalar_spans('{"unterminated": [1,}') is None + + +def test_json_template_falls_back_when_source_scanner_cannot_collect_spans(monkeypatch): + handler = JsonHandler() + monkeypatch.setattr(handler, "_collect_json_scalar_spans", lambda _text: None) + + template = handler.generate_jinja2_template( + {"answer": 42}, "role", original_text='{"answer": 42}' + ) + + assert ( + template + == '{\n "answer": {{ role_answer | to_json(ensure_ascii=False) }}\n}\n' + ) def test_generate_jinja2_template_json_type_error(): diff --git a/tests/test_multi.py b/tests/test_multi.py new file mode 100644 index 0000000..2076655 --- /dev/null +++ b/tests/test_multi.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from jinjaturtle.multi import ( + _collect_dict_like_paths, + _merge_union, + defined_var_name, + is_supported_file, + iter_supported_files, + process_directory, +) + + +def test_iter_supported_files_handles_files_dirs_and_missing_paths(tmp_path: Path): + json_file = tmp_path / "config.json" + text_file = tmp_path / "notes.txt" + nested = tmp_path / "nested" + nested.mkdir() + nested_yaml = nested / "config.yaml" + + json_file.write_text('{"name": "one"}\n', encoding="utf-8") + text_file.write_text("ignore me\n", encoding="utf-8") + nested_yaml.write_text("name: two\n", encoding="utf-8") + + assert is_supported_file(json_file) + assert not is_supported_file(text_file) + assert iter_supported_files(json_file, recursive=False) == [json_file] + assert iter_supported_files(text_file, recursive=False) == [] + assert iter_supported_files(tmp_path, recursive=False) == [json_file] + assert iter_supported_files(tmp_path, recursive=True) == [json_file, nested_yaml] + + with pytest.raises(FileNotFoundError): + iter_supported_files(tmp_path / "missing", recursive=False) + + +def test_merge_union_and_collect_dict_like_paths(): + merged = _merge_union( + {"name": "one", "ports": [80], "nested": {"a": 1}}, + {"ports": [80, 443], "nested": {"b": 2}, "enabled": True}, + ) + + assert merged == { + "name": "one", + "ports": [80, 443], + "nested": {"a": 1, "b": 2}, + "enabled": True, + } + + containers, leaves = _collect_dict_like_paths(merged) + assert ("nested",) in containers + assert ("nested", "a") in containers + assert ("ports", "1") in leaves + + +def test_process_directory_multiple_formats(tmp_path: Path): + (tmp_path / "a.json").write_text('{"name": "one"}\n', encoding="utf-8") + (tmp_path / "b.yaml").write_text("name: one\nextra: true\n", encoding="utf-8") + (tmp_path / "c.toml").write_text('name = "one"\n', encoding="utf-8") + (tmp_path / "d.ini").write_text("[main]\nname = one\n", encoding="utf-8") + (tmp_path / "e.xml").write_text("one", encoding="utf-8") + + defaults_yaml, outputs = process_directory( + tmp_path, recursive=False, role_prefix="role" + ) + defaults = yaml.safe_load(defaults_yaml) + by_fmt = {output.fmt: output for output in outputs} + + assert set(by_fmt) == {"ini", "json", "toml", "xml", "yaml"} + assert set(defaults) == { + "role_ini_items", + "role_json_items", + "role_toml_items", + "role_xml_items", + "role_yaml_items", + } + assert defaults["role_json_items"][0]["data"] == {"name": "one"} + assert ( + by_fmt["json"].template + == "{{ data | to_json(indent=2, ensure_ascii=False) }}\n" + ) + assert "{{ role_main_name }}" in by_fmt["ini"].template + assert "{{ role_name }}" in by_fmt["xml"].template + + +def test_process_directory_yaml_union_marks_optional_keys(tmp_path: Path): + (tmp_path / "one.yaml").write_text("name: one\nextra: yes\n", encoding="utf-8") + (tmp_path / "two.yaml").write_text("name: two\n", encoding="utf-8") + + defaults_yaml, outputs = process_directory( + tmp_path, recursive=False, role_prefix="role" + ) + defaults = yaml.safe_load(defaults_yaml) + + assert len(outputs) == 1 + assert outputs[0].fmt == "yaml" + assert "{% if role_defined_extra is defined %}" in outputs[0].template + assert defaults["role_items"][0][defined_var_name("role", ("extra",))] is True + assert defined_var_name("role", ("extra",)) not in defaults["role_items"][1] + + +def test_process_directory_ini_union_marks_optional_sections_and_keys(tmp_path: Path): + (tmp_path / "one.ini").write_text( + "[main]\nname = one\n[extra]\nflag = yes\n", encoding="utf-8" + ) + (tmp_path / "two.ini").write_text("[main]\nname = two\n", encoding="utf-8") + + defaults_yaml, outputs = process_directory( + tmp_path, recursive=False, role_prefix="role" + ) + defaults = yaml.safe_load(defaults_yaml) + + assert len(outputs) == 1 + assert outputs[0].fmt == "ini" + assert "{% if role_defined_extra is defined %}" in outputs[0].template + assert defaults["role_items"][0][defined_var_name("role", ("extra",))] is True + assert defined_var_name("role", ("extra",)) not in defaults["role_items"][1] + + +def test_process_directory_rejects_empty_folder(tmp_path: Path): + with pytest.raises(ValueError, match="No supported config files"): + process_directory(tmp_path, recursive=False, role_prefix="role") diff --git a/tests/test_output_safety_gate.py b/tests/test_output_safety_gate.py new file mode 100644 index 0000000..055ceb3 --- /dev/null +++ b/tests/test_output_safety_gate.py @@ -0,0 +1,233 @@ +"""Regression tests for the output safety gate (``jinjaturtle.safety``). + +The gate is JinjaTurtle's second, independent line of defence against template +injection. Where the per-handler escaper neutralises verbatim source text, the +gate inspects the *finished* template and refuses to emit it if any live +construct is not one JinjaTurtle itself produces. These tests cover: + + * the gate's allow/deny grammar (unit level); + * the two concrete injection findings that motivated it -- JSON object keys + and folder-mode union keys copied into templates unescaped; + * end-to-end CLI fail-closed behaviour (non-zero exit, no file written). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from jinjaturtle import core +from jinjaturtle.multi import process_directory +from jinjaturtle.safety import ( + TemplateSafetyError, + verify_jinja2_template_safe, + verify_erb_template_safe, +) + + +# --------------------------------------------------------------------------- # +# Unit: the allow/deny grammar. +# --------------------------------------------------------------------------- # + +LEGIT_TEMPLATES = [ + "{{ demo_memory_limit }}", + "{{ demo_x | to_json(ensure_ascii=False) }}", + "{{ demo_a | to_json(indent=2, ensure_ascii=False) }}", + "{{ demo_name | lower }}", + "{{ 'true' if demo_flag else 'false' }}", + '{{ "true" if demo_flag else "false" }}', + "{{ 'null' if demo_v is none else demo_v }}", + "{% for server in demo_servers %}{{ server.name }}{% endfor %}", + "{{ server.config.port }}", + "{% if demo_x is defined %}{{ demo_x }}{% endif %}", + "{% if demo_x is none %}x{% endif %}", + "{% if not loop.last %},{% endif %}", + "plain text with no tags", + "{% raw %}# literal {{ not_code }} {% if x %}{% endraw %}", + "{% raw %}{{ 7*7 }}{% endraw %}: value", # escaped key (folder-mode fix) +] + + +@pytest.mark.parametrize("template", LEGIT_TEMPLATES) +def test_gate_allows_jinjaturtle_constructs(template): + # Must not raise. + verify_jinja2_template_safe(template) + + +MALICIOUS_TEMPLATES = [ + "{{ 7*7 }}", + "{{ cycler.__init__.__globals__ }}", + "{{ cycler.__init__.__globals__.os.popen('id').read() }}", + "{{ salt['cmd.run']('id') }}", + "{{ self.__init__ }}", + "{% for x in ().__class__.__base__.__subclasses__() %}{{ x }}{% endfor %}", + "{{ config.items() }}", + '{{ request["application"] }}', + "{% set x = 1 %}", + "{{ lipsum.__globals__ }}", + "{{ a.__class__.__mro__ }}", + "{{ ''.join(['a','b']) }}", + "{% include 'x' %}", + "{% import 'x' as y %}", +] + + +@pytest.mark.parametrize("template", MALICIOUS_TEMPLATES) +def test_gate_blocks_injection(template): + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe(template) + + +def test_gate_blocks_double_underscore_anywhere(): + # The no-dunder rule is the backbone of blocking attribute-traversal SSTI. + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe("{{ demo__x }}") + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe("{{ a.b__c }}") + + +def test_gate_rejects_unlexable_breakout_as_safety_error(): + # A raw-wrapper breakout that leaves dangling tags must surface as a + # TemplateSafetyError, not a raw Jinja2 syntax error. + broken = "{% raw %}{%+ endraw %}{{ 7*7 }}" # unbalanced on purpose + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe(broken) + + +# --------------------------------------------------------------------------- # +# Finding 1: JSON object keys copied verbatim into the template. +# --------------------------------------------------------------------------- # + +JSON_KEY_PAYLOADS = [ + '{ "{{ 7*7 }}": "v" }', + '{ "{{cycler.__init__.__globals__}}": 1 }', + "{ \"ok\": { \"{{ salt['cmd.run']('id') }}\": 2 } }", +] + + +@pytest.mark.parametrize("src", JSON_KEY_PAYLOADS) +def test_json_key_injection_is_blocked(src): + parsed = json.loads(src) + with pytest.raises(TemplateSafetyError): + core.generate_jinja2_template("json", parsed, "demo", original_text=src) + + +def test_json_benign_template_still_generates(): + src = '{ "name": "app", "port": 8080 }' + parsed = json.loads(src) + out = core.generate_jinja2_template("json", parsed, "demo", original_text=src) + assert "demo_name" in out + assert "demo_port" in out + + +# --------------------------------------------------------------------------- # +# Finding 2: folder-mode union renderers copied keys verbatim. +# --------------------------------------------------------------------------- # + + +def _write(tmp: Path, name: str, text: str) -> None: + (tmp / name).write_text(text, encoding="utf-8") + + +def test_folder_yaml_key_injection_is_neutralised(tmp_path): + # The malicious key must be escaped (root-cause fix), so the union template + # generates successfully AND passes the gate, rendering the key inertly. + _write(tmp_path, "a.yaml", '"{{ 7*7 }}": value\nnormalkey: ok\n') + _write(tmp_path, "b.yaml", "normalkey: ok2\n") + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + # Escaped, not live: + assert "{% raw %}{{ 7*7 }}{% endraw %}" in template + # And the gate (run inside process_directory) did not reject it. + + +def test_folder_ini_section_and_key_injection_neutralised(tmp_path): + _write( + tmp_path, + "a.ini", + "[{{ 7*7 }}]\n{{ evil }} = x\n", + ) + _write(tmp_path, "b.ini", "[normal]\nk = y\n") + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + # Section header and key are escaped, not live. + assert "{{ 7*7 }}" not in _strip_raw_blocks(template) + assert "{{ evil }}" not in _strip_raw_blocks(template) + + +def test_folder_toml_key_injection_neutralised(tmp_path): + _write(tmp_path, "a.toml", '"{{ 7*7 }}" = "v"\nok = "y"\n') + _write(tmp_path, "b.toml", 'ok = "z"\n') + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + assert "{{ 7*7 }}" not in _strip_raw_blocks(template) + + +def _strip_raw_blocks(text: str) -> str: + """Remove the *contents* of raw blocks so we can assert no LIVE payload + remains outside them.""" + import re + + return re.sub( + r"{%[-+]?\s*raw\s*[-+]?%}.*?{%[-+]?\s*endraw\s*[-+]?%}", + "", + text, + flags=re.S, + ) + + +# --------------------------------------------------------------------------- # +# ERB gate. +# --------------------------------------------------------------------------- # + + +def test_erb_gate_blocks_leftover_jinja_delimiters(): + with pytest.raises(TemplateSafetyError): + verify_erb_template_safe("ok <%= @x %> but {{ leftover }} here") + + +def test_erb_gate_allows_clean_erb(): + verify_erb_template_safe("memory = <%= @memory_limit %>\n") + + +# --------------------------------------------------------------------------- # +# End-to-end CLI: fail closed. +# --------------------------------------------------------------------------- # + + +def _run_cli(args, env_extra=None): + import os + + env = {**os.environ, "PYTHONPATH": "src"} + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, "-m", "jinjaturtle.cli", *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_cli_fails_closed_on_injection(tmp_path): + bad = tmp_path / "evil.json" + bad.write_text('{ "{{ 7*7 }}": "v" }', encoding="utf-8") + out = tmp_path / "out.j2" + result = _run_cli([str(bad), "-r", "demo", "-f", "json", "-t", str(out)]) + assert result.returncode == 2 + assert "refusing to generate unsafe template" in result.stderr + # No template file may be written when the gate refuses. + assert not out.exists() + + +def test_cli_succeeds_on_benign_input(tmp_path): + good = tmp_path / "ok.json" + good.write_text('{ "name": "app" }', encoding="utf-8") + out = tmp_path / "out.j2" + result = _run_cli([str(good), "-r", "demo", "-f", "json", "-t", str(out)]) + assert result.returncode == 0 + assert out.exists() diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py index 850cc9a..1a6d47b 100644 --- a/tests/test_roundtrip.py +++ b/tests/test_roundtrip.py @@ -31,6 +31,7 @@ from jinjaturtle.core import ( def render_template(template: str, variables: dict[str, Any]) -> str: """Render a Jinja2 template with variables.""" env = Environment(undefined=StrictUndefined) + env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs) jinja_template = env.from_string(template) return jinja_template.render(variables) diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py new file mode 100644 index 0000000..b5bb855 --- /dev/null +++ b/tests/test_security_hardening.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from pathlib import Path +import os + +import pytest +import yaml +from defusedxml.common import EntitiesForbidden + +from jinjaturtle import cli +from jinjaturtle.core import generate_ansible_yaml, parse_config, flatten_config +from jinjaturtle.multi import is_supported_file, iter_supported_files, process_directory +from jinjaturtle.output_safety import OutputPathError, write_text_safely + + +class UnsafeAwareLoader(yaml.SafeLoader): + pass + + +def _unsafe(loader: UnsafeAwareLoader, node: yaml.Node): + return loader.construct_scalar(node) + + +UnsafeAwareLoader.add_constructor("!unsafe", _unsafe) + + +def test_jinja_values_are_emitted_as_ansible_unsafe(tmp_path: Path): + src = tmp_path / "app.ini" + src.write_text("[main]\ncmd = {{ lookup('pipe','id') }}\n", encoding="utf-8") + + fmt, parsed = parse_config(src) + defaults_yaml = generate_ansible_yaml("role", flatten_config(fmt, parsed)) + + assert "role_main_cmd: !unsafe" in defaults_yaml + assert "{{ lookup(''pipe'',''id'') }}" in defaults_yaml + loaded = yaml.load(defaults_yaml, Loader=UnsafeAwareLoader) + assert loaded["role_main_cmd"] == "{{ lookup('pipe','id') }}" + + +def test_folder_mode_marks_nested_jinja_values_and_ids_unsafe(tmp_path: Path): + src = tmp_path / "src" + src.mkdir() + # Filename ids are source-derived values too. + (src / "{{ bad }}.yaml").write_text( + "message: \"{{ lookup('pipe','id') }}\"\n", encoding="utf-8" + ) + + defaults_yaml, _outputs = process_directory( + src, recursive=False, role_prefix="role" + ) + + assert "id: !unsafe" in defaults_yaml + assert "role_message: !unsafe" in defaults_yaml + loaded = yaml.load(defaults_yaml, Loader=UnsafeAwareLoader) + assert loaded["role_items"][0]["id"] == "{{ bad }}.yaml" + assert loaded["role_items"][0]["role_message"] == "{{ lookup('pipe','id') }}" + + +def test_xml_parser_rejects_entities_when_called_as_library(tmp_path: Path): + src = tmp_path / "bad.xml" + src.write_text( + "]>&xxe;", + encoding="utf-8", + ) + + with pytest.raises(EntitiesForbidden): + parse_config(src, "xml") + + +def test_folder_mode_does_not_follow_symlinked_files(tmp_path: Path): + real = tmp_path / "secret.ini" + real.write_text("[main]\nsecret=yes\n", encoding="utf-8") + root = tmp_path / "root" + root.mkdir() + link = root / "link.ini" + link.symlink_to(real) + + assert not is_supported_file(link) + assert iter_supported_files(root, recursive=False) == [] + assert iter_supported_files(root, recursive=True) == [] + + +@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unavailable") +def test_cli_refuses_to_write_through_final_symlink(tmp_path: Path): + target = tmp_path / "target.txt" + target.write_text("keep\n", encoding="utf-8") + link = tmp_path / "out.yml" + link.symlink_to(target) + + with pytest.raises(OutputPathError): + write_text_safely(link, "replace\n") + + assert target.read_text(encoding="utf-8") == "keep\n" + assert link.is_symlink() + + +def test_cli_refuses_symlinked_output_parent(tmp_path: Path): + real_dir = tmp_path / "real" + real_dir.mkdir() + link_dir = tmp_path / "linkdir" + link_dir.symlink_to(real_dir, target_is_directory=True) + + with pytest.raises(OutputPathError): + write_text_safely(link_dir / "out.yml", "data\n") + + assert not (real_dir / "out.yml").exists() + + +def test_cli_reports_unsafe_output_path_without_overwriting_symlink(tmp_path: Path): + cfg = tmp_path / "app.ini" + cfg.write_text("[main]\nname = ok\n", encoding="utf-8") + target = tmp_path / "target.yml" + target.write_text("keep\n", encoding="utf-8") + link = tmp_path / "defaults.yml" + link.symlink_to(target) + + exit_code = cli._main([str(cfg), "--defaults-output", str(link)]) + + assert exit_code == 2 + assert target.read_text(encoding="utf-8") == "keep\n" + + +def test_cli_refuses_root_output_through_group_writable_parent( + tmp_path: Path, monkeypatch +): + from jinjaturtle import output_safety + + unsafe_dir = tmp_path / "unsafe" + unsafe_dir.mkdir() + unsafe_dir.chmod(0o777) + monkeypatch.setattr(output_safety, "_effective_uid", lambda: 0) + + with pytest.raises(OutputPathError): + write_text_safely(unsafe_dir / "out.yml", "data\n") + + assert not (unsafe_dir / "out.yml").exists() diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index c7bacb7..543c759 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -7,6 +7,7 @@ import yaml from jinjaturtle.core import ( parse_config, + analyze_loops, flatten_config, generate_ansible_yaml, generate_jinja2_template, @@ -100,3 +101,203 @@ def test_generate_jinja2_template_yaml_structural_fallback(): # We don't care about exact formatting, just that the expected variable # name shows up, proving we went through the structural path. assert "role_outer_inner" in tmpl + + +def test_yaml_empty_collection_defaults_match_template_vars(tmp_path: Path): + yaml_path = tmp_path / "pdk.yaml" + yaml_path.write_text("ignore: []\nsettings: {}\n", encoding="utf-8") + + fmt, parsed = parse_config(yaml_path) + flat_items = flatten_config(fmt, parsed) + ansible_yaml = generate_ansible_yaml("role", flat_items) + defaults = yaml.safe_load(ansible_yaml) + + assert defaults["role_ignore"] == [] + assert defaults["role_settings"] == {} + + template = generate_jinja2_template( + fmt, parsed, "role", original_text=yaml_path.read_text(encoding="utf-8") + ) + assert "{{ role_ignore }}" in template + assert "{{ role_settings }}" in template + + +def test_yaml_indentless_sequence_loop_roundtrips_semantically(tmp_path: Path): + """Indentless YAML sequences under a mapping key must be fully replaced. + + A previous loop renderer emitted a loop at ``images:`` but then processed + the original ``- item`` lines again because they had the same indentation + as the parent key. That duplicated values and nested following top-level + keys incorrectly. + """ + from jinja2 import Template + + from jinjaturtle.core import analyze_loops + + text = textwrap.dedent( + """ + default: + provisioner: docker + images: + - waffleimage/ubuntu18.04 + - waffleimage/centos7 + vagrant: + provisioner: vagrant + images: + - centos/7 + - generic/ubuntu1804 + """ + ).lstrip() + path = tmp_path / "provision.yaml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("role", flat_items, loop_candidates) + ) + template = generate_jinja2_template( + fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates + ) + rendered = Template(template).render(**defaults) + + assert yaml.safe_load(rendered) == yaml.safe_load(text) + assert "%} - {{ image }}" in template + assert "%} - {{ image }}" not in template + assert "%} - {{ image }}" not in template + assert " vagrant:" not in template + + +def test_yaml_loop_preserves_blank_separator_after_list(tmp_path: Path): + from jinja2 import Template + + cases = [ + ( + "blank", + "require:\n - rubocop-performance\n - rubocop-rspec\n\nAllCops:\n NewCops: enable\n", + "\n - rubocop-rspec\n\nAllCops:", + ), + ( + "no_blank", + "require:\n - rubocop-performance\n - rubocop-rspec\nAllCops:\n NewCops: enable\n", + "\n - rubocop-rspec\nAllCops:", + ), + ] + + for label, text, rendered_expected in cases: + path = tmp_path / f"{label}.yml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("role", flat_items, loop_candidates) + ) + + template = generate_jinja2_template( + fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates + ) + rendered = Template(template).render(**defaults) + assert rendered_expected in rendered + + +def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path): + from jinja2 import Environment, Template + + from jinjaturtle.core import analyze_loops + + text = textwrap.dedent( + """ + Style/HashExcept: + Exclude: + - lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb + - spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb + + # Offense count: 2 + # This cop supports unsafe autocorrection (--autocorrect-all). + Style/MapIntoArray: + Exclude: + - lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb + """ + ).lstrip() + path = tmp_path / ".rubocop_todo.yml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("role", flat_items, loop_candidates) + ) + template = generate_jinja2_template( + fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates + ) + rendered = Template(template).render(**defaults) + ansible_rendered = ( + Environment(trim_blocks=True).from_string(template).render(**defaults) + ) + + assert "# Offense count: 2" in rendered + assert "# This cop supports unsafe autocorrection" in rendered + assert "spec.rb\n\n# Offense count: 2" in ansible_rendered + assert yaml.safe_load(rendered) == yaml.safe_load(text) + assert yaml.safe_load(ansible_rendered) == yaml.safe_load(text) + + +def test_yaml_scalar_loop_preserves_quoted_list_items(tmp_path: Path): + from jinja2 import Template + + text = textwrap.dedent( + """ + files: + - 'spec/unit/puppet/provider/dsc_base_provider/dsc_base_provider_spec.rb' + - 'spec/unit/pwsh/util_spec.rb' + - 'spec/unit/pwsh/windows_powershell_spec.rb' + """ + ).lstrip() + path = tmp_path / "quoted-list.yaml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + loop_candidates = analyze_loops(fmt, parsed) + flat_items = flatten_config(fmt, parsed, loop_candidates) + defaults = yaml.safe_load( + generate_ansible_yaml("role", flat_items, loop_candidates) + ) + template = generate_jinja2_template( + fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates + ) + rendered = Template(template).render(**defaults) + + assert loop_candidates + assert "- '{{" in template + assert rendered == text + + +def test_yaml_bool_scalars_render_with_yaml_spelling(tmp_path: Path): + from jinja2 import Template + + text = textwrap.dedent( + """ + AllCops: + SuggestExtensions: false + Style/ClassAndModuleChildren: + Enabled: false + """ + ).lstrip() + path = tmp_path / ".rubocop.yml" + path.write_text(text, encoding="utf-8") + + fmt, parsed = parse_config(path) + flat_items = flatten_config(fmt, parsed) + defaults = yaml.safe_load(generate_ansible_yaml("role", flat_items)) + template = generate_jinja2_template(fmt, parsed, "role", original_text=text) + rendered = Template(template).render(**defaults) + + assert "SuggestExtensions: false" in rendered + assert "Enabled: false" in rendered + assert "SuggestExtensions: False" not in rendered + assert "Enabled: False" not in rendered + assert yaml.safe_load(rendered) == yaml.safe_load(text) diff --git a/tests/test_yaml_template_consistency.py b/tests/test_yaml_template_consistency.py index 22a3c69..5a48502 100644 --- a/tests/test_yaml_template_consistency.py +++ b/tests/test_yaml_template_consistency.py @@ -454,6 +454,7 @@ class TestStructuralConsistency: # Try to render the template env = Environment(undefined=StrictUndefined) + env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs) try: jinja_template = env.from_string(template) rendered = jinja_template.render(variables) @@ -520,11 +521,9 @@ class TestRegressionBugs: "app_database" in template ), f"Template should reference app_database\n{template}" - def test_json_array_no_index_refs(self): + def test_json_array_uses_index_refs_for_source_fidelity(self): """ - Regression test: JSON arrays should not generate index references. - - Bug: Template had {{ app_list_0 }}, {{ app_list_1 }} when YAML had app_list as list. + JSON arrays stay index-based so original inline formatting can survive. """ import json @@ -536,22 +535,18 @@ class TestRegressionBugs: ansible_yaml = generate_ansible_yaml("app", flat_items, loop_candidates) template = generate_jinja2_template( - "json", parsed, "app", None, loop_candidates + "json", parsed, "app", json_text, loop_candidates ) - # YAML should have app_items as a list defaults = yaml.safe_load(ansible_yaml) - assert isinstance(defaults.get("app_items"), list) - - # Template should NOT have app_items_0, app_items_1, app_items_2 - for i in range(3): - assert ( - f"app_items_{i}" not in template - ), f"Template incorrectly uses scalar 'app_items_{i}'\n{template}" - - # Template SHOULD use a loop - assert "{% for" in template - assert "app_items" in template + assert loop_candidates == [] + assert defaults["app_items_0"] == 1 + assert defaults["app_items_1"] == 2 + assert defaults["app_items_2"] == 3 + assert "{% for" not in template + assert "app_items_0" in template + assert "app_items_1" in template + assert "app_items_2" in template if __name__ == "__main__":