Compare commits

...

44 commits
0.3.5 ... main

Author SHA1 Message Date
a712b19a45
Remove Fediverse link
All checks were successful
CI / test (push) Successful in 44s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m23s
Lint / test (push) Successful in 36s
2026-07-24 10:58:23 +10:00
d3694240da
0.7.0
All checks were successful
CI / test (push) Successful in 52s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m40s
Lint / test (push) Successful in 50s
2026-07-05 10:54:37 +10:00
f9332879ba
More hardening
All checks were successful
CI / test (push) Successful in 1m21s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m33s
Lint / test (push) Successful in 41s
2026-07-03 12:23:52 +10:00
c6ce0311ed
Forbid the ansible globals
All checks were successful
CI / test (push) Successful in 51s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m36s
Lint / test (push) Successful in 42s
2026-07-02 14:52:09 +10:00
054b90ebde
Don't reference built-in globals via loop-items in Jinja2 into keys
All checks were successful
CI / test (push) Successful in 52s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m31s
Lint / test (push) Successful in 43s
2026-07-02 14:22:07 +10:00
575c2b79f9
Fix catching of defusedxml exception
All checks were successful
CI / test (push) Successful in 48s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m29s
Lint / test (push) Successful in 39s
2026-07-01 12:23:19 +10:00
d4fd42522d
Fix bandit alert
All checks were successful
CI / test (push) Successful in 49s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m32s
Lint / test (push) Successful in 40s
2026-06-29 20:53:15 +10:00
42321a8ec9
Catch malformed config errors gracefully
Some checks failed
CI / test (push) Successful in 49s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m28s
Lint / test (push) Failing after 39s
2026-06-29 20:47:05 +10:00
3573e8e750
Make var name collapse underscores 2026-06-29 20:46:07 +10:00
5ae85ad11e
Fix tests
All checks were successful
CI / test (push) Successful in 47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m25s
Lint / test (push) Successful in 37s
2026-06-29 14:44:39 +10:00
bc4af135b4
Fix tests
Some checks failed
CI / test (push) Successful in 49s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 1m22s
Lint / test (push) Successful in 37s
2026-06-29 14:31:39 +10:00
0e052a073a
Harden output 2026-06-29 14:30:46 +10:00
70be0f7e33
Hardening: use unsafe for ansible vars, ensure API use of JinjaTurtle uses safe XML parsing, avoid symlinks
Some checks failed
CI / test (push) Successful in 48s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m27s
Lint / test (push) Failing after 39s
2026-06-29 08:51:53 +10:00
4a1f2ac15e
More JSON defenses 2026-06-28 20:33:12 +10:00
661320558c
Go back to just jinja2 2026-06-25 17:07:58 +10:00
094c4d2274
Fixes
All checks were successful
CI / test (push) Successful in 47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m19s
Lint / test (push) Successful in 37s
2026-06-24 17:42:18 +10:00
a9d56b66c5
Fixes for releasing
All checks were successful
CI / test (push) Successful in 46s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m18s
Lint / test (push) Successful in 38s
2026-06-23 18:03:29 +10:00
c5375b180f
0.5.6
All checks were successful
CI / test (push) Successful in 47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m20s
Lint / test (push) Successful in 38s
2026-06-23 17:15:02 +10:00
383a529016
fix test
All checks were successful
CI / test (push) Successful in 46s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m19s
Lint / test (push) Successful in 40s
2026-06-23 15:57:17 +10:00
a5f8e7c481
Attempt to fix python bin
Some checks failed
CI / test (push) Successful in 47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m22s
Lint / test (push) Failing after 39s
2026-06-23 15:42:42 +10:00
15770c8e3d
Upgrade to poetry2 and update dependencies
Some checks failed
CI / test (push) Failing after 53s
Lint / test (push) Failing after 42s
2026-06-23 14:30:42 +10:00
a5ca5f2974
Try to prevent what could lead to execution of embedded jinja in original files when converting 2026-06-23 14:20:05 +10:00
f5de32b778
Rewrite the README
All checks were successful
CI / test (push) Successful in 1m6s
Lint / test (push) Successful in 1m6s
2026-06-20 20:17:28 +10:00
13d1a2b972
0.5.5 2026-06-20 18:28:19 +10:00
00d0064362
Fixes for newlines in yaml 2026-06-20 18:27:22 +10:00
bbbbe33e54
Fix newlines in yaml
All checks were successful
CI / test (push) Successful in 1m9s
Lint / test (push) Successful in 38s
2026-06-20 17:52:04 +10:00
94aeaf6e7a
Don't enforce the indentation 2026-06-20 17:40:56 +10:00
17612c9883
Add erb support 2026-06-20 17:40:30 +10:00
5ee084d395
0.5.4
All checks were successful
CI / test (push) Successful in 1m5s
Lint / test (push) Successful in 35s
2026-06-20 15:30:22 +10:00
49fee7afe4
Make templates more faithful to the original file in terms of indentation, newlines, no deserialisation of things like < or >. More test coverage 2026-06-20 15:28:48 +10:00
3d53d4fb30
Fix loss of comments and True/False to true/false
All checks were successful
CI / test (push) Successful in 1m5s
Lint / test (push) Successful in 36s
2026-06-19 18:46:04 +10:00
77d1658e65
Fix indentation with nested dicts
All checks were successful
CI / test (push) Successful in 1m12s
Lint / test (push) Successful in 37s
2026-06-19 18:38:35 +10:00
1413076c9c
Update release script
All checks were successful
CI / test (push) Successful in 1m9s
Lint / test (push) Successful in 48s
2026-06-19 17:46:56 +10:00
39ba01689c
Update dependencies
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-19 17:45:23 +10:00
d9bf2966c6
Empty dicts and lists are now emitted as leaf defaults. 2026-06-19 17:41:56 +10:00
1e545cca87
Support ssh configs
All checks were successful
CI / test (push) Successful in 53s
Lint / test (push) Successful in 31s
2026-05-12 11:45:49 +10:00
823e529373
Remove trivy..
All checks were successful
CI / test (push) Successful in 50s
Lint / test (push) Successful in 1m0s
2026-03-23 11:21:55 +11:00
e6d58199d3
Update my GPG key
All checks were successful
CI / test (push) Successful in 52s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 27s
2026-03-11 12:04:21 +11:00
121e67ae07
Bump versions 2026-01-06 12:01:37 +11:00
2f77cd4d80
Add support for systemd and postfix config files
All checks were successful
CI / test (push) Successful in 50s
Lint / test (push) Successful in 29s
Trivy / test (push) Successful in 23s
2026-01-06 11:57:50 +11:00
8f7f48dc91
fix fedora release
All checks were successful
CI / test (push) Successful in 51s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 22s
2026-01-03 14:13:45 +11:00
a5c860e463
remove 'fc' from release root
All checks were successful
CI / test (push) Successful in 51s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-03 12:49:59 +11:00
14428ff89c
Separate rpm dirs for different Fedora versions
All checks were successful
CI / test (push) Successful in 50s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-03 09:33:06 +11:00
f92854382a
Build for Fedora 43
All checks were successful
CI / test (push) Successful in 50s
Lint / test (push) Successful in 30s
Trivy / test (push) Successful in 23s
2026-01-01 15:25:11 +11:00
44 changed files with 4973 additions and 701 deletions

View file

@ -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

View file

@ -1,40 +0,0 @@
name: Trivy
on:
schedule:
- cron: '0 1 * * *'
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
- name: Run trivy
run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry --skip-version-check --exit-code 1 .
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

2
.gitignore vendored
View file

@ -8,3 +8,5 @@ dist
*.j2
*.toml
regenerated_*
*.orig
*.rej

View file

@ -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"

View file

@ -1,5 +1,6 @@
# syntax=docker/dockerfile:1
FROM fedora:42
ARG BASE_IMAGE=fedora:42
FROM ${BASE_IMAGE}
RUN set -eux; \
dnf -y update; \

258
README.md
View file

@ -4,55 +4,134 @@
<img src="https://git.mig5.net/mig5/jinjaturtle/raw/branch/main/jinjaturtle.svg" alt="JinjaTurtle logo" width="240" />
</div>
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
@ -74,7 +153,7 @@ sudo apt update
sudo apt install jinjaturtle
```
### Fedora 42
### Fedora
```bash
sudo rpm --import https://mig5.net/static/mig5.asc
@ -82,7 +161,7 @@ sudo rpm --import https://mig5.net/static/mig5.asc
sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF'
[mig5]
name=mig5 Repository
baseurl=https://rpm.mig5.net/rpm/$basearch
baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
@ -93,9 +172,9 @@ sudo dnf upgrade --refresh
sudo dnf install jinjaturtle
```
### From PyPi
### From PyPI
```
```bash
pip install jinjaturtle
```
@ -103,52 +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}] [-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 (TOML or INI-style).
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 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 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.

61
debian/changelog vendored
View file

@ -1,3 +1,64 @@
jinjaturtle (0.7.0) unstable; urgency=medium
* Much hardening.
-- Miguel Jacq <mig@mig5.net> Sun, 5 Jul 2026 10:54:00 +1000
jinjaturtle (0.5.7) unstable; urgency=medium
* More hardening measures
-- Miguel Jacq <mig@mig5.net> 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 <mig@mig5.net> Tue, 23 Jun 2026 16:47:00 +1000
jinjaturtle (0.5.5) unstable; urgency=medium
* erb support
-- Miguel Jacq <mig@mig5.net> 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 <mig@mig5.net> 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 <mig@mig5.net> Fri, 19 Jun 2026 18:43:00 +1000
jinjaturtle (0.5.2) unstable; urgency=medium
* Fix indentation problems with nested dicts
-- Miguel Jacq <mig@mig5.net> 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 <mig@mig5.net> Fri, 19 Jun 2026 17:43:00 +1000
jinjaturtle (0.5.0) unstable; urgency=medium
* Support ssh configs
-- Miguel Jacq <mig@mig5.net> Tue, 12 May 2026 11:40:00 +1000
jinjaturtle (0.4.0) unstable; urgency=medium
* Support converting systemd files and postfix main.cf
-- Miguel Jacq <mig@mig5.net> Tue, 06 Jan 2026 12:00:00 +1100
jinjaturtle (0.3.5) unstable; urgency=medium
* Support converting a directory (optionally recursively) instead of just an individual file.

612
poetry.lock generated
View file

@ -1,136 +1,154 @@
# 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 = "2025.11.12"
version = "2026.6.17"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"},
{file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"},
{file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
{file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
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.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"},
{file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"},
{file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"},
{file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"},
{file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"},
{file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"},
{file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"},
{file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"},
{file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"},
{file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"},
{file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"},
{file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"},
{file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"},
{file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"},
{file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"},
{file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"},
{file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"},
{file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"},
{file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"},
{file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"},
{file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"},
{file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"},
{file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"},
{file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"},
{file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"},
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
{file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
{file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
{file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
{file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]]
@ -139,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"},
@ -146,110 +166,110 @@ files = [
[[package]]
name = "coverage"
version = "7.13.0"
version = "7.14.3"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
{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"
@ -257,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"},
@ -268,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"},
@ -282,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"},
@ -295,17 +319,18 @@ test = ["pytest (>=6)"]
[[package]]
name = "idna"
version = "3.11"
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.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
@ -313,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"},
@ -324,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"},
@ -341,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"},
@ -435,13 +463,14 @@ files = [
[[package]]
name = "packaging"
version = "25.0"
version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]]
@ -450,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"},
@ -461,13 +491,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
@ -479,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"},
@ -495,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"},
@ -518,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"},
@ -536,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"},
@ -614,75 +649,83 @@ files = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]
[package.dependencies]
certifi = ">=2017.4.17"
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "tomli"
version = "2.3.0"
version = "2.4.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
{file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
{file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
{file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
{file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
{file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
{file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
{file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
{file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
{file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
{file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
{file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
{file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
{file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
{file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
{file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
{file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
{file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
{file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
{file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
{file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
{file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
{file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
{file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
{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"
@ -690,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"},
@ -697,22 +742,23 @@ files = [
[[package]]
name = "urllib3"
version = "2.6.2"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
{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"

View file

@ -1,36 +1,40 @@
[tool.poetry]
[project]
name = "jinjaturtle"
version = "0.3.5"
version = "0.7.0"
description = "Convert config files into Ansible defaults and Jinja2 templates."
authors = ["Miguel Jacq <mig@mig5.net>"]
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"

View file

@ -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
@ -45,29 +40,49 @@ done
# RPM
sudo apt-get -y install createrepo-c rpm
docker build -f Dockerfile.rpmbuild -t jinjaturtle:f42 --progress=plain .
docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out jinjaturtle:f42
sudo chown -R "${USER}" "$PWD/dist"
REPO_ROOT="${HOME}/git/repo_rpm"
RPM_REPO="${REPO_ROOT}/rpm/x86_64"
BUILD_OUTPUT="${HOME}/git/jinjaturtle/dist"
REMOTE="letessier.mig5.net:/opt/repo_rpm"
KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D"
KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99"
REPO_ROOT="${HOME}/git/repo_rpm"
REMOTE="ashpool.mig5.net:/opt/repo_rpm"
echo "==> Updating RPM repo..."
mkdir -p "$RPM_REPO"
DISTS=(
fedora:43
)
for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do
rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file"
for dist in ${DISTS[@]}; do
release=$(echo ${dist} | cut -d: -f2)
REPO_RELEASE_ROOT="${REPO_ROOT}/${release}"
RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64"
mkdir -p "$RPM_REPO"
docker build \
--no-cache \
-f Dockerfile.rpmbuild \
-t jinjaturtle-rpm:${release} \
--progress=plain \
--build-arg BASE_IMAGE=${dist} \
.
rm -rf "$PWD/dist/rpm"/*
mkdir -p "$PWD/dist/rpm"
docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out jinjaturtle-rpm:${release}
sudo chown -R "${USER}" "$PWD/dist"
for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do
rpmsign --addsign "${BUILD_OUTPUT}/rpm/$file"
done
cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/"
createrepo_c "$RPM_REPO"
echo "==> Signing repomd.xml..."
qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc"
done
cp "${BUILD_OUTPUT}/rpm/"*.rpm "$RPM_REPO/"
createrepo_c "$RPM_REPO"
echo "==> Signing repomd.xml..."
qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc"
# 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/"

View file

@ -1,4 +1,4 @@
%global upstream_version 0.3.5
%global upstream_version 0.7.0
Name: jinjaturtle
Version: %{upstream_version}
@ -42,6 +42,27 @@ Convert config files into Ansible defaults and Jinja2 templates.
%{_bindir}/jinjaturtle
%changelog
* Sun Jul 05 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Much hardening
* Wed Jun 24 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- More hardening
* Tue Jun 23 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Try to prevent what could lead to execution of embedded jinja in original files when converting
* Sat Jun 20 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- erb support
* Sat Jun 20 2026 Miguel Jacq <mig@mig5.net> - %{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 <mig@mig5.net> - %{version}-%{release}
- Fix loss of comments and True/False to true/false
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Fix indentation problems with nested dicts
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Empty dicts and lists are now emitted as leaf defaults.
* Mon May 11 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Support ssh configs
* Tue Jan 06 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Support converting systemd files and postfix main.cf
* Tue Dec 30 2025 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Support converting a directory (optionally recursively) instead of just an individual file.
* Sat Dec 27 2025 Miguel Jacq <mig@mig5.net> - %{version}-%{release}

View file

@ -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:
@ -25,7 +30,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
"config",
help=(
"Path to a config file OR a folder containing supported config files. "
"Supported: .toml, .yaml/.yml, .json, .ini/.cfg/.conf, .xml"
"Supported: .toml, .yaml/.yml, .json, .ini/.cfg/.conf, .xml, ssh_config/sshd_config"
),
)
ap.add_argument(
@ -42,7 +47,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
ap.add_argument(
"-f",
"--format",
choices=["ini", "json", "toml", "yaml", "xml"],
choices=["ini", "json", "toml", "yaml", "xml", "postfix", "systemd", "ssh"],
help="Force config format instead of auto-detecting from filename.",
)
ap.add_argument(
@ -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()

View file

@ -4,9 +4,14 @@ from pathlib import Path
from typing import Any, Iterable
import datetime
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,
@ -14,6 +19,9 @@ from .handlers import (
TomlHandler,
YamlHandler,
XmlHandler,
PostfixMainHandler,
SystemdUnitHandler,
SshConfigHandler,
)
@ -25,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.
@ -44,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)
@ -56,20 +106,29 @@ _TOML_HANDLER = TomlHandler()
_YAML_HANDLER = YamlHandler()
_XML_HANDLER = XmlHandler()
_POSTFIX_HANDLER = PostfixMainHandler()
_SYSTEMD_HANDLER = SystemdUnitHandler()
_SSH_HANDLER = SshConfigHandler()
_HANDLERS["ini"] = _INI_HANDLER
_HANDLERS["json"] = _JSON_HANDLER
_HANDLERS["toml"] = _TOML_HANDLER
_HANDLERS["yaml"] = _YAML_HANDLER
_HANDLERS["xml"] = _XML_HANDLER
_HANDLERS["postfix"] = _POSTFIX_HANDLER
_HANDLERS["systemd"] = _SYSTEMD_HANDLER
_HANDLERS["ssh"] = _SSH_HANDLER
def dump_yaml(data: Any, *, sort_keys: bool = True) -> str:
"""Dump YAML using JinjaTurtle's dumper settings.
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,
@ -86,28 +145,226 @@ def make_var_name(role_prefix: str, path: Iterable[str]) -> str:
return BaseHandler.make_var_name(role_prefix, path)
def _read_head(path: Path, max_bytes: int = 65536) -> str:
try:
with path.open("r", encoding="utf-8", errors="replace") as f:
return f.read(max_bytes)
except OSError:
return ""
_SYSTEMD_SUFFIXES: set[str] = {
".service",
".socket",
".target",
".timer",
".path",
".mount",
".automount",
".slice",
".swap",
".scope",
".link",
".netdev",
".network",
}
def _looks_like_systemd(text: str) -> bool:
# Be conservative: many INI-style configs have [section] and key=value.
# systemd unit files almost always contain one of these well-known sections.
if re.search(
r"^\s*\[(Unit|Service|Install|Socket|Timer|Path|Mount|Automount|Slice|Swap|Scope)\]\s*$",
text,
re.M,
) and re.search(r"^\s*\w[\w\-]*\s*=", text, re.M):
return True
return False
def _looks_like_ssh_config(text: str) -> bool:
"""Conservatively sniff OpenSSH config snippets.
This is intentionally stricter than generic key/value detection so random
.conf files are not misclassified. Exact ssh_config/sshd_config filenames
are handled separately above.
"""
meaningful: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
meaningful.append(stripped)
if len(meaningful) >= 20:
break
if not meaningful:
return False
ssh_keywords = {
"acceptenv",
"addressfamily",
"allowgroups",
"allowtcpforwarding",
"allowusers",
"authenticationmethods",
"authorizedkeysfile",
"banner",
"ciphers",
"chrootdirectory",
"denyusers",
"forcecommand",
"forwardagent",
"host",
"hostbasedauthentication",
"hostkey",
"hostname",
"identityfile",
"include",
"kexalgorithms",
"listenaddress",
"loglevel",
"match",
"passwordauthentication",
"permitrootlogin",
"port",
"proxycommand",
"proxyjump",
"pubkeyauthentication",
"sendenv",
"subsystem",
"user",
"x11forwarding",
}
hits = 0
for line in meaningful:
m = re.match(r"^([^\s=#]+)", line)
if not m:
continue
if m.group(1).lower() in ssh_keywords:
hits += 1
return hits >= 2 or (len(meaningful) <= 3 and hits >= 1)
def detect_format(path: Path, explicit: str | None = None) -> str:
"""
Determine config format from argument or filename.
Determine config format.
For unambiguous extensions (json/yaml/toml/xml/ini), we rely on the suffix.
For ambiguous extensions like '.conf' (or no extension), we sniff the content.
"""
if explicit:
return explicit
suffix = path.suffix.lower()
name = path.name.lower()
# Unambiguous extensions
if suffix == ".toml":
return "toml"
if suffix in {".yaml", ".yml"}:
return "yaml"
if suffix == ".json":
return "json"
if suffix in {".ini", ".cfg", ".conf"} or name.endswith(".ini"):
return "ini"
if suffix == ".xml":
return "xml"
# Special-ish INI-like formats
if suffix in {".ini", ".cfg"} or name.endswith(".ini"):
return "ini"
if suffix == ".repo":
return "ini"
# systemd units
if suffix in _SYSTEMD_SUFFIXES:
return "systemd"
# well-known filenames
if name == "main.cf":
return "postfix"
if name in {"ssh_config", "sshd_config"}:
return "ssh"
head = _read_head(path)
# Content sniffing
if _looks_like_systemd(head):
return "systemd"
# Ambiguous .conf/.cf defaults to INI-ish if no better match
if suffix in {".conf", ".cf"}:
if name == "main.cf":
return "postfix"
if _looks_like_ssh_config(head):
return "ssh"
return "ini"
# Fallback: treat as INI-ish
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.
@ -116,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
@ -126,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)
@ -219,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:

98
src/jinjaturtle/escape.py Normal file
View file

@ -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 %}"

View file

@ -8,6 +8,10 @@ from .toml import TomlHandler
from .yaml import YamlHandler
from .xml import XmlHandler
from .postfix import PostfixMainHandler
from .systemd import SystemdUnitHandler
from .ssh import SshConfigHandler
__all__ = [
"BaseHandler",
"DictLikeHandler",
@ -16,4 +20,7 @@ __all__ = [
"TomlHandler",
"YamlHandler",
"XmlHandler",
"PostfixMainHandler",
"SystemdUnitHandler",
"SshConfigHandler",
]

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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}]"
)

View file

@ -0,0 +1,186 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from . import BaseHandler
from .. import j2
from ..escape import escape_jinja_literal
class PostfixMainHandler(BaseHandler):
"""
Handler for Postfix main.cf style configuration.
Postfix main.cf is largely 'key = value' with:
- '#' comments
- continuation lines starting with whitespace (they continue the previous value)
"""
fmt = "postfix"
def parse(self, path: Path) -> dict[str, str]:
text = path.read_text(encoding="utf-8")
return self._parse_text_to_dict(text)
def _parse_text_to_dict(self, text: str) -> dict[str, str]:
lines = text.splitlines()
out: dict[str, str] = {}
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped or stripped.startswith("#"):
i += 1
continue
if "=" not in line:
i += 1
continue
eq_index = line.find("=")
key = line[:eq_index].strip()
if not key:
i += 1
continue
# value + inline comment
after = line[eq_index + 1 :]
value_part, _comment = self._split_inline_comment(after, {"#"})
value = value_part.strip()
# collect continuation lines
j = i + 1
cont_parts: list[str] = []
while j < len(lines):
nxt = lines[j]
if not nxt:
break
if nxt.startswith((" ", "\t")):
if nxt.strip().startswith("#"):
# a commented continuation line - treat as a break
break
cont_parts.append(nxt.strip())
j += 1
continue
break
if cont_parts:
value = " ".join([value] + cont_parts).strip()
out[key] = value
i = j if cont_parts else i + 1
return out
def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]:
if not isinstance(parsed, dict):
raise TypeError("Postfix parse result must be a dict[str, str]")
items: list[tuple[tuple[str, ...], Any]] = []
for k, v in parsed.items():
items.append(((k,), v))
return items
def generate_jinja2_template(
self,
parsed: Any,
role_prefix: str,
original_text: str | None = None,
) -> str:
if original_text is None:
# Canonical render (lossy)
if not isinstance(parsed, dict):
raise TypeError("Postfix parse result must be a dict[str, str]")
lines: list[str] = []
for k, v in parsed.items():
var = self.make_var_name(role_prefix, (k,))
lines.append(f"{k} = {j2.variable(var)}")
return "\n".join(lines).rstrip() + "\n"
return self._generate_from_text(role_prefix, original_text)
def _generate_from_text(self, role_prefix: str, text: str) -> str:
lines = text.splitlines(keepends=True)
out_lines: list[str] = []
i = 0
while i < len(lines):
raw_line = lines[i]
content = raw_line.rstrip("\n")
newline = "\n" if raw_line.endswith("\n") else ""
stripped = content.strip()
if not stripped:
out_lines.append(escape_jinja_literal(raw_line))
i += 1
continue
if stripped.startswith("#"):
out_lines.append(escape_jinja_literal(raw_line))
i += 1
continue
if "=" not in content:
out_lines.append(escape_jinja_literal(raw_line))
i += 1
continue
eq_index = content.find("=")
before_eq = content[:eq_index]
after_eq = content[eq_index + 1 :]
key = before_eq.strip()
if not key:
out_lines.append(escape_jinja_literal(raw_line))
i += 1
continue
# whitespace after '='
value_ws_len = len(after_eq) - len(after_eq.lstrip(" \t"))
leading_ws = after_eq[:value_ws_len]
value_and_comment = after_eq[value_ws_len:]
value_part, comment_part = self._split_inline_comment(
value_and_comment, {"#"}
)
value = value_part.strip()
# collect continuation physical lines to skip
j = i + 1
cont_parts: list[str] = []
while j < len(lines):
nxt_raw = lines[j]
nxt = nxt_raw.rstrip("\n")
if (
nxt.startswith((" ", "\t"))
and nxt.strip()
and not nxt.strip().startswith("#")
):
cont_parts.append(nxt.strip())
j += 1
continue
break
if cont_parts:
value = " ".join([value] + cont_parts).strip()
var = self.make_var_name(role_prefix, (key,))
v = value
# ``before_eq`` (key) and ``comment_part`` are source-derived and may
# contain template metacharacters; escape each around the safe
# placeholder.
safe_before = escape_jinja_literal(before_eq)
safe_comment = escape_jinja_literal(comment_part)
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if quoted:
replacement = (
f"{safe_before}={leading_ws}{j2.quoted_variable(var)}"
f"{safe_comment}{newline}"
)
else:
replacement = (
f"{safe_before}={leading_ws}{j2.variable(var)}"
f"{safe_comment}{newline}"
)
out_lines.append(replacement)
i = j # skip continuation lines (if any)
return "".join(out_lines)

View file

@ -0,0 +1,283 @@
from __future__ import annotations
from dataclasses import dataclass
import re
from pathlib import Path
from typing import Any
from . import BaseHandler
from .. import j2
from ..escape import escape_jinja_literal
_SECTION_KEYWORDS = {"host", "match"}
@dataclass
class SshConfigLine:
kind: str # 'blank' | 'comment' | 'kv' | 'raw'
raw: str
lineno: int
key: str | None = None
value: str | None = None
processed_value: str | None = None
comment: str = ""
whitespace_before_comment: str = ""
before_value: str = ""
newline: str = ""
quoted: bool = False
is_section: bool = False
section_kind: str | None = None
section_label: str | None = None
context_kind: str | None = None
context_label: str | None = None
occ_index: int | None = None
@dataclass
class SshConfig:
lines: list[SshConfigLine]
class SshConfigHandler(BaseHandler):
"""
Handler for OpenSSH ssh_config/sshd_config-style files.
The format is intentionally treated as keyword + raw argument string rather
than attempting to understand the argument type for every OpenSSH keyword.
This preserves directives whose values are naturally whitespace-separated,
comma-separated, colon-separated, commands, token strings, or quoted strings.
Host and Match are treated as section headers for variable naming while
still templating their criteria like ordinary keyword/argument lines.
"""
fmt = "ssh"
def parse(self, path: Path) -> SshConfig:
text = path.read_text(encoding="utf-8")
return self._parse_text(text)
def _parse_text(self, text: str) -> SshConfig:
lines = text.splitlines(keepends=True)
out: list[SshConfigLine] = []
current_kind: str | None = None
current_label: str | None = None
for lineno, raw_line in enumerate(lines, start=1):
content, newline = self._split_newline(raw_line)
stripped = content.strip()
if not stripped:
out.append(
SshConfigLine(
kind="blank", raw=raw_line, lineno=lineno, newline=newline
)
)
continue
if content.lstrip(" \t").startswith("#"):
out.append(
SshConfigLine(
kind="comment", raw=raw_line, lineno=lineno, newline=newline
)
)
continue
parsed = self._split_keyword_value(content)
if parsed is None:
out.append(
SshConfigLine(
kind="raw", raw=raw_line, lineno=lineno, newline=newline
)
)
continue
key, before_value, value_and_comment = parsed
value_part, comment = self._split_inline_comment(value_and_comment, {"#"})
whitespace_before_comment = value_part[len(value_part.rstrip(" \t")) :]
raw_value = value_part.strip()
quoted = (
len(raw_value) >= 2
and raw_value[0] == raw_value[-1]
and raw_value[0] in {'"', "'"}
)
processed_value = raw_value[1:-1] if quoted else raw_value
key_lower = key.lower()
is_section = key_lower in _SECTION_KEYWORDS
section_kind = key if is_section else None
section_label = None
context_kind = current_kind
context_label = current_label
if is_section:
# Host/Match lines define the context for subsequent lines, but
# the line itself is kept globally named as the section marker.
base_label = self._section_label(processed_value, lineno)
section_label = base_label
current_kind = key
current_label = base_label
context_kind = None
context_label = None
out.append(
SshConfigLine(
kind="kv",
raw=raw_line,
lineno=lineno,
key=key,
value=raw_value,
processed_value=processed_value,
comment=comment,
whitespace_before_comment=whitespace_before_comment,
before_value=before_value,
newline=newline,
quoted=quoted,
is_section=is_section,
section_kind=section_kind,
section_label=section_label,
context_kind=context_kind,
context_label=context_label,
)
)
unit = SshConfig(lines=out)
self._assign_occurrences(unit)
return unit
@staticmethod
def _split_newline(raw_line: str) -> tuple[str, str]:
if raw_line.endswith("\r\n"):
return raw_line[:-2], "\r\n"
if raw_line.endswith("\n"):
return raw_line[:-1], "\n"
return raw_line, ""
@staticmethod
def _split_keyword_value(content: str) -> tuple[str, str, str] | None:
"""Return (key, text_before_value, value_and_comment)."""
m = re.match(r"^([ \t]*)([^\s=#]+)(.*)$", content)
if not m:
return None
leading, key, rest = m.groups()
# Keep the original spelling/indentation for rendering, but allow either:
# Keyword value
# Keyword=value
# Keyword = value
i = 0
while i < len(rest) and rest[i] in " \t":
i += 1
if i < len(rest) and rest[i] == "=":
i += 1
while i < len(rest) and rest[i] in " \t":
i += 1
elif i == 0 and rest:
# Some unexpected non-whitespace separator. Treat as raw.
return None
before_value = leading + key + rest[:i]
value_and_comment = rest[i:]
return key, before_value, value_and_comment
def _section_label(self, value: str | None, lineno: int) -> str:
raw = (value or "").strip()
if not raw:
return f"line_{lineno}"
# Use the same sanitisation semantics as variable names, but without a
# role prefix. This keeps labels such as '*' useful instead of empty.
label = self.make_var_name("x", (raw,))
if label.startswith("x_"):
label = label[2:]
elif label == "x":
label = "all"
label = re.sub(r"_+", "_", label).strip("_")
return label or f"line_{lineno}"
def _base_path(self, ln: SshConfigLine) -> tuple[str, ...]:
if ln.kind != "kv" or not ln.key:
return ()
if ln.is_section and ln.section_kind and ln.section_label:
return (ln.section_kind, ln.section_label)
if ln.context_kind and ln.context_label:
return (ln.context_kind, ln.context_label, ln.key)
return (ln.key,)
def _assign_occurrences(self, parsed: SshConfig) -> None:
counts: dict[tuple[str, ...], int] = {}
for ln in parsed.lines:
if ln.kind == "kv":
base = self._base_path(ln)
if base:
counts[base] = counts.get(base, 0) + 1
seen: dict[tuple[str, ...], int] = {}
for ln in parsed.lines:
if ln.kind != "kv":
continue
base = self._base_path(ln)
if not base:
continue
idx = seen.get(base, 0)
seen[base] = idx + 1
if counts.get(base, 0) > 1:
ln.occ_index = idx
def _path_for_line(self, ln: SshConfigLine) -> tuple[str, ...]:
base = self._base_path(ln)
if ln.occ_index is not None:
return base + (str(ln.occ_index),)
return base
def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]:
if not isinstance(parsed, SshConfig):
raise TypeError("SSH config parse result must be an SshConfig")
items: list[tuple[tuple[str, ...], Any]] = []
for ln in parsed.lines:
if ln.kind != "kv":
continue
path = self._path_for_line(ln)
if not path:
continue
items.append((path, ln.processed_value or ""))
return items
def generate_jinja2_template(
self,
parsed: Any,
role_prefix: str,
original_text: str | None = None,
) -> str:
if not isinstance(parsed, SshConfig):
raise TypeError("SSH config parse result must be an SshConfig")
out_lines: list[str] = []
for ln in parsed.lines:
if ln.kind != "kv":
out_lines.append(escape_jinja_literal(ln.raw))
continue
path = self._path_for_line(ln)
if not path:
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 = j2.quoted_variable(var, quote_char)
else:
replacement_value = j2.variable(var)
# ``before_value`` (keyword + spacing) and ``comment`` are
# source-derived; escape around the safe placeholder.
rendered = (
f"{escape_jinja_literal(ln.before_value)}{replacement_value}"
f"{ln.whitespace_before_comment}"
f"{escape_jinja_literal(ln.comment)}{ln.newline}"
)
out_lines.append(rendered)
return "".join(out_lines)

View file

@ -0,0 +1,193 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from . import BaseHandler
from .. import j2
from ..escape import escape_jinja_literal
@dataclass
class SystemdLine:
kind: str # 'blank' | 'comment' | 'section' | 'kv' | 'raw'
raw: str
lineno: int
section: str | None = None
key: str | None = None
value: str | None = None
comment: str = ""
before_eq: str = ""
leading_ws_after_eq: str = ""
occ_index: int | None = None
@dataclass
class SystemdUnit:
lines: list[SystemdLine]
class SystemdUnitHandler(BaseHandler):
"""
Handler for systemd unit files.
unit files are INI-like, but keys may repeat (e.g. multiple ExecStart= lines).
We preserve repeated keys by indexing them when flattening and templating.
"""
fmt = "systemd"
def parse(self, path: Path) -> SystemdUnit:
text = path.read_text(encoding="utf-8")
return self._parse_text(text)
def _parse_text(self, text: str) -> SystemdUnit:
lines = text.splitlines(keepends=True)
out: list[SystemdLine] = []
current_section: str | None = None
# counts per section+key to assign occ_index
occ: dict[tuple[str, str], int] = {}
for lineno, raw_line in enumerate(lines, start=1):
content = raw_line.rstrip("\n")
stripped = content.strip()
if not stripped:
out.append(SystemdLine(kind="blank", raw=raw_line, lineno=lineno))
continue
if stripped.startswith(("#", ";")):
out.append(SystemdLine(kind="comment", raw=raw_line, lineno=lineno))
continue
# section header
if (
stripped.startswith("[")
and stripped.endswith("]")
and len(stripped) >= 2
):
sec = stripped[1:-1].strip()
current_section = sec
out.append(
SystemdLine(
kind="section", raw=raw_line, lineno=lineno, section=sec
)
)
continue
if "=" not in content:
out.append(SystemdLine(kind="raw", raw=raw_line, lineno=lineno))
continue
eq_index = content.find("=")
before_eq = content[:eq_index]
after_eq = content[eq_index + 1 :]
key = before_eq.strip()
if not key:
out.append(SystemdLine(kind="raw", raw=raw_line, lineno=lineno))
continue
# whitespace after '='
value_ws_len = len(after_eq) - len(after_eq.lstrip(" \t"))
leading_ws = after_eq[:value_ws_len]
value_and_comment = after_eq[value_ws_len:]
value_part, comment = self._split_inline_comment(
value_and_comment, {"#", ";"}
)
value = value_part.strip()
sec = current_section or "DEFAULT"
k = (sec, key)
idx = occ.get(k, 0)
occ[k] = idx + 1
out.append(
SystemdLine(
kind="kv",
raw=raw_line,
lineno=lineno,
section=sec,
key=key,
value=value,
comment=comment,
before_eq=before_eq,
leading_ws_after_eq=leading_ws,
occ_index=idx,
)
)
return SystemdUnit(lines=out)
def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]:
if not isinstance(parsed, SystemdUnit):
raise TypeError("systemd parse result must be a SystemdUnit")
# determine duplicates per (section,key)
counts: dict[tuple[str, str], int] = {}
for ln in parsed.lines:
if ln.kind == "kv" and ln.section and ln.key:
counts[(ln.section, ln.key)] = counts.get((ln.section, ln.key), 0) + 1
items: list[tuple[tuple[str, ...], Any]] = []
for ln in parsed.lines:
if ln.kind != "kv" or not ln.section or not ln.key:
continue
path: tuple[str, ...] = (ln.section, ln.key)
if counts.get((ln.section, ln.key), 0) > 1 and ln.occ_index is not None:
path = path + (str(ln.occ_index),)
items.append((path, ln.value or ""))
return items
def generate_jinja2_template(
self,
parsed: Any,
role_prefix: str,
original_text: str | None = None,
) -> str:
if not isinstance(parsed, SystemdUnit):
raise TypeError("systemd parse result must be a SystemdUnit")
# We template using parsed lines so we preserve original formatting/comments.
counts: dict[tuple[str, str], int] = {}
for ln in parsed.lines:
if ln.kind == "kv" and ln.section and ln.key:
counts[(ln.section, ln.key)] = counts.get((ln.section, ln.key), 0) + 1
out_lines: list[str] = []
for ln in parsed.lines:
if ln.kind != "kv" or not ln.section or not ln.key:
# Verbatim lines (blank/comment/section/unrecognised "raw")
# originate from the source file. Escape template
# metacharacters so they cannot become live template code.
# This is the only handler that emits unrecognised lines, which
# is where Jinja *statement* injection (``{% ... %}``) was
# possible, so escaping here is essential.
out_lines.append(escape_jinja_literal(ln.raw))
continue
path: tuple[str, ...] = (ln.section, ln.key)
if counts.get((ln.section, ln.key), 0) > 1 and ln.occ_index is not None:
path = path + (str(ln.occ_index),)
var = self.make_var_name(role_prefix, path)
v = (ln.value or "").strip()
safe_before = escape_jinja_literal(ln.before_eq)
safe_comment = escape_jinja_literal(ln.comment)
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if quoted:
repl = (
f"{safe_before}={ln.leading_ws_after_eq}"
f"{j2.quoted_variable(var)}{safe_comment}"
)
else:
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)
return "".join(out_lines)

View file

@ -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)

View file

@ -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 ``<!-- {{ cmd.run('id') }} -->`` would otherwise become live
template code. Element/attribute *names* cannot carry Jinja delimiters
(XML naming rules forbid the characters and the parser rejects them), and
text/attribute *values* are already replaced with ``{{ var }}``
placeholders, so comments (and the prolog, handled separately) are the
only XML injection vector.
JinjaTurtle's own internal marker comments are left untouched so they can
be converted into real loops/conditionals later.
"""
# ET represents comments with a callable tag (ET.Comment). Iterate all
# descendants and escape comment text that is not one of our markers.
for elem in root.iter():
if elem.tag is ET.Comment:
if not self._is_jt_marker(elem.text or ""):
elem.text = escape_jinja_literal(elem.text or "")
def _generate_xml_template_from_text(self, role_prefix: str, text: str) -> str:
"""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 <!--ENDIF:field--> with {% endif %}
elif "<!--ENDIF:" in line:
indent = len(line) - len(line.lstrip())
final_lines.append(f"{' ' * indent}{{% endif %}}")
final_lines.append(f"{' ' * indent}{j2.endif()}")
else:
final_lines.append(line)
@ -416,13 +459,13 @@ class XmlHandler(BaseHandler):
# Attribute - these come from element attributes
attr_name = key[1:] # Remove @ prefix
# Use simple variable reference - attributes should always exist
elem.set(attr_name, f"{{{{ {loop_var}.{attr_name} }}}}")
elem.set(attr_name, j2.variable(f"{loop_var}.{attr_name}"))
elif key == "_text":
# Simple text content - use ._text accessor for dict-based items
elem.text = f"{{{{ {loop_var}._text }}}}"
elem.text = j2.variable(f"{loop_var}._text")
elif key == "value":
# Text with attributes/children
elem.text = f"{{{{ {loop_var}.value }}}}"
elem.text = j2.variable(f"{loop_var}.value")
elif key == "_key":
# This is the dict key (for dict collections), skip in XML
pass
@ -431,13 +474,13 @@ class XmlHandler(BaseHandler):
# Create a conditional wrapper comment
child = ET.Element(key)
if "_text" in value:
child.text = f"{{{{ {loop_var}.{key}._text }}}}"
child.text = j2.variable(f"{loop_var}.{key}._text")
else:
# More complex nested structure
for sub_key, sub_val in value.items():
if not sub_key.startswith("_"):
grandchild = ET.SubElement(child, sub_key)
grandchild.text = f"{{{{ {loop_var}.{key}.{sub_key} }}}}"
grandchild.text = j2.variable(f"{loop_var}.{key}.{sub_key}")
# Wrap the child in a Jinja if statement (will be done via text replacement)
# For now, add a marker comment before the element
@ -452,7 +495,7 @@ class XmlHandler(BaseHandler):
marker = ET.Comment(f"IF:{loop_var}.{key}")
elem.append(marker)
child = ET.SubElement(elem, key)
child.text = f"{{{{ {loop_var}.{key} }}}}"
child.text = j2.variable(f"{loop_var}.{key}")
end_marker = ET.Comment(f"ENDIF:{key}")
elem.append(end_marker)

View file

@ -5,9 +5,42 @@ from pathlib import Path
from typing import Any
from .dict import DictLikeHandler
from .. import j2
from ..escape import escape_jinja_literal
from ..loop_analyzer import is_safe_loop_field_key
from ..loop_analyzer import LoopCandidate
def _reject_recursive_structure(obj: Any) -> None:
"""Raise ``yaml.YAMLError`` if *obj* contains a reference cycle.
A recursive YAML anchor (``a: &a [*a]``) produces a container that contains
itself. Every consumer in JinjaTurtle walks the parsed object depth-first,
so a cycle would raise ``RecursionError`` deep in unrelated code. Detect it
up front by tracking the ``id()`` of containers on the current descent path;
a repeat means a cycle. ``yaml.YAMLError`` is raised so ``parse_config``
normalises it into a clean ``ConfigParseError`` like any other malformed
input, rather than surfacing a stack-overflow traceback.
"""
on_path: set[int] = set()
def walk(node: Any) -> None:
if isinstance(node, (dict, list)):
marker = id(node)
if marker in on_path:
raise yaml.YAMLError(
"recursive/self-referential YAML structure is not supported"
)
on_path.add(marker)
children = node.values() if isinstance(node, dict) else node
for child in children:
walk(child)
on_path.discard(marker)
walk(obj)
class YamlHandler(DictLikeHandler):
"""
YAML handler that can generate both scalar templates and loop-based templates.
@ -18,7 +51,16 @@ class YamlHandler(DictLikeHandler):
def parse(self, path: Path) -> Any:
text = path.read_text(encoding="utf-8")
return yaml.safe_load(text) or {}
parsed = yaml.safe_load(text) or {}
# PyYAML's safe_load happily builds *recursive* structures from an anchor
# that references itself (e.g. ``a: &a [*a]``). Downstream flattening,
# timestamp-stringifying and template generation all walk the parsed
# object recursively and would blow the Python stack (RecursionError) on
# such input. JinjaTurtle is regularly pointed at harvested,
# attacker-influenceable config, so reject a self-referential document
# cleanly here rather than crashing later.
_reject_recursive_structure(parsed)
return parsed
def generate_jinja2_template(
self,
@ -59,6 +101,28 @@ class YamlHandler(DictLikeHandler):
role_prefix, dumped, loop_candidates, loop_paths
)
def _yaml_scalar_expr(self, var_name: str, raw_value: str | None = None) -> str:
"""Return a Jinja expression that preserves YAML scalar spelling.
Plain ``{{ var }}`` renders Python booleans as ``True``/``False``.
YAML config files conventionally use ``true``/``false`` and some
consumers are stricter than PyYAML, so emit explicit YAML spelling for
values that were originally YAML booleans/nulls.
"""
return j2.yaml_scalar_expression(var_name, raw_value)
def _yaml_value_expr(self, value_expr: str, sample_value: Any | None = None) -> str:
return j2.yaml_value_expression(value_expr, sample_value)
def _surrounding_quote(self, raw_value: str) -> str | None:
if (
len(raw_value) >= 2
and raw_value[0] == raw_value[-1]
and raw_value[0] in {'"', "'"}
):
return raw_value[0]
return None
def _generate_yaml_template_from_text(
self,
role_prefix: str,
@ -79,7 +143,7 @@ class YamlHandler(DictLikeHandler):
indent = len(raw_line) - len(stripped)
if not stripped or stripped.startswith("#"):
out_lines.append(raw_line)
out_lines.append(escape_jinja_literal(raw_line))
continue
while stack and indent < stack[-1][0]:
@ -89,7 +153,7 @@ class YamlHandler(DictLikeHandler):
key_part, rest = stripped.split(":", 1)
key = key_part.strip()
if not key:
out_lines.append(raw_line)
out_lines.append(escape_jinja_literal(raw_line))
continue
rest_stripped = rest.lstrip(" \t")
@ -102,7 +166,7 @@ class YamlHandler(DictLikeHandler):
stack.append((indent, path, "map"))
if not has_value:
out_lines.append(raw_line)
out_lines.append(escape_jinja_literal(raw_line))
continue
value_part, comment_part = self._split_inline_comment(
@ -119,13 +183,13 @@ class YamlHandler(DictLikeHandler):
if use_quotes:
q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}"
replacement = j2.quoted_variable(var_name, q)
else:
replacement = f"{{{{ {var_name} }}}}"
replacement = self._yaml_scalar_expr(var_name, raw_value)
leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
new_rest = f"{leading}{replacement}{comment_part}"
new_stripped = f"{key}:{new_rest}"
new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}"
new_stripped = f"{escape_jinja_literal(key)}:{new_rest}"
out_lines.append(
" " * indent
+ new_stripped
@ -158,11 +222,11 @@ class YamlHandler(DictLikeHandler):
if use_quotes:
q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}"
replacement = j2.quoted_variable(var_name, q)
else:
replacement = f"{{{{ {var_name} }}}}"
replacement = self._yaml_scalar_expr(var_name, raw_value)
new_stripped = f"- {replacement}{comment_part}"
new_stripped = f"- {replacement}{escape_jinja_literal(comment_part)}"
out_lines.append(
" " * indent
+ new_stripped
@ -170,7 +234,7 @@ class YamlHandler(DictLikeHandler):
)
continue
out_lines.append(raw_line)
out_lines.append(escape_jinja_literal(raw_line))
return "".join(out_lines)
@ -204,24 +268,83 @@ class YamlHandler(DictLikeHandler):
def current_path() -> tuple[str, ...]:
return stack[-1][1] if stack else ()
for raw_line in lines:
def first_sequence_item_style(
start_index: int, parent_indent: int
) -> tuple[str | None, int | None]:
for future_line in lines[start_index + 1 :]:
future_stripped = future_line.lstrip()
future_indent = len(future_line) - len(future_stripped)
if not future_stripped or future_stripped.startswith("#"):
continue
if future_indent < parent_indent:
return None, None
if future_stripped.startswith("- "):
value_part, _comment_part = self._split_inline_comment(
future_stripped[2:], {"#"}
)
return self._surrounding_quote(value_part.strip()), future_indent
if future_indent <= parent_indent:
return None, None
return None, None
def next_significant_line(index: int) -> tuple[int, str] | None:
for future_line in lines[index + 1 :]:
future_stripped = future_line.lstrip()
if not future_stripped.strip() or future_stripped.startswith("#"):
continue
return len(future_line) - len(future_stripped), future_stripped
return None
for line_index, raw_line in enumerate(lines):
stripped = raw_line.lstrip()
is_blank = not stripped.strip()
is_comment = stripped.startswith("#")
indent = len(raw_line) - len(stripped)
# If we're skipping lines (inside a loop section), check if we can stop
# If we're skipping lines inside a collection replaced by a loop,
# continue through YAML's indentless sequence style too, where list
# items can appear at the same indentation as the parent key:
#
# images:
# - ubuntu
# - debian
#
# Stop only when a non-list item at the parent indentation appears,
# or when indentation moves above the parent collection.
if skip_until_indent is not None:
if (
indent <= skip_until_indent
and stripped
and not stripped.startswith("#")
if is_blank:
next_line = next_significant_line(line_index)
if next_line is None:
skip_until_indent = None
out_lines.append(escape_jinja_literal(raw_line))
else:
next_indent, next_stripped = next_line
still_in_collection = next_indent > skip_until_indent or (
next_indent == skip_until_indent
and next_stripped.startswith("- ")
)
if not still_in_collection:
skip_until_indent = None
out_lines.append(escape_jinja_literal(raw_line))
continue
if is_comment:
if indent <= skip_until_indent:
skip_until_indent = None
out_lines.append(escape_jinja_literal(raw_line))
# Comments/blank lines indented beneath the replaced
# collection are considered part of that collection and
# cannot be placed safely inside a generated loop.
continue
if indent < skip_until_indent or (
indent == skip_until_indent and not stripped.startswith("- ")
):
skip_until_indent = None
else:
continue # Skip this line
# Blank or comment lines
if not stripped or stripped.startswith("#"):
out_lines.append(raw_line)
if is_blank or is_comment:
out_lines.append(escape_jinja_literal(raw_line))
continue
# Adjust stack based on indent
@ -233,7 +356,7 @@ class YamlHandler(DictLikeHandler):
key_part, rest = stripped.split(":", 1)
key = key_part.strip()
if not key:
out_lines.append(raw_line)
out_lines.append(escape_jinja_literal(raw_line))
continue
rest_stripped = rest.lstrip(" \t")
@ -250,8 +373,20 @@ class YamlHandler(DictLikeHandler):
# Find the matching candidate
candidate = next(c for c in loop_candidates if c.path == path)
scalar_quote, item_indent = first_sequence_item_style(
line_index, indent
)
if candidate.item_schema != "scalar":
scalar_quote = None
# Generate loop
loop_str = self._generate_yaml_loop(candidate, role_prefix, indent)
loop_str = self._generate_yaml_loop(
candidate,
role_prefix,
indent,
scalar_quote=scalar_quote,
item_indent=item_indent,
)
out_lines.append(loop_str)
# Skip subsequent lines that are part of this collection
@ -259,7 +394,7 @@ class YamlHandler(DictLikeHandler):
continue
if not has_value:
out_lines.append(raw_line)
out_lines.append(escape_jinja_literal(raw_line))
continue
# Scalar value - replace with variable
@ -277,13 +412,13 @@ class YamlHandler(DictLikeHandler):
if use_quotes:
q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}"
replacement = j2.quoted_variable(var_name, q)
else:
replacement = f"{{{{ {var_name} }}}}"
replacement = self._yaml_scalar_expr(var_name, raw_value)
leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
new_rest = f"{leading}{replacement}{comment_part}"
new_stripped = f"{key}:{new_rest}"
new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}"
new_stripped = f"{escape_jinja_literal(key)}:{new_rest}"
out_lines.append(
" " * indent
+ new_stripped
@ -298,6 +433,10 @@ class YamlHandler(DictLikeHandler):
stack.append((indent, parent_path, "seq"))
parent_path = stack[-1][1]
content = stripped[2:]
value_part, _comment_part = self._split_inline_comment(content, {"#"})
raw_value = value_part.strip()
scalar_quote = self._surrounding_quote(raw_value)
# Check if parent path is a loop candidate
if parent_path in loop_paths:
@ -308,7 +447,11 @@ class YamlHandler(DictLikeHandler):
# Generate loop (with indent for the '-' items)
loop_str = self._generate_yaml_loop(
candidate, role_prefix, indent, is_list=True
candidate,
role_prefix,
indent,
is_list=True,
scalar_quote=scalar_quote,
)
out_lines.append(loop_str)
@ -316,7 +459,6 @@ class YamlHandler(DictLikeHandler):
skip_until_indent = indent - 1 if indent > 0 else None
continue
content = stripped[2:]
index = seq_counters.get(parent_path, 0)
seq_counters[parent_path] = index + 1
@ -334,11 +476,11 @@ class YamlHandler(DictLikeHandler):
if use_quotes:
q = raw_value[0]
replacement = f"{q}{{{{ {var_name} }}}}{q}"
replacement = j2.quoted_variable(var_name, q)
else:
replacement = f"{{{{ {var_name} }}}}"
replacement = self._yaml_scalar_expr(var_name, raw_value)
new_stripped = f"- {replacement}{comment_part}"
new_stripped = f"- {replacement}{escape_jinja_literal(comment_part)}"
out_lines.append(
" " * indent
+ new_stripped
@ -346,7 +488,7 @@ class YamlHandler(DictLikeHandler):
)
continue
out_lines.append(raw_line)
out_lines.append(escape_jinja_literal(raw_line))
return "".join(out_lines)
@ -356,6 +498,8 @@ class YamlHandler(DictLikeHandler):
role_prefix: str,
indent: int,
is_list: bool = False,
scalar_quote: str | None = None,
item_indent: int | None = None,
) -> str:
"""
Generate a Jinja2 for loop for a YAML collection.
@ -374,41 +518,45 @@ class YamlHandler(DictLikeHandler):
collection_var = self.make_var_name(role_prefix, candidate.path)
item_var = candidate.loop_var
lines = []
lines: list[str] = []
if not is_list:
# Dict-style: key: {% for ... %}
key = candidate.path[-1] if candidate.path else "items"
lines.append(f"{indent_str}{key}:")
lines.append(f"{indent_str} {{% for {item_var} in {collection_var} -%}}")
else:
# List-style: just the loop
lines.append(f"{indent_str}{{% for {item_var} in {collection_var} -%}}")
lines.append(f"{indent_str}{escape_jinja_literal(str(key))}:")
# Generate template for item structure
item_lines: list[str] = []
if candidate.items:
sample_item = candidate.items[0]
item_indent = indent + 2 if not is_list else indent
effective_item_indent = (
item_indent if item_indent is not None else indent + 2
)
if is_list:
effective_item_indent = indent
item_indent_str = " " * effective_item_indent
if candidate.item_schema == "scalar":
# Simple list of scalars
if is_list:
lines.append(f"{indent_str}- {{{{ {item_var} }}}}")
else:
lines.append(f"{indent_str} - {{{{ {item_var} }}}}")
value_expr = self._yaml_value_expr(item_var, sample_item)
if scalar_quote and isinstance(sample_item, str):
value_expr = j2.quoted_variable(item_var, scalar_quote)
item_lines.append(f"{item_indent_str}- {value_expr}")
elif candidate.item_schema in ("simple_dict", "nested"):
# List of dicts or complex items - these are ALWAYS list items in YAML
item_lines = self._dict_to_yaml_lines(
sample_item, item_var, item_indent, is_list_item=True
)
lines.extend(item_lines)
# Close loop
close_indent = indent + 2 if not is_list else indent
lines.append(f"{' ' * close_indent}{{% endfor %}}")
if item_lines:
# Put the first YAML item on the same physical line as the Jinja
# ``for`` tag. With default Jinja whitespace settings this avoids
# rendering a blank line after the parent key. Keeping the control
# tag itself at column zero prevents its indentation from leaking
# into the rendered YAML and nesting the next top-level key.
lines.append(f"{j2.for_start(item_var, collection_var)}{item_lines[0]}")
lines.extend(item_lines[1:])
lines.append(j2.for_end(keep_trailing_newline=True))
else:
lines.append(j2.for_start(item_var, collection_var))
lines.append(j2.for_end(keep_trailing_newline=True))
return "\n".join(lines) + "\n"
return "\n".join(lines)
def _dict_to_yaml_lines(
self,
@ -439,13 +587,31 @@ class YamlHandler(DictLikeHandler):
# Special key for dict collections - output as comment or skip
continue
# Defence in depth: the loop analyzer already refuses a dict-loop
# whose items contain a non-identifier key (see _analyze_dict_schema),
# so ``key`` should always be a plain identifier here. Assert it
# rather than interpolate a raw key into a Jinja reference: a key such
# as ``a }}{{ x`` would otherwise close the placeholder and inject a
# live construct that the output 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}"
)
if first_key and is_list_item:
# First key gets the list marker
lines.append(f"{indent_str}- {key}: {{{{ {loop_var}.{key} }}}}")
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
lines.append(
f"{indent_str}- {escape_jinja_literal(str(key))}: {value_expr}"
)
first_key = False
else:
# Subsequent keys are indented
sub_indent = indent + 2 if is_list_item else indent
lines.append(f"{' ' * sub_indent}{key}: {{{{ {loop_var}.{key} }}}}")
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
lines.append(
f"{' ' * sub_indent}{escape_jinja_literal(str(key))}: {value_expr}"
)
return lines

101
src/jinjaturtle/j2.py Normal file
View file

@ -0,0 +1,101 @@
from __future__ import annotations
from typing import Any
TEMPLATE_EXTENSION = "j2"
JSON_VALUE_FILTER = "to_json(ensure_ascii=False)"
def expression(value: str) -> str:
"""Return a Jinja2 output expression for an already-built expression body."""
return f"{{{{ {value} }}}}"
def variable(name: str) -> str:
"""Return a Jinja2 output expression for a variable name."""
return expression(name)
def quoted_variable(name: str, quote: str = '"') -> str:
"""Return a quoted Jinja2 variable placeholder."""
return f"{quote}{variable(name)}{quote}"
def filtered(value: str, filter_expression: str) -> str:
"""Return a Jinja2 output expression with one filter expression applied."""
return expression(f"{value} | {filter_expression}")
def lower(value: str) -> str:
"""Return a Jinja2 expression that renders a value through ``| lower``."""
return filtered(value, "lower")
def to_json(
value: str, *, indent: int | None = None, ensure_ascii: bool = False
) -> str:
"""Return a Jinja2 expression using Ansible's ``to_json`` filter."""
args: list[str] = []
if indent is not None:
args.append(f"indent={indent}")
args.append(f"ensure_ascii={ensure_ascii}")
return filtered(value, f"to_json({', '.join(args)})")
def statement(
value: str,
*,
trim_right: bool = False,
keep_trailing_newline: bool = False,
) -> str:
"""Return a Jinja2 statement tag for an already-built statement body."""
if trim_right and keep_trailing_newline:
raise ValueError("trim_right and keep_trailing_newline are mutually exclusive")
if trim_right:
return f"{{% {value} -%}}"
if keep_trailing_newline:
return f"{{% {value} +%}}"
return f"{{% {value} %}}"
def if_defined(name: str) -> str:
return statement(f"if {name} is defined")
def if_not_loop_last() -> str:
return statement("if not loop.last")
def endif() -> str:
return statement("endif")
def for_start(item_var: str, collection_var: str) -> str:
return statement(f"for {item_var} in {collection_var}")
def for_end(*, trim_right: bool = False, keep_trailing_newline: bool = False) -> str:
return statement(
"endfor",
trim_right=trim_right,
keep_trailing_newline=keep_trailing_newline,
)
def yaml_scalar_expression(var_name: str, raw_value: str | None = None) -> str:
"""Return a YAML-safe Jinja2 expression for a scalar value."""
raw = (raw_value or "").strip().lower()
if raw in {"true", "false"}:
return expression(f"'true' if {var_name} else 'false'")
if raw in {"null", "~"}:
return expression(f"'null' if {var_name} is none else {var_name}")
return variable(var_name)
def yaml_value_expression(value_expr: str, sample_value: Any | None = None) -> str:
"""Return a YAML-safe Jinja2 expression for a possibly typed sample value."""
if isinstance(sample_value, bool):
return expression(f"'true' if {value_expr} else 'false'")
if sample_value is None:
return expression(f"'null' if {value_expr} is none else {value_expr}")
return expression(value_expr)

View file

@ -7,10 +7,34 @@ instead of flattened scalar variables.
from __future__ import annotations
import re
from collections import Counter
from typing import Any, Literal
# A dict-loop emits per-item field references of the form ``loopvar.<field>``.
# ``<field>`` is derived from a source key, which is attacker-influenceable when
# JinjaTurtle is fed harvested config. The output-safety gate only accepts a
# reference whose every hop matches this identifier class, so a key must reduce
# to exactly this shape before it can be used as a loop-item field. Anything else
# (a key containing ``}}``, quotes, ``.``, ``__``, ...) must not be turned into a
# loop; the caller falls back to scalar generation, where every value goes through
# make_var_name() and all verbatim text is escaped.
_SAFE_LOOP_FIELD_RE = re.compile(r"(?!\w*__)[A-Za-z_][A-Za-z0-9_]*\Z")
def is_safe_loop_field_key(key: Any) -> bool:
"""Return True if *key* is safe to emit as a ``loopvar.<key>`` field access.
The check mirrors the output-safety gate's identifier class (single
identifier, no double underscore). A key that does not match cannot be
expressed as a dotted loop-item reference without risking template
injection, so a loop candidate containing such a key is rejected upstream.
"""
return bool(_SAFE_LOOP_FIELD_RE.match(str(key)))
class LoopCandidate:
"""
Represents a detected loop opportunity in the config structure.
@ -265,6 +289,19 @@ class LoopAnalyzer:
if not dicts:
return "heterogeneous"
# Security: a dict-loop emits ``loopvar.<key>`` field references. If any
# item key is not a plain identifier, it cannot be expressed safely as a
# dotted reference (a key such as ``a }}{{ x`` would break out of the
# placeholder and inject a live construct). Refuse the loop so the caller
# falls back to scalar generation, which escapes all verbatim text and
# routes every value through make_var_name().
for d in dicts:
for k in d.keys():
if k == "_key":
continue
if not is_safe_loop_field_key(k):
return "heterogeneous"
# Get key sets from each dict
key_sets = [set(d.keys()) for d in dicts]

View file

@ -16,33 +16,60 @@ Notes:
* If the folder contains *multiple* formats, we generate one template per
format (e.g. config.yaml.j2, config.xml.j2) and emit one list variable per
format in the defaults YAML.
* JSON union templates are emitted using a simple `{{ data | tojson }}`
* JSON union templates are emitted using a simple `{{ data | to_json }}`
approach to avoid comma-management complexity for optional keys.
"""
from collections import Counter, defaultdict
from copy import deepcopy
import os
import configparser
import stat
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
import xml.etree.ElementTree as ET # nosec
from .core import dump_yaml, flatten_config, make_var_name, parse_config
from . import j2
from .core import (
dump_yaml,
flatten_config,
make_var_name,
parse_config,
ConfigParseError,
)
from .handlers.xml import XmlHandler
from .safety import verify_jinja2_template_safe, verify_no_live_jinja_in_json_keys
from .escape import escape_jinja_literal
SUPPORTED_SUFFIXES: dict[str, set[str]] = {
"toml": {".toml"},
"yaml": {".yaml", ".yml"},
"json": {".json"},
"ini": {".ini", ".cfg", ".conf"},
"ini": {".ini", ".cfg", ".conf", ".repo"},
"xml": {".xml"},
}
def _lstat(path: Path) -> os.stat_result:
return path.lstat()
def is_supported_file(path: Path) -> bool:
if not path.is_file():
"""Return True only for real regular files with supported suffixes.
pathlib.Path.is_file() follows symlinks. Folder mode must not follow
attacker-controlled symlinks when run over an untrusted tree, especially if
an administrator accidentally runs the CLI as root.
"""
try:
st = _lstat(path)
except FileNotFoundError:
return False
if not stat.S_ISREG(st.st_mode):
return False
suffix = path.suffix.lower()
for exts in SUPPORTED_SUFFIXES.values():
@ -52,11 +79,16 @@ def is_supported_file(path: Path) -> bool:
def iter_supported_files(root: Path, recursive: bool) -> list[Path]:
if not root.exists():
try:
st = _lstat(root)
except FileNotFoundError:
raise FileNotFoundError(str(root))
if root.is_file():
if stat.S_ISLNK(st.st_mode):
raise ValueError(f"refusing to follow symlink: {root}")
if stat.S_ISREG(st.st_mode):
return [root] if is_supported_file(root) else []
if not root.is_dir():
if not stat.S_ISDIR(st.st_mode):
return []
it = root.rglob("*") if recursive else root.glob("*")
@ -140,8 +172,8 @@ def _yaml_scalar_placeholder(
) -> str:
var = make_var_name(role_prefix, path)
if isinstance(sample, str):
return f'"{{{{ {var} }}}}"'
return f"{{{{ {var} }}}}"
return j2.quoted_variable(var)
return j2.variable(var)
def _yaml_render_union(
@ -159,6 +191,11 @@ def _yaml_render_union(
if isinstance(union_obj, dict):
for key, val in union_obj.items():
key_path = path + (str(key),)
# The key text is copied verbatim into the template; escape it so an
# attacker-influenced key (e.g. ``{{ 7*7 }}``) cannot become live
# template code. ``key_path`` (used only to build sanitised var
# names) keeps the original key.
safe_key = escape_jinja_literal(str(key))
cond_var = (
defined_var_name(role_prefix, key_path)
if key_path in optional_containers
@ -168,14 +205,14 @@ def _yaml_render_union(
if _is_scalar(val) or val is None:
value = _yaml_scalar_placeholder(role_prefix, key_path, val)
if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}")
lines.append(f"{ind}{key}: {value}")
lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}{safe_key}: {value}")
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
else:
if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}")
lines.append(f"{ind}{key}:")
lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}{safe_key}:")
lines.extend(
_yaml_render_union(
role_prefix,
@ -187,7 +224,7 @@ def _yaml_render_union(
)
)
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
return lines
if isinstance(union_obj, list):
@ -202,17 +239,18 @@ def _yaml_render_union(
if _is_scalar(item) or item is None:
value = _yaml_scalar_placeholder(role_prefix, item_path, item)
if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}")
lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}- {value}")
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
elif isinstance(item, dict):
if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}")
lines.append(f"{ind}{j2.if_defined(cond_var)}")
# First line: list marker with first key if possible
first = True
for k, v in item.items():
kp = item_path + (str(k),)
safe_k = escape_jinja_literal(str(k))
k_cond = (
defined_var_name(role_prefix, kp)
if kp in optional_containers
@ -222,23 +260,23 @@ def _yaml_render_union(
value = _yaml_scalar_placeholder(role_prefix, kp, v)
if first:
if k_cond:
lines.append(f"{ind}{{% if {k_cond} is defined %}}")
lines.append(f"{ind}- {k}: {value}")
lines.append(f"{ind}{j2.if_defined(k_cond)}")
lines.append(f"{ind}- {safe_k}: {value}")
if k_cond:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
first = False
else:
if k_cond:
lines.append(f"{ind} {{% if {k_cond} is defined %}}")
lines.append(f"{ind} {k}: {value}")
lines.append(f"{ind} {j2.if_defined(k_cond)}")
lines.append(f"{ind} {safe_k}: {value}")
if k_cond:
lines.append(f"{ind} {{% endif %}}")
lines.append(f"{ind} {j2.endif()}")
else:
# nested
if first:
if k_cond:
lines.append(f"{ind}{{% if {k_cond} is defined %}}")
lines.append(f"{ind}- {k}:")
lines.append(f"{ind}{j2.if_defined(k_cond)}")
lines.append(f"{ind}- {safe_k}:")
lines.extend(
_yaml_render_union(
role_prefix,
@ -249,12 +287,12 @@ def _yaml_render_union(
)
)
if k_cond:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
first = False
else:
if k_cond:
lines.append(f"{ind} {{% if {k_cond} is defined %}}")
lines.append(f"{ind} {k}:")
lines.append(f"{ind} {j2.if_defined(k_cond)}")
lines.append(f"{ind} {safe_k}:")
lines.extend(
_yaml_render_union(
role_prefix,
@ -265,20 +303,20 @@ def _yaml_render_union(
)
)
if k_cond:
lines.append(f"{ind} {{% endif %}}")
lines.append(f"{ind} {j2.endif()}")
if first:
# empty dict item
lines.append(f"{ind}- {{}}")
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
else:
# list of lists - emit as scalar-ish fallback
value = f"{{{{ {make_var_name(role_prefix, item_path)} }}}}"
value = j2.variable(make_var_name(role_prefix, item_path))
if cond_var:
lines.append(f"{ind}{{% if {cond_var} is defined %}}")
lines.append(f"{ind}{j2.if_defined(cond_var)}")
lines.append(f"{ind}- {value}")
if cond_var:
lines.append(f"{ind}{{% endif %}}")
lines.append(f"{ind}{j2.endif()}")
return lines
# scalar at root
@ -300,21 +338,22 @@ def _toml_render_union(
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
var_name = make_var_name(role_prefix, path + (key,))
safe_key = escape_jinja_literal(str(key))
cond = (
defined_var_name(role_prefix, path + (key,))
if (path + (key,)) in optional_containers
else None
)
if cond:
lines.append(f"{{% if {cond} is defined %}}")
lines.append(f"{j2.if_defined(cond)}")
if isinstance(value, str):
lines.append(f'{key} = "{{{{ {var_name} }}}}"')
lines.append(f"{safe_key} = {j2.quoted_variable(var_name)}")
elif isinstance(value, bool):
lines.append(f"{key} = {{{{ {var_name} | lower }}}}")
lines.append(f"{safe_key} = {j2.lower(var_name)}")
else:
lines.append(f"{key} = {{{{ {var_name} }}}}")
lines.append(f"{safe_key} = {j2.variable(var_name)}")
if cond:
lines.append("{% endif %}")
lines.append(j2.endif())
def walk(obj: dict[str, Any], path: tuple[str, ...]) -> None:
if path:
@ -324,8 +363,8 @@ def _toml_render_union(
else None
)
if cond:
lines.append(f"{{% if {cond} is defined %}}")
lines.append(f"[{'.'.join(path)}]")
lines.append(f"{j2.if_defined(cond)}")
lines.append(f"[{'.'.join(escape_jinja_literal(str(p)) for p in path)}]")
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)}
@ -340,7 +379,7 @@ def _toml_render_union(
walk(v, path + (str(k),))
if path and (path in optional_containers):
lines.append("{% endif %}")
lines.append(j2.endif())
lines.append("")
# root scalars
@ -410,27 +449,28 @@ def _ini_render_union(
else None
)
if sec_cond:
lines.append(f"{{% if {sec_cond} is defined %}}")
lines.append(f"[{section}]")
lines.append(f"{j2.if_defined(sec_cond)}")
lines.append(f"[{escape_jinja_literal(str(section))}]")
for key, raw_val in union.items(section, raw=True):
path = (section, key)
var = make_var_name(role_prefix, path)
safe_key = escape_jinja_literal(str(key))
key_cond = (
defined_var_name(role_prefix, path) if path in optional_keys else None
)
v = (raw_val or "").strip()
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
if key_cond:
lines.append(f"{{% if {key_cond} is defined %}}")
lines.append(f"{j2.if_defined(key_cond)}")
if quoted:
lines.append(f'{key} = "{{{{ {var} }}}}"')
lines.append(f"{safe_key} = {j2.quoted_variable(var)}")
else:
lines.append(f"{key} = {{{{ {var} }}}}")
lines.append(f"{safe_key} = {j2.variable(var)}")
if key_cond:
lines.append("{% endif %}")
lines.append(j2.endif())
lines.append("")
if sec_cond:
lines.append("{% endif %}")
lines.append(j2.endif())
lines.append("")
return "\n".join(lines).rstrip() + "\n"
@ -584,6 +624,9 @@ class FormatOutput:
items: list[dict[str, Any]]
FOLDER_SUPPORTED_FORMATS: set[str] = {"json", "yaml", "toml", "ini", "xml"}
def process_directory(
root: Path, recursive: bool, role_prefix: str
) -> tuple[str, list[FormatOutput]]:
@ -595,9 +638,21 @@ def process_directory(
# Parse and group by format
grouped: dict[str, list[tuple[Path, Any]]] = defaultdict(list)
for p in files:
fmt, parsed = parse_config(p, None)
try:
fmt, parsed = parse_config(p, None)
except ConfigParseError as exc:
# One malformed file should not abort processing of an entire
# directory. Skip it with a warning; the rest still generate.
print(f"jinjaturtle: skipping {p}: {exc}", file=sys.stderr)
continue
if fmt not in FOLDER_SUPPORTED_FORMATS:
# Directory mode only supports a subset of formats for now.
continue
grouped[fmt].append((p, parsed))
if not grouped:
raise ValueError(f"No folder-supported config files found under: {root}")
multiple_formats = len(grouped) > 1
outputs: list[FormatOutput] = []
@ -615,7 +670,7 @@ def process_directory(
if multiple_formats
else f"{role_prefix}_items"
)
template = "{{ data | tojson(indent=2) }}\n"
template = f"{j2.to_json('data', indent=2)}\n"
items: list[dict[str, Any]] = []
for rid, parsed in zip(rel_ids, parsed_list):
items.append({"id": rid, "data": parsed})
@ -759,4 +814,12 @@ def process_directory(
defaults_doc[out.list_var] = out.items
defaults_yaml = dump_yaml(defaults_doc, sort_keys=True)
# Defence in depth: folder-mode union templates are built by their own
# renderers (not core.generate_jinja2_template), so gate each one here too.
# Any un-neutralised source text that became a live tag aborts generation.
for out in outputs:
verify_jinja2_template_safe(out.template)
if out.fmt == "json":
verify_no_live_jinja_in_json_keys(out.template)
return defaults_yaml, outputs

View file

@ -0,0 +1,191 @@
from __future__ import annotations
"""Safer file-output helpers for the JinjaTurtle CLI.
The CLI is often used by administrators. A plain Path.write_text() follows a
final-path symlink and can therefore be dangerous when a root-run invocation
writes into an attacker-writable tree. These helpers validate path components,
refuse symlink parents, reject root-run output through untrusted parent
components, write through a temporary file in the target directory, and replace
the final path atomically. Existing final-path symlinks are refused rather than
followed.
"""
import os
from pathlib import Path
import stat
import tempfile
class OutputPathError(OSError):
"""Raised when a requested output path is unsafe."""
# Keep a reference to the real euid getter. Tests can monkeypatch
# _effective_uid directly without changing process-wide os.geteuid behaviour.
_OS_GETEUID = getattr(os, "geteuid", None)
def _effective_uid() -> int | None:
if _OS_GETEUID is None:
return None
try:
return int(_OS_GETEUID())
except OSError:
return None
def _absolute(path: Path) -> Path:
expanded = path.expanduser()
return expanded if expanded.is_absolute() else Path.cwd() / expanded
def _chmod_private(path: Path) -> None:
try:
os.chmod(path, 0o700)
except OSError:
# Best-effort; mkdir(mode=0o700) is already used for normal filesystems.
pass
def _assert_trusted_root_parent(path: Path, st: os.stat_result) -> None:
"""Reject root-run output through attacker-controlled parent directories.
A root-run JinjaTurtle process may write files that an administrator later
applies as configuration-management input. Parent directories controlled by
an unprivileged user are therefore not acceptable output anchors. Root-owned
sticky shared directories such as /tmp are allowed as a boundary, but any
existing child below them must be root-owned and not writable by group/other.
"""
if _effective_uid() != 0:
return
if not stat.S_ISDIR(st.st_mode):
raise OutputPathError(f"output parent is not a directory: {path}")
if st.st_uid != 0:
raise OutputPathError(
f"output parent is not owned by root; refusing root-run output: {path}"
)
writable_by_group_or_other = st.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
sticky = st.st_mode & stat.S_ISVTX
if writable_by_group_or_other and not sticky:
raise OutputPathError(
"output parent is writable by group/other; "
f"refusing root-run output: {path}"
)
def _assert_existing_directory_component(path: Path) -> None:
try:
st = path.lstat()
except OSError as exc:
raise OutputPathError(f"unable to inspect output parent: {path}") from exc
if stat.S_ISLNK(st.st_mode):
raise OutputPathError(f"refusing to use symlink parent: {path}")
if not stat.S_ISDIR(st.st_mode):
raise OutputPathError(f"output parent is not a directory: {path}")
_assert_trusted_root_parent(path, st)
def _mkdir_safe_dir_tree(path: Path) -> Path:
"""Create/validate a directory tree one component at a time.
pathlib.mkdir(parents=True) can traverse a symlink inserted after a parent
pre-check. Walking one component at a time keeps every existing component
checked before it is used, and newly-created components are immediately
re-inspected.
"""
out = _absolute(path)
parts = out.parts
if not parts:
return out
if out.is_absolute():
cur = Path(parts[0])
rest = parts[1:]
_assert_existing_directory_component(cur)
else:
# _absolute() currently always returns an absolute path, but keep this
# branch for clarity if that helper is ever relaxed.
cur = Path.cwd()
rest = parts
_assert_existing_directory_component(cur)
for part in rest:
cur = cur / part
if os.path.lexists(cur):
_assert_existing_directory_component(cur)
continue
try:
os.mkdir(cur, 0o700)
except FileExistsError:
_assert_existing_directory_component(cur)
continue
_chmod_private(cur)
_assert_existing_directory_component(cur)
return out
def _check_existing_output_file(path: Path) -> None:
try:
st = path.lstat()
except FileNotFoundError:
return
if stat.S_ISLNK(st.st_mode):
raise OutputPathError(f"refusing to write through symlink: {path}")
if not stat.S_ISREG(st.st_mode):
raise OutputPathError(f"refusing to replace non-regular file: {path}")
def _check_parent_components(parent: Path) -> None:
"""Require every existing parent component to be a trusted real directory."""
_mkdir_safe_dir_tree(parent)
def ensure_safe_directory(path: Path) -> None:
"""Create or validate a directory tree without accepting unsafe parents."""
_mkdir_safe_dir_tree(path)
def write_text_safely(path: Path, text: str, *, encoding: str = "utf-8") -> None:
"""Write text without following a final-path symlink.
The target's parent is created/validated component by component. Existing
parent symlinks are refused for every user. When running as root, existing
parent components must also be root-owned and not writable by group/other,
except for sticky shared boundaries such as /tmp. Existing final-path
symlinks are refused rather than followed.
"""
path = _absolute(path)
parent = _mkdir_safe_dir_tree(path.parent)
_check_existing_output_file(path)
fd = -1
tmp_name: str | None = None
try:
fd, tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=str(parent), text=True
)
with os.fdopen(fd, "w", encoding=encoding) as f:
fd = -1
f.write(text)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp_name, 0o600)
_check_parent_components(path.parent)
_check_existing_output_file(path)
os.replace(tmp_name, path)
tmp_name = None
finally:
if fd >= 0:
os.close(fd)
if tmp_name is not None:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass

382
src/jinjaturtle/safety.py Normal file
View file

@ -0,0 +1,382 @@
from __future__ import annotations
"""Output safety gate for generated templates (defence in depth).
JinjaTurtle's first line of defence is per-handler escaping: every piece of
verbatim source text is meant to be wrapped/neutralised before it reaches the
template (see ``escape.py``). That model is correct but *fragile*: it relies on
every handler remembering to escape at every site, and on the escaper being
exactly right for every delimiter form. A single forgotten call site -- or a
new handler, or a missed delimiter variant -- silently reopens a
template-injection / SSTI path that can become remote code execution on the
configuration-management control node when the template is later rendered.
This module adds a second, independent line of defence that does **not** depend
on getting every escape right. It inspects the *finished* template and proves a
single global property:
Every *live* template construct in the output is one that JinjaTurtle itself
legitimately emits. Anything else can only have originated from
un-neutralised source text, so generation fails loudly instead of emitting a
dangerous template.
The check is positive/allowlist-based, which is the safe direction: unknown
constructs are rejected, not ignored. It runs at the single choke points in
``core.py`` (``generate_jinja2_template``) so it covers every current handler and
every future one automatically.
Why this is robust against the escaper being wrong
---------------------------------------------------
We tokenise with Jinja2's own lexer. The lexer emits a JinjaTurtle
``{% raw %} ... {% endraw %}`` wrapper as ``raw_begin`` / inert ``data`` /
``raw_end``: the wrapped literal text is *not* tokenised as live tags. So the
verifier only ever sees, as live constructs, the tags Jinja2 would actually
execute. If an escaped block was mis-wrapped such that a payload escapes the
raw wrapper (the historical ``{%+ endraw %}`` bug), that payload now appears as a
*live* token here and is rejected -- the gate catches the failure even though
the escaper produced it.
"""
import re
__all__ = [
"TemplateSafetyError",
"verify_jinja2_template_safe",
"verify_erb_template_safe",
"verify_no_live_jinja_in_json_keys",
]
class TemplateSafetyError(Exception):
"""Raised when a generated template contains a construct JinjaTurtle would
never emit, indicating that un-neutralised source text became live template
code. Generation must abort rather than emit the template."""
# --------------------------------------------------------------------------- #
# Allowlist grammar for JinjaTurtle-emitted Jinja2.
#
# JinjaTurtle emits a deliberately tiny subset of Jinja2. Each pattern below
# describes the *full body* of a tag (the text between ``{%``/``%}`` or
# ``{{``/``}}``), already stripped of surrounding whitespace and of any ``-``/
# ``+`` whitespace-control markers. Identifiers (variable names, loop vars,
# keys) are restricted to a conservative character class; crucially this class
# excludes characters needed for SSTI gadgets (quotes, parentheses, brackets,
# arithmetic/operator characters, ``%``, ``|`` except in the known filter forms,
# attribute access beyond a single dotted hop, etc.).
# --------------------------------------------------------------------------- #
# A single identifier. Word characters only, and -- critically -- no
# double-underscore anywhere. ``__`` is the gateway to every classic Jinja2
# SSTI gadget (``__class__``, ``__init__``, ``__globals__``, ``__builtins__``),
# and JinjaTurtle never emits a dunder, so forbidding ``__`` here removes the
# entire attribute-traversal escape class even if such a token reached output.
_NAME = r"(?!\w*__)[A-Za-z_][A-Za-z0-9_]*"
# A dotted reference for loop-item field access. JinjaTurtle emits at most
# three hops (``loopvar``, ``loopvar.field``, ``loopvar.key.subkey``), so cap the
# depth rather than allow arbitrary chains.
_DOTTED = rf"{_NAME}(?:\.{_NAME}){{0,2}}"
# Filters JinjaTurtle is known to emit inside ``{{ ... }}`` expressions.
_KNOWN_FILTERS = (
r"lower",
r"to_json\(ensure_ascii=(?:True|False)\)",
r"to_json\(indent=\d+,\s*ensure_ascii=(?:True|False)\)",
r"tojson",
)
_FILTER_ALT = "|".join(_KNOWN_FILTERS)
# Expression bodies allowed inside ``{{ ... }}``.
_EXPR_PATTERNS = tuple(
re.compile(p)
for p in (
# Plain variable / dotted loop-field reference.
rf"^{_DOTTED}$",
# Filtered reference: ``name | filter`` (one known filter).
rf"^{_DOTTED}\s*\|\s*(?:{_FILTER_ALT})$",
# YAML-preserving boolean ternary emitted by j2.yaml_*_expression:
# 'true' if NAME else 'false' / "true" if NAME else "false"
rf"^(['\"])(?:true|false)\1\s+if\s+{_DOTTED}\s+else\s+(['\"])(?:true|false)\2$",
# YAML-preserving null ternary:
# 'null' if NAME is none else NAME
rf"^(['\"])null\1\s+if\s+{_DOTTED}\s+is\s+none\s+else\s+{_DOTTED}$",
)
)
# Statement bodies allowed inside ``{% ... %}``.
_STMT_PATTERNS = tuple(
re.compile(p)
for p in (
rf"^for\s+{_NAME}\s+in\s+{_DOTTED}$",
r"^endfor$",
rf"^if\s+{_DOTTED}\s+is\s+defined$",
rf"^if\s+{_DOTTED}\s+is\s+none$",
r"^if\s+not\s+loop\.last$",
r"^else$",
r"^endif$",
# ``elif`` is emitted only for the same shapes as the if-conditions
# above; keep it conservative.
rf"^elif\s+{_DOTTED}\s+is\s+(?:defined|none)$",
)
)
def _strip_ws_control(body: str) -> str:
"""Remove a leading/trailing Jinja whitespace-control marker and spaces."""
body = body.strip()
if body[:1] in "-+":
body = body[1:]
if body[-1:] in "-+":
body = body[:-1]
return body.strip()
# Built-in Jinja2/Ansible globals that JinjaTurtle never emits as a reference
# head. The allowlist grammar necessarily accepts any bare identifier (it cannot
# tell an injected global from a legitimate generated variable), so these names
# are rejected explicitly: their presence as the head of a live reference means
# source text leaked into a live construct. This is a targeted backstop and does
# not replace the grammar check.
_FORBIDDEN_REFERENCE_HEADS = frozenset(
{
"range",
"dict",
"lipsum",
"cycler",
"joiner",
"namespace",
"config",
"self",
"cycle",
"request",
"get_flashed_messages",
"url_for",
# Ansible global set
"lookup",
"q",
"query",
"now",
"omit",
"undef",
}
)
def _reference_head(body: str) -> str:
"""Return the leading identifier (before any dot/filter) of an expression."""
body = _strip_ws_control(body)
m = re.match(r"[A-Za-z_][A-Za-z0-9_]*", body)
return m.group(0) if m else ""
def _expr_is_allowed(body: str) -> bool:
body = _strip_ws_control(body)
if _reference_head(body) in _FORBIDDEN_REFERENCE_HEADS:
return False
return any(p.match(body) for p in _EXPR_PATTERNS)
def _stmt_is_allowed(body: str) -> bool:
body = _strip_ws_control(body)
# A ``for ... in <collection>`` whose collection is a forbidden global is
# rejected for the same reason as an expression head.
m = re.match(r"for\s+[A-Za-z_]\w*\s+in\s+([A-Za-z_][A-Za-z0-9_]*)", body)
if m and m.group(1) in _FORBIDDEN_REFERENCE_HEADS:
return False
return any(p.match(body) for p in _STMT_PATTERNS)
def verify_jinja2_template_safe(template_text: str) -> None:
"""Validate that *template_text* contains only JinjaTurtle-emitted Jinja2.
Raises :class:`TemplateSafetyError` on the first live construct that is not
in the allowlist grammar. Text inside JinjaTurtle's own ``{% raw %}``
wrappers is treated as inert (the lexer does not tokenise it as tags), so
legitimately-escaped source content passes.
"""
# Import lazily so the dependency is only needed when generating Jinja2.
import jinja2
env = jinja2.Environment(autoescape=True)
try:
tokens = list(env.lex(template_text))
except jinja2.TemplateSyntaxError as exc:
# A syntax error means our own raw-wrapping did not fully contain the
# source text (e.g. an early ``endraw`` breakout left dangling tags).
# That is precisely a safety failure, not a benign parse hiccup.
raise TemplateSafetyError(
f"generated template does not lex as the JinjaTurtle subset: {exc}"
) from exc
# Walk the token stream. Jinja2 yields raw blocks as single
# ``raw_begin``/``raw_end`` tokens with inert ``data`` between them, so we
# never see wrapped literal text as live tags. We collect the raw inner
# text of each live ``{% %}`` / ``{{ }}`` construct (preserving original
# spacing) and check it against the allowlist.
mode: str | None = None # None, "block", or "variable"
body_parts: list[str] = []
def _flush(kind: str, lineno: int) -> None:
body = "".join(body_parts)
if kind == "variable":
ok = _expr_is_allowed(body)
else:
ok = _stmt_is_allowed(body)
if not ok:
shown = body.strip()
wrapped = (
"{{ " + shown + " }}" if kind == "variable" else "{% " + shown + " %}"
)
raise TemplateSafetyError(
"refusing to emit template: unexpected live "
f"{'expression' if kind == 'variable' else 'statement'} "
f"{wrapped!r} at line {lineno}. This construct is not one "
"JinjaTurtle emits, so it likely came from un-neutralised "
"source text (possible template injection)."
)
for lineno, tok_type, value in tokens:
if tok_type in ("variable_begin", "block_begin"):
mode = "variable" if tok_type == "variable_begin" else "block"
body_parts = []
elif tok_type in ("variable_end", "block_end"):
if mode is not None:
_flush(mode, lineno)
mode = None
body_parts = []
elif mode is not None:
# Preserve the original token text (including its own whitespace
# tokens) so the reconstructed body matches the source spacing.
body_parts.append(value if isinstance(value, str) else str(value))
# raw_begin / raw_end / data tokens outside a tag are inert: skip.
# --------------------------------------------------------------------------- #
# JSON-key gate (defence in depth, format-specific).
#
# JinjaTurtle never emits a live Jinja construct inside a JSON *object key*: keys
# are copied verbatim from the source and (after escape.py) are wrapped in
# ``{% raw %}`` if they contain markup, so a key never lexes as a live tag. A
# live construct in key position can therefore only mean source key text leaked
# into the template unescaped (the json-handler blind spot). This gate is
# independent of the escaper: it inspects the finished template, replaces every
# *live* Jinja construct with an inert sentinel (raw-wrapped literal text stays
# literal), and rejects any sentinel that lands in a JSON key slot.
# --------------------------------------------------------------------------- #
# Sentinel byte that cannot occur in normal generated template text.
_LIVE_SENTINEL = "\x00"
# A JSON key is a double-quoted string immediately followed (after optional
# whitespace) by a colon. We only need to detect a sentinel *inside* such a
# string, so match a quoted run that ends in `":` and look for the sentinel.
_JSON_KEY_RE = re.compile(r'"((?:[^"\\]|\\.)*)"\s*:', re.S)
_KEY_JINJA_DELIMS = ("{{", "}}", "{%", "%}", "{#", "#}")
def _contains_jinja_delim(text: str) -> bool:
"""True if *text* contains any Jinja delimiter (live or escaped-literal)."""
return any(d in text for d in _KEY_JINJA_DELIMS)
def verify_no_live_jinja_in_json_keys(template_text: str) -> None:
"""Reject a JSON template that carries Jinja markup in an object key.
JinjaTurtle never templates a JSON *object key*: keys come straight from the
source and a key is an identifier/string, never a value placeholder. Any
Jinja in key position therefore means attacker-influenced source key text
reached the template. This gate fails closed on it, independent of whether a
handler left the markup *live* (a raw ``{{ ... }}`` in the key) or *escaped*
it into a ``{% raw %}`` wrapper -- both indicate a key that should never have
contained templating, so generation aborts rather than emitting it.
Detection is done on the lexer token stream: we reconstruct the document with
every live tag collapsed to a sentinel and every ``{% raw %}``-wrapped region
also marked, then reject a sentinel that lands inside a JSON key string.
"""
import jinja2
env = jinja2.Environment(autoescape=True)
try:
tokens = list(env.lex(template_text))
except jinja2.TemplateSyntaxError as exc:
raise TemplateSafetyError(
f"generated JSON template does not lex as the JinjaTurtle subset: {exc}"
) from exc
out: list[str] = []
in_tag = False
for _lineno, tok_type, value in tokens:
if tok_type in ("variable_begin", "block_begin", "comment_begin"):
# A live construct: collapse to a sentinel so it is detectable if it
# sits in key position. (raw_begin/raw_end are *not* live; the data
# inside a raw block is preserved verbatim below, so an escaped key
# still shows its literal Jinja delimiters to the key check.)
in_tag = True
out.append(_LIVE_SENTINEL)
elif tok_type in ("variable_end", "block_end", "comment_end"):
in_tag = False
elif not in_tag:
# data, whitespace, raw_begin/raw_end markers, and inert raw content.
out.append(value if isinstance(value, str) else str(value))
reconstructed = "".join(out)
for match in _JSON_KEY_RE.finditer(reconstructed):
key_text = match.group(1)
if _LIVE_SENTINEL in key_text or _contains_jinja_delim(key_text):
raise TemplateSafetyError(
"refusing to emit JSON template: Jinja markup appears inside a "
"JSON object key. JinjaTurtle never templates keys, so this "
"indicates attacker-influenced source key text (possible "
"template injection)."
)
#
# JinjaTurtle's ERB output is produced by translating the (already-verified)
# Jinja2 subset, so the Jinja2 gate is the primary guarantee. As an independent
# ERB-side backstop we confirm that every ERB tag body is one the translator
# emits, and that no Jinja2 delimiters survived into the ERB output (which would
# indicate a raw block the translator failed to recognise -- the historical
# ``{%+ raw %}`` blind spot).
# --------------------------------------------------------------------------- #
_ERB_TAG_RE = re.compile(r"<%[-=#]?(.*?)[-]?%>", re.S)
_JINJA_DELIMS = ("{{", "}}", "{%", "%}", "{#", "#}")
# Bodies the ErbTranslator emits. Kept permissive for Ruby method chains it
# constructs (``@var``, ``.each_with_index``, ``JSON.generate(...)`` etc.) but
# anchored so arbitrary attacker text cannot masquerade as one.
_ERB_STMT_PATTERNS = tuple(
re.compile(p)
for p in (
r"^require 'json'$",
r"^end$",
r"^else$",
r"^@?[A-Za-z_][\w@\.\[\]'\"]*\.each_with_index do \|[A-Za-z_]\w*, __jt_idx_\d+\| $",
r"^if .+$",
r"^elsif .+$",
r"^unless .+\.nil\?$",
r"^# Unsupported JinjaTurtle statement: .*$",
)
)
def verify_erb_template_safe(template_text: str) -> None:
"""Validate that *template_text* contains no leftover Jinja2 delimiters.
The translator is the security-relevant step for ERB; this backstop ensures
no live Jinja construct survived translation (which would mean a raw block
was not recognised and source text passed through untouched).
"""
for delim in _JINJA_DELIMS:
if delim in template_text:
raise TemplateSafetyError(
"refusing to emit ERB template: it still contains the Jinja2 "
f"delimiter {delim!r}, which means source text was not fully "
"translated/neutralised (possible template injection)."
)

View file

@ -62,3 +62,73 @@ def test_cli_writes_output_files(tmp_path, capsys):
# When writing to files, we shouldn't print the big headers
assert "# defaults/main.yml" not in captured.out
assert "# config.j2" not in captured.out
def test_cli_folder_stdout(tmp_path, capsys):
"""Folder mode without output paths prints defaults and all templates."""
(tmp_path / "a.json").write_text('{"name": "one"}\n', encoding="utf-8")
(tmp_path / "b.yaml").write_text("name: two\n", encoding="utf-8")
exit_code = cli._main([str(tmp_path), "-r", "role"])
assert exit_code == 0
captured = capsys.readouterr()
assert "# defaults/main.yml" in captured.out
assert "# config.json.j2" in captured.out
assert "# config.yaml.j2" in captured.out
assert "role_json_items" in captured.out
assert "role_yaml_items" in captured.out
def test_cli_folder_writes_template_directory(tmp_path, capsys):
"""Folder mode writes multiple templates when template output is a directory."""
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "a.json").write_text('{"name": "one"}\n', encoding="utf-8")
(src_dir / "b.yaml").write_text("name: two\n", encoding="utf-8")
defaults_path = tmp_path / "defaults.yml"
template_dir = tmp_path / "templates"
exit_code = cli._main(
[
str(src_dir),
"-r",
"role",
"--defaults-output",
str(defaults_path),
"--template-output",
str(template_dir),
]
)
assert exit_code == 0
assert defaults_path.is_file()
assert (template_dir / "config.json.j2").is_file()
assert (template_dir / "config.yaml.j2").is_file()
captured = capsys.readouterr()
assert captured.out == ""
def test_cli_folder_single_output_file_when_one_format(tmp_path):
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "a.json").write_text('{"name": "one"}\n', encoding="utf-8")
defaults_path = tmp_path / "defaults.yml"
template_path = tmp_path / "config.j2"
exit_code = cli._main(
[
str(src_dir),
"-r",
"role",
"--defaults-output",
str(defaults_path),
"--template-output",
str(template_path),
]
)
assert exit_code == 0
assert defaults_path.is_file()
assert template_path.is_file()
assert "to_json" in template_path.read_text(encoding="utf-8")

View file

@ -0,0 +1,205 @@
"""Regression tests for malformed-input handling (``ConfigParseError``).
Bug: every parse-layer handler (xml/json/toml/yaml/ini) used to let its
underlying parser's exception escape as an unhandled traceback when given a
malformed file. Pointing JinjaTurtle (or Enroll, which calls it as a library)
at harvested, attacker-influenceable config makes malformed input an entirely
expected condition, so it must fail cleanly instead of crashing.
These tests pin down that:
* ``parse_config`` raises the normalised ``ConfigParseError`` for malformed
XML/JSON/TOML/INI;
* defusedxml's *security* exceptions (XXE / ``EntitiesForbidden``) are NOT
swallowed by that normalisation -- they must still propagate so a caller can
distinguish "malformed" from "malicious";
* an unrelated error (e.g. the TOML "tomllib missing" ``RuntimeError``) is not
captured by the normalisation either;
* the CLI exits non-zero with a clean message (no traceback) on malformed
input; and
* folder mode skips an unparseable file instead of aborting the whole run.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import pytest
from jinjaturtle.core import ConfigParseError, parse_config
def _run_cli(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-m", "jinjaturtle.cli", *args],
capture_output=True,
text=True,
)
@pytest.mark.parametrize(
"fmt,filename,content",
[
# Element name is not well-formed XML -> expat ParseError historically
# escaped as an uncaught traceback.
("xml", "bad.xml", "<root><{{tag}}/></root>"),
("xml", "bad2.xml", "<root><unclosed></root>"),
("json", "bad.json", "{not valid json"),
("toml", "bad.toml", "x = = ="),
# A bare key with no value is invalid INI for configparser.
("ini", "bad.ini", "[s]\nthis line has no equals and no colon\n"),
],
)
def test_parse_config_raises_configparseerror_on_malformed(
tmp_path: Path, fmt: str, filename: str, content: str
) -> None:
src = tmp_path / filename
src.write_text(content, encoding="utf-8")
with pytest.raises(ConfigParseError):
parse_config(src, fmt=fmt)
def test_parse_config_does_not_swallow_xxe_security_exception(tmp_path: Path) -> None:
"""defusedxml's EntitiesForbidden must propagate, not become ConfigParseError.
EntitiesForbidden subclasses ValueError, so a naive ``except ValueError`` /
``except Exception`` in parse_config would mask an XXE attempt as a benign
"malformed file". The normalisation is deliberately scoped to exclude it.
"""
from defusedxml.common import EntitiesForbidden
xxe = (
'<?xml version="1.0"?>\n'
'<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>\n'
"<foo>&xxe;</foo>\n"
)
src = tmp_path / "xxe.xml"
src.write_text(xxe, encoding="utf-8")
with pytest.raises(EntitiesForbidden):
parse_config(src, fmt="xml")
def test_parse_config_does_not_swallow_unrelated_runtimeerror(
tmp_path: Path, monkeypatch
) -> None:
"""A non-malformation error (tomllib missing) must propagate unchanged."""
import jinjaturtle.handlers.toml as toml_module
monkeypatch.setattr(toml_module, "tomllib", None)
src = tmp_path / "x.toml"
src.write_text('a = "b"\n', encoding="utf-8")
with pytest.raises(RuntimeError) as exc:
parse_config(src, fmt="toml")
assert "tomllib/tomli is required" in str(exc.value)
def test_cli_fails_cleanly_on_malformed_xml(tmp_path: Path) -> None:
"""The CLI must exit non-zero with a clean message, not a traceback."""
src = tmp_path / "bad.xml"
src.write_text("<root><{{tag}}/></root>", encoding="utf-8")
res = _run_cli([str(src), "-f", "xml", "-r", "demo"])
assert res.returncode != 0
# Clean, user-facing message -- not a Python traceback.
assert "could not parse" in res.stderr
assert "Traceback (most recent call last)" not in res.stderr
@pytest.mark.parametrize(
"content",
[
# Classic XXE: external SYSTEM entity used to read a local file.
(
'<?xml version="1.0"?>\n'
'<!DOCTYPE root [<!ENTITY xxe SYSTEM "file:///etc/hostname">]>\n'
"<root>&xxe;</root>\n"
),
# Billion-laughs style internal entity expansion.
(
'<?xml version="1.0"?>\n'
'<!DOCTYPE lolz [<!ENTITY lol "lol"><!ENTITY lol2 "&lol;&lol;&lol;">]>\n'
"<lolz>&lol2;</lolz>\n"
),
# External parameter entity (SSRF / out-of-band XXE vector).
(
'<?xml version="1.0"?>\n'
'<!DOCTYPE root [<!ENTITY % ext SYSTEM "http://127.0.0.1:9/x.dtd"> %ext;]>\n'
"<root>x</root>\n"
),
],
)
def test_cli_refuses_xxe_cleanly(tmp_path: Path, content: str) -> None:
"""defusedxml's security refusal must surface as a clean non-zero exit.
``EntitiesForbidden``/``DTDForbidden``/``ExternalReferenceForbidden`` subclass
``DefusedXmlException`` (and ``ValueError``). ``parse_config`` deliberately
lets them propagate rather than folding them into ``ConfigParseError`` so a
blocked attack stays distinguishable from a benign malformed file. The CLI
must therefore catch ``DefusedXmlException`` itself and exit cleanly instead
of leaking a Python traceback.
"""
src = tmp_path / "xxe.xml"
src.write_text(content, encoding="utf-8")
res = _run_cli([str(src), "-f", "xml", "-r", "demo"])
assert res.returncode == 2
assert "refusing unsafe XML input" in res.stderr
assert "Traceback (most recent call last)" not in res.stderr
def test_folder_mode_skips_unparseable_file(tmp_path: Path) -> None:
"""One malformed file should not abort processing of the whole directory."""
from jinjaturtle.multi import process_directory
good = tmp_path / "good.json"
good.write_text('{ "host": "localhost" }', encoding="utf-8")
bad = tmp_path / "bad.json"
bad.write_text("{not valid json", encoding="utf-8")
defaults_yaml, outputs = process_directory(tmp_path, False, "demo")
# The good file still produced output despite the bad sibling: its content
# and source id appear, and a template was generated.
assert "localhost" in defaults_yaml
assert "good.json" in defaults_yaml
assert outputs
@pytest.mark.parametrize(
"content",
[
"a: &a [*a]\n", # self-referential sequence alias
"root: &r\n child: *r\n", # self-referential mapping alias
],
)
def test_recursive_yaml_is_rejected_cleanly(tmp_path: Path, content: str) -> None:
"""A recursive YAML anchor must fail as a clean ConfigParseError.
PyYAML's safe_load builds a self-referential object from a recursive anchor;
JinjaTurtle's downstream walks (flatten, timestamp-stringify, template gen)
would otherwise blow the stack with a RecursionError traceback. Harvested
config is attacker-influenceable, so this must fail closed.
"""
src = tmp_path / "cyclic.yaml"
src.write_text(content, encoding="utf-8")
with pytest.raises(ConfigParseError):
parse_config(src, "yaml")
def test_recursive_yaml_cli_exits_nonzero_without_traceback(tmp_path: Path) -> None:
src = tmp_path / "cyclic.yaml"
src.write_text("a: &a [*a]\n", encoding="utf-8")
res = _run_cli([str(src), "-f", "yaml", "-r", "role"])
assert res.returncode != 0
assert "Traceback" not in res.stderr
assert "RecursionError" not in res.stderr
def test_shared_noncyclic_yaml_aliases_still_work(tmp_path: Path) -> None:
"""A shared (acyclic) anchor referenced multiple times is a DAG, not a
cycle, and must still parse and template normally."""
src = tmp_path / "shared.yaml"
src.write_text("base: &b\n x: 1\na: *b\nc: *b\n", encoding="utf-8")
fmt, parsed = parse_config(src, "yaml")
assert fmt == "yaml"
assert parsed["a"] == {"x": 1}
assert parsed["c"] == {"x": 1}

View file

@ -191,3 +191,43 @@ def test_flatten_config_unsupported_format():
flatten_config("bogusfmt", parsed=None)
assert "Unsupported format" in str(exc.value)
def test_make_var_name_collapses_adjacent_separators():
"""Regression: adjacent separators must not produce a forbidden ``__``.
A source key such as ``log..level`` or ``cache--size`` previously sanitised
to a name containing a double underscore (``..._log__level``). The output
safety gate in safety.py rejects *any* ``__`` in a generated identifier
(it is the gateway to Jinja2 SSTI gadgets), so emitting one made JinjaTurtle
reject its own placeholder and abort generation on entirely benign config.
make_var_name now collapses runs of underscores to a single ``_``.
"""
for raw_key in ("log..level", "cache--size", "a...b", "x.-.y", "a..b--c"):
name = make_var_name("role", ("main", raw_key))
assert "__" not in name, f"{raw_key!r} produced {name!r}"
# Still a valid Ansible/Jinja identifier.
assert name.replace("_", "").isalnum() or name == "role"
# The double-underscore-free collapse is stable and predictable.
assert make_var_name("role", ("main", "log..level")) == "role_main_log_level"
assert make_var_name("role", ("main", "cache--size")) == "role_main_cache_size"
# A leading-digit-free prefix with its own repeated separators is collapsed too.
assert make_var_name("My__Role", ("k",)) == "my_role_k"
def test_make_var_name_collapsed_names_pass_output_safety_gate():
"""The names make_var_name emits must be accepted by the safety gate.
This binds the two modules together: whatever identifier make_var_name
produces for a hostile-looking key must lex as the JinjaTurtle subset, so a
real template built from it is not refused.
"""
from jinjaturtle.safety import verify_jinja2_template_safe
for raw_key in ("log..level", "cache--size", "a...b", "weird..key..name"):
var = make_var_name("demo", ("section", raw_key))
# Build the kind of expression a handler would emit for this variable.
template = "{{ " + var + " }}"
# Must not raise TemplateSafetyError.
verify_jinja2_template_safe(template)

View file

@ -0,0 +1,327 @@
"""Security regression tests for template injection (SSTI).
JinjaTurtle copies parts of the source config (comments, unrecognised lines,
structural keys) verbatim into the generated template. If that text contains
Jinja2/ERB delimiters it must be neutralised, otherwise attacker-influenced
config content becomes live template code that executes when Ansible later
renders the template.
These tests render the *generated* template the way a downstream tool would and
assert that an injected payload never executes. A tripwire object is exposed
under every name a payload might reference; if the rendered output ever contains
the tripwire sentinel, an injected expression executed and the test fails.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
import jinja2
import pytest
import yaml as pyyaml
from jinjaturtle.escape import (
escape_jinja_literal,
)
TRIP = "__TRIPWIRE_FIRED__"
class _UnsafeAwareLoader(pyyaml.SafeLoader):
pass
def _construct_unsafe(loader: _UnsafeAwareLoader, node: pyyaml.Node):
return loader.construct_scalar(node)
_UnsafeAwareLoader.add_constructor("!unsafe", _construct_unsafe)
def _safe_load_defaults(text: str):
return pyyaml.load(text, Loader=_UnsafeAwareLoader)
class _Boom:
"""Returns the tripwire sentinel for any access/call an SSTI payload makes."""
def run(self, *a, **k):
return TRIP
def __call__(self, *a, **k):
return TRIP
def __getitem__(self, k):
return self
def __getattr__(self, n):
return _Boom()
def __str__(self):
return TRIP
def _render_jinja(template_text: str, defaults: dict) -> str:
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
env.filters.setdefault("to_json", lambda v, **k: __import__("json").dumps(v))
env.filters.setdefault("lower", lambda v: str(v).lower())
ctx = dict(defaults or {})
for name in ("salt", "cmd", "os", "subprocess", "cycler", "lipsum", "namespace"):
ctx.setdefault(name, _Boom())
return env.from_string(template_text).render(**ctx)
def _run_jinjaturtle(tmp_path: Path, source_name: str, body: str, fmt: str):
src = tmp_path / source_name
src.write_text(body, encoding="utf-8")
tpl = tmp_path / "out.tpl"
dfl = tmp_path / "defaults.yml"
res = subprocess.run(
[
sys.executable,
"-m",
"jinjaturtle.cli",
str(src),
"-f",
fmt,
"--role-name",
"role",
"-t",
str(tpl),
"-d",
str(dfl),
],
capture_output=True,
text=True,
)
assert res.returncode == 0, f"generation failed: {res.stderr}"
defaults = _safe_load_defaults(dfl.read_text()) or {}
return tpl.read_text(), defaults
# A representative payload for each format, placed where the format allows
# attacker-controlled verbatim text (comments / unrecognised lines).
FORMAT_CASES = [
(
"ini",
"evil.ini",
"[s]\n"
"good = ok ; {{ salt['cmd.run']('id') }}\n"
"# {{ cmd.run('whoami') }}\n",
),
(
"yaml",
"evil.yaml",
"server:\n" " motd: ok\n" " # {{ salt['cmd.run']('id') }}\n",
),
(
"toml",
"evil.toml",
"[s]\n" 'good = "ok"\n' "# {{ cmd.run('id') }}\n",
),
(
"xml",
"evil.xml",
"<config>\n"
" <!-- {{ salt['cmd.run']('id') }} -->\n"
' <server name="ok"><motd>ok</motd></server>\n'
"</config>\n",
),
(
"postfix",
"main.cf",
"myhostname = mail.example.com\n" "# {{ salt['cmd.run']('id') }}\n",
),
(
"systemd",
"evil.service",
"[Unit]\n"
"Description=ok\n"
"# {{ cmd.run('id') }}\n"
"RawLineNoEquals {% for x in ().__class__.__bases__ %}\n"
"[Service]\n"
"ExecStart=/bin/true\n",
),
(
"ssh",
"sshd_config",
"# {{ salt['cmd.run']('id') }}\n" "Port 22\n" "PermitRootLogin no\n",
),
]
@pytest.mark.parametrize("fmt,name,body", FORMAT_CASES)
def test_comment_payload_does_not_execute(tmp_path, fmt, name, body):
template_text, defaults = _run_jinjaturtle(tmp_path, name, body, fmt)
rendered = _render_jinja(template_text, defaults)
assert TRIP not in rendered, f"injected payload executed for {fmt}:\n{rendered}"
@pytest.mark.parametrize("fmt,name,body", FORMAT_CASES)
def test_generated_template_is_renderable(tmp_path, fmt, name, body):
# A correct escape must still produce a syntactically valid template.
template_text, defaults = _run_jinjaturtle(tmp_path, name, body, fmt)
# Should not raise a TemplateSyntaxError.
_render_jinja(template_text, defaults)
# --- 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

View file

@ -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():

125
tests/test_multi.py Normal file
View file

@ -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("<root><name>one</name></root>", 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")

View file

@ -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 %}",
]
<