Compare commits
44 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a712b19a45 | |||
| d3694240da | |||
| f9332879ba | |||
| c6ce0311ed | |||
| 054b90ebde | |||
| 575c2b79f9 | |||
| d4fd42522d | |||
| 42321a8ec9 | |||
| 3573e8e750 | |||
| 5ae85ad11e | |||
| bc4af135b4 | |||
| 0e052a073a | |||
| 70be0f7e33 | |||
| 4a1f2ac15e | |||
| 661320558c | |||
| 094c4d2274 | |||
| a9d56b66c5 | |||
| c5375b180f | |||
| 383a529016 | |||
| a5f8e7c481 | |||
| 15770c8e3d | |||
| a5ca5f2974 | |||
| f5de32b778 | |||
| 13d1a2b972 | |||
| 00d0064362 | |||
| bbbbe33e54 | |||
| 94aeaf6e7a | |||
| 17612c9883 | |||
| 5ee084d395 | |||
| 49fee7afe4 | |||
| 3d53d4fb30 | |||
| 77d1658e65 | |||
| 1413076c9c | |||
| 39ba01689c | |||
| d9bf2966c6 | |||
| 1e545cca87 | |||
| 823e529373 | |||
| e6d58199d3 | |||
| 121e67ae07 | |||
| 2f77cd4d80 | |||
| 8f7f48dc91 | |||
| a5c860e463 | |||
| 14428ff89c | |||
| f92854382a |
44 changed files with 4973 additions and 701 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@ dist
|
|||
*.j2
|
||||
*.toml
|
||||
regenerated_*
|
||||
*.orig
|
||||
*.rej
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
258
README.md
|
|
@ -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
61
debian/changelog
vendored
|
|
@ -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
612
poetry.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
61
release.sh
61
release.sh
|
|
@ -7,11 +7,6 @@ filedust -y .
|
|||
|
||||
# Publish to Pypi
|
||||
poetry build
|
||||
poetry publish
|
||||
|
||||
# Make AppImage
|
||||
poetry run pyproject-appimage
|
||||
mv JinjaTurtle.AppImage dist/
|
||||
|
||||
# Sign packages
|
||||
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
|
||||
|
|
@ -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/"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
98
src/jinjaturtle/escape.py
Normal 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 %}"
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}]"
|
||||
)
|
||||
|
|
|
|||
186
src/jinjaturtle/handlers/postfix.py
Normal file
186
src/jinjaturtle/handlers/postfix.py
Normal 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)
|
||||
283
src/jinjaturtle/handlers/ssh.py
Normal file
283
src/jinjaturtle/handlers/ssh.py
Normal 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)
|
||||
193
src/jinjaturtle/handlers/systemd.py
Normal file
193
src/jinjaturtle/handlers/systemd.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
101
src/jinjaturtle/j2.py
Normal 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)
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
191
src/jinjaturtle/output_safety.py
Normal file
191
src/jinjaturtle/output_safety.py
Normal 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
382
src/jinjaturtle/safety.py
Normal 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)."
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
205
tests/test_config_parse_errors.py
Normal file
205
tests/test_config_parse_errors.py
Normal 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}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
327
tests/test_injection_security.py
Normal file
327
tests/test_injection_security.py
Normal 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
|
||||
|
|
@ -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
125
tests/test_multi.py
Normal 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")
|
||||
233
tests/test_output_safety_gate.py
Normal file
233
tests/test_output_safety_gate.py
Normal 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 %}",
|
||||
]
|
||||
|
||||
|
||||
| < | ||||