Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0fbed5ca5 | |||
| 6c58beddfe | |||
| fbb06f1177 | |||
| 62b2f2ffe6 | |||
| bf735c8328 | |||
| 1544dc0295 | |||
| b25dd1e314 | |||
| 3fcfefe644 | |||
| 618dd20e7c | |||
| 5695f4258e | |||
| 5c686d27cc | |||
| 4ea7267b92 | |||
| d403dcb918 | |||
| 778237740a | |||
| 87ddf52e81 |
36 changed files with 5989 additions and 919 deletions
|
|
@ -27,6 +27,11 @@ jobs:
|
|||
run: |
|
||||
poetry install --with dev
|
||||
|
||||
- name: Install sops
|
||||
run: |
|
||||
curl -L -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.13.1/sops-v3.13.1.linux.amd64
|
||||
chmod +x /usr/local/bin/sops
|
||||
|
||||
- name: Run test script
|
||||
run: |
|
||||
./tests.sh
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ dist
|
|||
*.pdf
|
||||
*.csv
|
||||
*.html
|
||||
coverage.xml
|
||||
|
|
|
|||
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -1,3 +1,19 @@
|
|||
# 0.6.0
|
||||
|
||||
* Add support for capturing ipset and iptables configuration files
|
||||
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present (`firewall_runtime` role)
|
||||
* Dependency updates
|
||||
|
||||
# 0.5.0
|
||||
|
||||
* Add support for templating `sshd_config`, if a compatible version of JinjaTurtle is also present.
|
||||
* Dependency updates
|
||||
|
||||
# 0.4.4
|
||||
|
||||
* Update cryptography dependency
|
||||
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
|
||||
|
||||
# 0.4.3
|
||||
|
||||
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -13,6 +13,7 @@
|
|||
- Defensively excludes likely secrets (path denylist + content sniff + size caps).
|
||||
- Captures non-system users and their SSH public keys and any .bashrc or .bash_aliases or .profile files that deviate from the skel defaults.
|
||||
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
|
||||
- Captures live ipset and iptables runtime state into a fallback `firewall_runtime` role, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found.
|
||||
- Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled'
|
||||
- Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc
|
||||
- Avoids trying to start systemd services that were detected as inactive during harvest.
|
||||
|
|
@ -70,6 +71,8 @@ Harvest state about a host and write a harvest bundle.
|
|||
- Changed-from-default config (plus related custom/unowned files under service dirs)
|
||||
- Non-system users + SSH public keys
|
||||
- Misc `/etc` that can't be attributed to a package (`etc_custom` role)
|
||||
- Static firewall config files such as nftables, UFW, firewalld, `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, and `/etc/ipset*`
|
||||
- Live kernel ipset/iptables state via `ipset save`, `iptables-save`, and `ip6tables-save` as a fallback, but only when the corresponding persistent config was not found (`firewall_runtime` role at manifest time)
|
||||
- Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time)
|
||||
|
||||
**Common flags**
|
||||
|
|
@ -89,8 +92,27 @@ Harvest state about a host and write a harvest bundle.
|
|||
- glob (default): supports `*` and `**` (prefix with `glob:` to force)
|
||||
- regex: prefix with `re:` or `regex:`
|
||||
- Precedence: excludes win over includes.
|
||||
* Using remote mode and sudo requires password?
|
||||
- `--ask-become-pass` (or `-K`) will prompt for the password. If you forget, and remote requires password for sudo, it'll still fall back to prompting for a password, but will be a bit slower to do so.
|
||||
* Using remote mode and auth requires secrets?
|
||||
* sudo password:
|
||||
* `--ask-become-pass` (or `-K`) prompts for the sudo password.
|
||||
* If you forget, and remote sudo requires a password, Enroll will still fall back to prompting in interactive mode (slightly slower due to retry).
|
||||
* SSH private-key passphrase:
|
||||
* `--ask-key-passphrase` prompts for the SSH key passphrase.
|
||||
* `--ssh-key-passphrase-env ENV_VAR` reads the SSH key passphrase from an environment variable (useful for CI/non-interactive runs).
|
||||
* If neither is provided, and Enroll detects an encrypted key in an interactive session, it will still fall back to prompting on-demand.
|
||||
* In non-interactive sessions, pass `--ask-key-passphrase` or `--ssh-key-passphrase-env ENV_VAR` when using encrypted private keys.
|
||||
* Note: `--ask-key-passphrase` and `--ssh-key-passphrase-env` are mutually exclusive.
|
||||
|
||||
Examples (encrypted SSH key)
|
||||
|
||||
```bash
|
||||
# Interactive
|
||||
enroll harvest --remote-host myhost.example.com --remote-user myuser --ask-key-passphrase --out /tmp/enroll-harvest
|
||||
|
||||
# Non-interactive / CI
|
||||
export ENROLL_SSH_KEY_PASSPHRASE='correct horse battery staple'
|
||||
enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn myhost.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -512,6 +534,7 @@ Roles collected
|
|||
- packages: 232 package snapshot(s), 41 file(s), 0 excluded
|
||||
- apt_config: 26 file(s), 7 dir(s), 10 excluded
|
||||
- dnf_config: 0 file(s), 0 dir(s), 0 excluded
|
||||
- firewall_runtime: 2 snapshot(s), 1 ipset(s)
|
||||
- etc_custom: 70 file(s), 20 dir(s), 0 excluded
|
||||
- usr_local_custom: 35 file(s), 1 dir(s), 0 excluded
|
||||
- extra_paths: 0 file(s), 0 dir(s), 0 excluded
|
||||
|
|
@ -593,7 +616,7 @@ exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
|
|||
[manifest]
|
||||
# you can set defaults here too, e.g.
|
||||
no_jinjaturtle = true
|
||||
sops = 00AE817C24A10C2540461A9C1D7CDE0234DB458D
|
||||
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
|
||||
|
||||
[diff]
|
||||
# ignore noisy drift
|
||||
|
|
|
|||
19
debian/changelog
vendored
19
debian/changelog
vendored
|
|
@ -1,3 +1,22 @@
|
|||
enroll (0.6.0) unstable; urgency=medium
|
||||
|
||||
* Add support for capturing ipset and iptables configuration files
|
||||
* Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role)
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Thu, 14 May 2026 15:00 +1000
|
||||
|
||||
enroll (0.5.0) unstable; urgency=medium
|
||||
|
||||
* Add ssh config support where JinjaTurtle is used
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Tue, 12 May 2026 12:00 +1000
|
||||
|
||||
enroll (0.4.4) unstable; urgency=medium
|
||||
|
||||
* Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Tue, 17 Feb 2026 11:00 +1100
|
||||
|
||||
enroll (0.4.3) unstable; urgency=medium
|
||||
|
||||
* Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ from .diff import (
|
|||
from .explain import explain_state
|
||||
from .harvest import harvest
|
||||
from .manifest import manifest
|
||||
from .remote import remote_harvest, RemoteSudoPasswordRequired
|
||||
from .remote import (
|
||||
remote_harvest,
|
||||
RemoteSudoPasswordRequired,
|
||||
RemoteSSHKeyPassphraseRequired,
|
||||
)
|
||||
from .sopsutil import SopsError, encrypt_file_binary
|
||||
from .validate import validate_harvest
|
||||
from .version import get_enroll_version
|
||||
|
|
@ -390,6 +394,24 @@ def _add_remote_args(p: argparse.ArgumentParser) -> None:
|
|||
),
|
||||
)
|
||||
|
||||
keyp = p.add_mutually_exclusive_group()
|
||||
keyp.add_argument(
|
||||
"--ask-key-passphrase",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Prompt for the SSH private key passphrase when using --remote-host. "
|
||||
"If not set, enroll will still prompt on-demand if it detects an encrypted key in an interactive session."
|
||||
),
|
||||
)
|
||||
keyp.add_argument(
|
||||
"--ssh-key-passphrase-env",
|
||||
metavar="ENV_VAR",
|
||||
help=(
|
||||
"Read the SSH private key passphrase from environment variable ENV_VAR "
|
||||
"(useful for non-interactive runs/CI)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(prog="enroll")
|
||||
|
|
@ -771,6 +793,10 @@ def main() -> None:
|
|||
pass
|
||||
remote_harvest(
|
||||
ask_become_pass=args.ask_become_pass,
|
||||
ask_key_passphrase=bool(args.ask_key_passphrase),
|
||||
ssh_key_passphrase_env=getattr(
|
||||
args, "ssh_key_passphrase_env", None
|
||||
),
|
||||
local_out_dir=tmp_bundle,
|
||||
remote_host=args.remote_host,
|
||||
remote_port=args.remote_port,
|
||||
|
|
@ -793,6 +819,10 @@ def main() -> None:
|
|||
)
|
||||
state = remote_harvest(
|
||||
ask_become_pass=args.ask_become_pass,
|
||||
ask_key_passphrase=bool(args.ask_key_passphrase),
|
||||
ssh_key_passphrase_env=getattr(
|
||||
args, "ssh_key_passphrase_env", None
|
||||
),
|
||||
local_out_dir=out_dir,
|
||||
remote_host=args.remote_host,
|
||||
remote_port=args.remote_port,
|
||||
|
|
@ -996,6 +1026,10 @@ def main() -> None:
|
|||
pass
|
||||
remote_harvest(
|
||||
ask_become_pass=args.ask_become_pass,
|
||||
ask_key_passphrase=bool(args.ask_key_passphrase),
|
||||
ssh_key_passphrase_env=getattr(
|
||||
args, "ssh_key_passphrase_env", None
|
||||
),
|
||||
local_out_dir=tmp_bundle,
|
||||
remote_host=args.remote_host,
|
||||
remote_port=args.remote_port,
|
||||
|
|
@ -1027,6 +1061,10 @@ def main() -> None:
|
|||
)
|
||||
remote_harvest(
|
||||
ask_become_pass=args.ask_become_pass,
|
||||
ask_key_passphrase=bool(args.ask_key_passphrase),
|
||||
ssh_key_passphrase_env=getattr(
|
||||
args, "ssh_key_passphrase_env", None
|
||||
),
|
||||
local_out_dir=harvest_dir,
|
||||
remote_host=args.remote_host,
|
||||
remote_port=args.remote_port,
|
||||
|
|
@ -1096,6 +1134,12 @@ def main() -> None:
|
|||
raise SystemExit(
|
||||
"error: remote sudo requires a password. Re-run with --ask-become-pass."
|
||||
) from None
|
||||
except RemoteSSHKeyPassphraseRequired as e:
|
||||
msg = str(e).strip() or (
|
||||
"SSH private key passphrase is required. "
|
||||
"Re-run with --ask-key-passphrase or --ssh-key-passphrase-env VAR."
|
||||
)
|
||||
raise SystemExit(f"error: {msg}") from None
|
||||
except RuntimeError as e:
|
||||
raise SystemExit(f"error: {e}") from None
|
||||
except SopsError as e:
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ _MANAGED_FILE_REASONS: Dict[str, ReasonInfo] = {
|
|||
),
|
||||
"system_firewall": ReasonInfo(
|
||||
"Firewall configuration",
|
||||
"Firewall rules/configuration (ufw, nftables, iptables, etc.).",
|
||||
"Firewall rules/configuration (ufw, nftables, iptables, ipset, etc.).",
|
||||
),
|
||||
"system_sysctl": ReasonInfo(
|
||||
"sysctl configuration",
|
||||
|
|
@ -211,6 +211,10 @@ _OBSERVED_VIA: Dict[str, ReasonInfo] = {
|
|||
"Referenced by package role",
|
||||
"Package was referenced by an enroll packages snapshot/role.",
|
||||
),
|
||||
"firewall_runtime": ReasonInfo(
|
||||
"Referenced by firewall runtime role",
|
||||
"Package was referenced by captured live ipset/iptables runtime state.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -359,6 +363,22 @@ def explain_state(
|
|||
}
|
||||
)
|
||||
|
||||
# Runtime firewall snapshot
|
||||
firewall_obj = roles.get("firewall_runtime") or {}
|
||||
if isinstance(firewall_obj, dict) and firewall_obj:
|
||||
captures = [
|
||||
key
|
||||
for key in ("ipset_save", "iptables_v4_save", "iptables_v6_save")
|
||||
if firewall_obj.get(key)
|
||||
]
|
||||
role_summaries.append(
|
||||
{
|
||||
"role": "firewall_runtime",
|
||||
"summary": f"{len(captures)} snapshot(s), {len(firewall_obj.get('ipset_sets') or [])} ipset(s)",
|
||||
"notes": firewall_obj.get("notes") or [],
|
||||
}
|
||||
)
|
||||
|
||||
# Single snapshots
|
||||
for rname in [
|
||||
"apt_config",
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import json
|
|||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess # nosec
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from typing import Dict, List, Optional, Set
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from .systemd import (
|
||||
list_enabled_services,
|
||||
|
|
@ -148,6 +150,17 @@ class ExtraPathsSnapshot:
|
|||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirewallRuntimeSnapshot:
|
||||
role_name: str
|
||||
packages: List[str] = field(default_factory=list)
|
||||
ipset_save: Optional[str] = None
|
||||
ipset_sets: List[str] = field(default_factory=list)
|
||||
iptables_v4_save: Optional[str] = None
|
||||
iptables_v6_save: Optional[str] = None
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
ALLOWED_UNOWNED_EXTS = {
|
||||
".cfg",
|
||||
".cnf",
|
||||
|
|
@ -653,6 +666,13 @@ _SYSTEM_CAPTURE_GLOBS: List[tuple[str, str]] = [
|
|||
("/etc/nftables.d/*", "system_firewall"),
|
||||
("/etc/iptables/rules.v4", "system_firewall"),
|
||||
("/etc/iptables/rules.v6", "system_firewall"),
|
||||
("/etc/sysconfig/iptables", "system_firewall"),
|
||||
("/etc/sysconfig/ip6tables", "system_firewall"),
|
||||
("/etc/ipset.conf", "system_firewall"),
|
||||
("/etc/ipset/*", "system_firewall"),
|
||||
("/etc/ipset.d/*", "system_firewall"),
|
||||
("/etc/sysconfig/ipset", "system_firewall"),
|
||||
("/etc/default/ipset", "system_firewall"),
|
||||
("/etc/ufw/*", "system_firewall"),
|
||||
("/etc/default/ufw", "system_firewall"),
|
||||
("/etc/firewalld/*", "system_firewall"),
|
||||
|
|
@ -664,6 +684,46 @@ _SYSTEM_CAPTURE_GLOBS: List[tuple[str, str]] = [
|
|||
]
|
||||
|
||||
|
||||
# Persistent firewall files that are treated as authoritative for their
|
||||
# respective runtime state. If any matching file exists, the runtime capture
|
||||
# for that family is retained only as static managed-file harvest output and
|
||||
# not duplicated through the generated firewall_runtime role.
|
||||
_PERSISTENT_IPTABLES_V4_GLOBS = [
|
||||
"/etc/iptables/rules.v4",
|
||||
"/etc/sysconfig/iptables",
|
||||
]
|
||||
|
||||
_PERSISTENT_IPTABLES_V6_GLOBS = [
|
||||
"/etc/iptables/rules.v6",
|
||||
"/etc/sysconfig/ip6tables",
|
||||
]
|
||||
|
||||
_PERSISTENT_IPSET_GLOBS = [
|
||||
"/etc/ipset.conf",
|
||||
"/etc/ipset/*",
|
||||
"/etc/ipset.d/*",
|
||||
"/etc/sysconfig/ipset",
|
||||
]
|
||||
|
||||
|
||||
def _persistent_firewall_files(globs: List[str]) -> List[str]:
|
||||
"""Return persistent firewall files matching ``globs``.
|
||||
|
||||
This intentionally uses the same file walking helper as the static system
|
||||
capture path so the runtime fallback decision matches what Enroll can
|
||||
harvest as managed files.
|
||||
"""
|
||||
seen: Set[str] = set()
|
||||
out: List[str] = []
|
||||
for spec in globs:
|
||||
for path in _iter_matching_files(spec):
|
||||
if path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
out.append(path)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def _iter_matching_files(spec: str, *, cap: int = MAX_FILES_CAP) -> List[str]:
|
||||
"""Expand a glob spec and also walk directories to collect files."""
|
||||
out: List[str] = []
|
||||
|
|
@ -854,6 +914,200 @@ def _iter_system_capture_paths() -> List[tuple[str, str]]:
|
|||
return uniq
|
||||
|
||||
|
||||
_FIREWALL_CAPTURE_COMMANDS: Dict[str, Tuple[str, ...]] = {
|
||||
"ipset_save": ("ipset", "save"),
|
||||
"iptables_v4_save": ("iptables-save",),
|
||||
"iptables_v6_save": ("ip6tables-save",),
|
||||
}
|
||||
|
||||
|
||||
def _run_capture_command(
|
||||
command_key: str, *, timeout: int = 10
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Return (stdout, error_note) for an allowlisted local state command.
|
||||
|
||||
The command key is resolved through ``_FIREWALL_CAPTURE_COMMANDS`` so this
|
||||
helper never executes caller-supplied argv. Commands are run with
|
||||
``shell=False`` explicitly to avoid shell interpretation.
|
||||
"""
|
||||
argv = _FIREWALL_CAPTURE_COMMANDS.get(command_key)
|
||||
if argv is None:
|
||||
return None, f"Unknown capture command: {command_key}"
|
||||
|
||||
exe = argv[0]
|
||||
if shutil.which(exe) is None:
|
||||
return None, f"{exe} not found on PATH."
|
||||
|
||||
try:
|
||||
proc = subprocess.run( # nosec
|
||||
argv,
|
||||
shell=False,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return None, f"{' '.join(argv)} failed: {e!r}"
|
||||
|
||||
if proc.returncode != 0:
|
||||
stderr = (proc.stderr or "").strip()
|
||||
if len(stderr) > 300:
|
||||
stderr = stderr[:297] + "..."
|
||||
return (
|
||||
None,
|
||||
f"{' '.join(argv)} exited {proc.returncode}: {stderr or '(no stderr)'}",
|
||||
)
|
||||
|
||||
return proc.stdout or "", None
|
||||
|
||||
|
||||
def _write_generated_artifact(
|
||||
bundle_dir: str, role_name: str, src_rel: str, content: str
|
||||
) -> None:
|
||||
"""Write a generated harvest artifact that did not exist as a file on disk."""
|
||||
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
with open(dst, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _ipset_save_has_state(text: str) -> bool:
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith(("create ", "add ")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parse_ipset_set_names(text: str) -> List[str]:
|
||||
names: List[str] = []
|
||||
seen: Set[str] = set()
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
toks = shlex.split(line)
|
||||
except ValueError:
|
||||
toks = line.split()
|
||||
if len(toks) >= 2 and toks[0] == "create" and toks[1] not in seen:
|
||||
seen.add(toks[1])
|
||||
names.append(toks[1])
|
||||
return names
|
||||
|
||||
|
||||
def _iptables_save_has_state(text: str) -> bool:
|
||||
"""Return True when iptables-save output contains non-default state."""
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("*") or line == "COMMIT":
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
parts = line.split()
|
||||
chain_name = parts[0][1:] if parts else ""
|
||||
policy = parts[1] if len(parts) >= 2 else ""
|
||||
# Built-in empty chains usually look like ':INPUT ACCEPT [0:0]'.
|
||||
# A changed policy, or any custom chain, is meaningful state.
|
||||
if policy not in ("ACCEPT", "-"):
|
||||
return True
|
||||
if policy == "-" and chain_name:
|
||||
return True
|
||||
continue
|
||||
if line.startswith(("-A ", "-I ", "-N ", "-P ", "-R ")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _collect_firewall_runtime_snapshot(
|
||||
bundle_dir: str,
|
||||
*,
|
||||
persistent_ipset_files: Optional[List[str]] = None,
|
||||
persistent_iptables_v4_files: Optional[List[str]] = None,
|
||||
persistent_iptables_v6_files: Optional[List[str]] = None,
|
||||
) -> FirewallRuntimeSnapshot:
|
||||
"""Capture live kernel firewall state only when no persistent config exists.
|
||||
|
||||
Enroll also harvests persistent firewall files such as
|
||||
/etc/iptables/rules.v4, /etc/iptables/rules.v6, and /etc/ipset.conf as
|
||||
managed files. The generated runtime restore role is therefore a fallback:
|
||||
it captures each firewall family only when that family has no persistent
|
||||
file to avoid generating two roles that try to manage the same state.
|
||||
"""
|
||||
role_name = "firewall_runtime"
|
||||
packages: Set[str] = set()
|
||||
notes: List[str] = []
|
||||
ipset_save_rel: Optional[str] = None
|
||||
ipset_sets: List[str] = []
|
||||
iptables_v4_rel: Optional[str] = None
|
||||
iptables_v6_rel: Optional[str] = None
|
||||
|
||||
persistent_ipset_files = persistent_ipset_files or []
|
||||
persistent_iptables_v4_files = persistent_iptables_v4_files or []
|
||||
persistent_iptables_v6_files = persistent_iptables_v6_files or []
|
||||
|
||||
if persistent_ipset_files:
|
||||
notes.append(
|
||||
"Live ipset runtime capture skipped because persistent ipset "
|
||||
f"configuration was found: {', '.join(persistent_ipset_files)}"
|
||||
)
|
||||
else:
|
||||
ipset_out, ipset_err = _run_capture_command("ipset_save")
|
||||
if ipset_err:
|
||||
notes.append(ipset_err)
|
||||
elif ipset_out is not None and _ipset_save_has_state(ipset_out):
|
||||
ipset_save_rel = "firewall/ipset.save"
|
||||
_write_generated_artifact(bundle_dir, role_name, ipset_save_rel, ipset_out)
|
||||
ipset_sets = _parse_ipset_set_names(ipset_out)
|
||||
packages.add("ipset")
|
||||
|
||||
if persistent_iptables_v4_files:
|
||||
notes.append(
|
||||
"Live IPv4 iptables runtime capture skipped because persistent "
|
||||
f"IPv4 iptables configuration was found: {', '.join(persistent_iptables_v4_files)}"
|
||||
)
|
||||
else:
|
||||
ipt4_out, ipt4_err = _run_capture_command("iptables_v4_save")
|
||||
if ipt4_err:
|
||||
notes.append(ipt4_err)
|
||||
elif ipt4_out is not None and _iptables_save_has_state(ipt4_out):
|
||||
iptables_v4_rel = "firewall/iptables.v4"
|
||||
_write_generated_artifact(bundle_dir, role_name, iptables_v4_rel, ipt4_out)
|
||||
packages.add("iptables")
|
||||
|
||||
if persistent_iptables_v6_files:
|
||||
notes.append(
|
||||
"Live IPv6 iptables runtime capture skipped because persistent "
|
||||
f"IPv6 iptables configuration was found: {', '.join(persistent_iptables_v6_files)}"
|
||||
)
|
||||
else:
|
||||
ipt6_out, ipt6_err = _run_capture_command("iptables_v6_save")
|
||||
if ipt6_err:
|
||||
notes.append(ipt6_err)
|
||||
elif ipt6_out is not None and _iptables_save_has_state(ipt6_out):
|
||||
iptables_v6_rel = "firewall/iptables.v6"
|
||||
_write_generated_artifact(bundle_dir, role_name, iptables_v6_rel, ipt6_out)
|
||||
packages.add("iptables")
|
||||
|
||||
# Package names are intentionally added only when matching live state was
|
||||
# captured. Merely having iptables/ipset installed should not create a role.
|
||||
|
||||
return FirewallRuntimeSnapshot(
|
||||
role_name=role_name,
|
||||
packages=sorted(packages),
|
||||
ipset_save=ipset_save_rel,
|
||||
ipset_sets=ipset_sets,
|
||||
iptables_v4_save=iptables_v4_rel,
|
||||
iptables_v6_save=iptables_v6_rel,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def harvest(
|
||||
bundle_dir: str,
|
||||
policy: Optional[IgnorePolicy] = None,
|
||||
|
|
@ -907,6 +1161,29 @@ def harvest(
|
|||
installed_pkgs = backend.installed_packages() or {}
|
||||
installed_names: Set[str] = set(installed_pkgs.keys())
|
||||
|
||||
persistent_ipset_files = _persistent_firewall_files(_PERSISTENT_IPSET_GLOBS)
|
||||
persistent_iptables_v4_files = _persistent_firewall_files(
|
||||
_PERSISTENT_IPTABLES_V4_GLOBS
|
||||
)
|
||||
persistent_iptables_v6_files = _persistent_firewall_files(
|
||||
_PERSISTENT_IPTABLES_V6_GLOBS
|
||||
)
|
||||
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
firewall_runtime_snapshot = FirewallRuntimeSnapshot(
|
||||
role_name="firewall_runtime",
|
||||
notes=[
|
||||
"Live ipset/iptables runtime capture skipped because harvest is not running as root."
|
||||
],
|
||||
)
|
||||
else:
|
||||
firewall_runtime_snapshot = _collect_firewall_runtime_snapshot(
|
||||
bundle_dir,
|
||||
persistent_ipset_files=persistent_ipset_files,
|
||||
persistent_iptables_v4_files=persistent_iptables_v4_files,
|
||||
persistent_iptables_v6_files=persistent_iptables_v6_files,
|
||||
)
|
||||
|
||||
def _pick_installed(cands: List[str]) -> Optional[str]:
|
||||
for c in cands:
|
||||
if c in installed_names:
|
||||
|
|
@ -2121,6 +2398,7 @@ def harvest(
|
|||
pkg_names |= manual_set
|
||||
pkg_names |= set(pkg_units.keys())
|
||||
pkg_names |= {ps.package for ps in pkg_snaps}
|
||||
pkg_names |= set(firewall_runtime_snapshot.packages or [])
|
||||
|
||||
packages_inventory: Dict[str, Dict[str, object]] = {}
|
||||
for pkg in sorted(pkg_names):
|
||||
|
|
@ -2136,6 +2414,13 @@ def harvest(
|
|||
observed.append({"kind": "systemd_unit", "ref": unit})
|
||||
for rn in sorted(set(pkg_role_names.get(pkg, []))):
|
||||
observed.append({"kind": "package_role", "ref": rn})
|
||||
if pkg in set(firewall_runtime_snapshot.packages or []):
|
||||
observed.append(
|
||||
{"kind": "firewall_runtime", "ref": firewall_runtime_snapshot.role_name}
|
||||
)
|
||||
pkg_roles_map.setdefault(pkg, set()).add(
|
||||
firewall_runtime_snapshot.role_name
|
||||
)
|
||||
|
||||
roles = sorted(pkg_roles_map.get(pkg, set()))
|
||||
|
||||
|
|
@ -2219,6 +2504,7 @@ def harvest(
|
|||
"packages": [asdict(p) for p in pkg_snaps],
|
||||
"apt_config": asdict(apt_config_snapshot),
|
||||
"dnf_config": asdict(dnf_config_snapshot),
|
||||
"firewall_runtime": asdict(firewall_runtime_snapshot),
|
||||
"etc_custom": asdict(etc_custom_snapshot),
|
||||
"usr_local_custom": asdict(usr_local_custom_snapshot),
|
||||
"extra_paths": asdict(extra_paths_snapshot),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ def infer_other_formats(dest_path: str) -> Optional[str]:
|
|||
# systemd units
|
||||
if suffix in SYSTEMD_SUFFIXES:
|
||||
return "systemd"
|
||||
# OpenSSH system config files and snippets
|
||||
parts = {part.lower() for part in p.parts}
|
||||
if name in {"sshd_config", "ssh_config"}:
|
||||
return "ssh"
|
||||
if suffix == ".conf" and {"sshd_config.d", "ssh_config.d"} & parts:
|
||||
return "ssh"
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -582,6 +582,97 @@ def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
|
|||
"""
|
||||
|
||||
|
||||
def _render_firewall_runtime_tasks(var_prefix: str) -> str:
|
||||
"""Render tasks for live ipset/iptables snapshots."""
|
||||
return f"""- name: Ensure firewall runtime snapshot directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/enroll/firewall
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
|
||||
- name: Deploy captured ipset snapshot
|
||||
vars:
|
||||
_enroll_ff:
|
||||
files:
|
||||
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_ipset_save }}}}"
|
||||
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_ipset_save }}}}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
|
||||
dest: /etc/enroll/firewall/ipset.save
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
when: ({var_prefix}_ipset_save | default('') | length) > 0
|
||||
|
||||
- name: Flush captured ipsets before restoring members
|
||||
ansible.builtin.command:
|
||||
cmd: "ipset flush {{{{ item }}}}"
|
||||
loop: "{{{{ {var_prefix}_ipset_sets | default([]) }}}}"
|
||||
register: _enroll_ipset_flush
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
when:
|
||||
- ({var_prefix}_ipset_save | default('') | length) > 0
|
||||
- {var_prefix}_sync_ipsets_exact | default(true) | bool
|
||||
|
||||
- name: Restore captured ipsets
|
||||
ansible.builtin.shell: "ipset restore -exist < /etc/enroll/firewall/ipset.save"
|
||||
args:
|
||||
executable: /bin/sh
|
||||
register: _enroll_ipset_restore
|
||||
changed_when: _enroll_ipset_restore.rc == 0
|
||||
when: ({var_prefix}_ipset_save | default('') | length) > 0
|
||||
|
||||
- name: Deploy captured IPv4 iptables snapshot
|
||||
vars:
|
||||
_enroll_ff:
|
||||
files:
|
||||
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_iptables_v4_save }}}}"
|
||||
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_iptables_v4_save }}}}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
|
||||
dest: /etc/enroll/firewall/iptables.v4
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
when: ({var_prefix}_iptables_v4_save | default('') | length) > 0
|
||||
|
||||
- name: Restore captured IPv4 iptables rules
|
||||
ansible.builtin.command:
|
||||
cmd: iptables-restore /etc/enroll/firewall/iptables.v4
|
||||
register: _enroll_iptables_v4_restore
|
||||
changed_when: _enroll_iptables_v4_restore.rc == 0
|
||||
when:
|
||||
- ({var_prefix}_iptables_v4_save | default('') | length) > 0
|
||||
- {var_prefix}_restore_iptables | default(true) | bool
|
||||
|
||||
- name: Deploy captured IPv6 iptables snapshot
|
||||
vars:
|
||||
_enroll_ff:
|
||||
files:
|
||||
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_iptables_v6_save }}}}"
|
||||
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_iptables_v6_save }}}}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
|
||||
dest: /etc/enroll/firewall/iptables.v6
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
when: ({var_prefix}_iptables_v6_save | default('') | length) > 0
|
||||
|
||||
- name: Restore captured IPv6 iptables rules
|
||||
ansible.builtin.command:
|
||||
cmd: ip6tables-restore /etc/enroll/firewall/iptables.v6
|
||||
register: _enroll_iptables_v6_restore
|
||||
changed_when: _enroll_iptables_v6_restore.rc == 0
|
||||
when:
|
||||
- ({var_prefix}_iptables_v6_save | default('') | length) > 0
|
||||
- {var_prefix}_restore_iptables | default(true) | bool
|
||||
"""
|
||||
|
||||
|
||||
def _prepare_bundle_dir(
|
||||
bundle: str,
|
||||
*,
|
||||
|
|
@ -746,6 +837,7 @@ def _manifest_from_bundle_dir(
|
|||
users_snapshot: Dict[str, Any] = roles.get("users", {})
|
||||
apt_config_snapshot: Dict[str, Any] = roles.get("apt_config", {})
|
||||
dnf_config_snapshot: Dict[str, Any] = roles.get("dnf_config", {})
|
||||
firewall_runtime_snapshot: Dict[str, Any] = roles.get("firewall_runtime", {})
|
||||
etc_custom_snapshot: Dict[str, Any] = roles.get("etc_custom", {})
|
||||
usr_local_custom_snapshot: Dict[str, Any] = roles.get("usr_local_custom", {})
|
||||
extra_paths_snapshot: Dict[str, Any] = roles.get("extra_paths", {})
|
||||
|
|
@ -782,6 +874,7 @@ def _manifest_from_bundle_dir(
|
|||
manifested_users_roles: List[str] = []
|
||||
manifested_apt_config_roles: List[str] = []
|
||||
manifested_dnf_config_roles: List[str] = []
|
||||
manifested_firewall_runtime_roles: List[str] = []
|
||||
manifested_etc_custom_roles: List[str] = []
|
||||
manifested_usr_local_custom_roles: List[str] = []
|
||||
manifested_extra_paths_roles: List[str] = []
|
||||
|
|
@ -1332,6 +1425,104 @@ DNF/YUM configuration harvested from the system (repos, config files, and RPM GP
|
|||
|
||||
manifested_dnf_config_roles.append(role)
|
||||
|
||||
# -------------------------
|
||||
# firewall_runtime role (live ipset/iptables kernel state)
|
||||
# -------------------------
|
||||
if firewall_runtime_snapshot and (
|
||||
firewall_runtime_snapshot.get("ipset_save")
|
||||
or firewall_runtime_snapshot.get("iptables_v4_save")
|
||||
or firewall_runtime_snapshot.get("iptables_v6_save")
|
||||
):
|
||||
role = firewall_runtime_snapshot.get("role_name", "firewall_runtime")
|
||||
role_dir = os.path.join(roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
|
||||
var_prefix = role
|
||||
packages = firewall_runtime_snapshot.get("packages", []) or []
|
||||
ipset_save = firewall_runtime_snapshot.get("ipset_save") or ""
|
||||
ipset_sets = firewall_runtime_snapshot.get("ipset_sets", []) or []
|
||||
iptables_v4_save = firewall_runtime_snapshot.get("iptables_v4_save") or ""
|
||||
iptables_v6_save = firewall_runtime_snapshot.get("iptables_v6_save") or ""
|
||||
notes = firewall_runtime_snapshot.get("notes", []) or []
|
||||
|
||||
# Generated firewall snapshots are host-specific in site mode.
|
||||
if site_mode:
|
||||
_copy_artifacts(
|
||||
bundle_dir,
|
||||
role,
|
||||
_host_role_files_dir(out_dir, fqdn or "", role),
|
||||
)
|
||||
else:
|
||||
_copy_artifacts(bundle_dir, role, os.path.join(role_dir, "files"))
|
||||
|
||||
vars_map: Dict[str, Any] = {
|
||||
f"{var_prefix}_packages": packages,
|
||||
f"{var_prefix}_ipset_save": ipset_save,
|
||||
f"{var_prefix}_ipset_sets": ipset_sets,
|
||||
f"{var_prefix}_iptables_v4_save": iptables_v4_save,
|
||||
f"{var_prefix}_iptables_v6_save": iptables_v6_save,
|
||||
f"{var_prefix}_sync_ipsets_exact": True,
|
||||
f"{var_prefix}_restore_iptables": True,
|
||||
}
|
||||
|
||||
if site_mode:
|
||||
_write_role_defaults(
|
||||
role_dir,
|
||||
{
|
||||
f"{var_prefix}_packages": [],
|
||||
f"{var_prefix}_ipset_save": "",
|
||||
f"{var_prefix}_ipset_sets": [],
|
||||
f"{var_prefix}_iptables_v4_save": "",
|
||||
f"{var_prefix}_iptables_v6_save": "",
|
||||
f"{var_prefix}_sync_ipsets_exact": True,
|
||||
f"{var_prefix}_restore_iptables": True,
|
||||
},
|
||||
)
|
||||
_write_hostvars(out_dir, fqdn or "", role, vars_map)
|
||||
else:
|
||||
_write_role_defaults(role_dir, vars_map)
|
||||
|
||||
tasks = (
|
||||
"---\n"
|
||||
+ _render_install_packages_tasks(role, var_prefix)
|
||||
+ _render_firewall_runtime_tasks(var_prefix)
|
||||
)
|
||||
with open(
|
||||
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(tasks.rstrip() + "\n")
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
||||
readme = f"""# {role}
|
||||
|
||||
Generated from live firewall runtime state captured during harvest.
|
||||
|
||||
This role restores live ipset and iptables state only for firewall families where Enroll did not find corresponding persistent configuration on the source host. Static firewall configuration files, such as `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, UFW, nftables, firewalld, or `/etc/ipset*`, are harvested separately as managed files and treated as authoritative for their respective family.
|
||||
|
||||
## Captured snapshots
|
||||
- ipset: {ipset_save or "(none)"}
|
||||
- iptables IPv4: {iptables_v4_save or "(none)"}
|
||||
- iptables IPv6: {iptables_v6_save or "(none)"}
|
||||
|
||||
## Captured ipsets
|
||||
{os.linesep.join("- " + x for x in ipset_sets) or "- (none)"}
|
||||
|
||||
## Notes
|
||||
{os.linesep.join("- " + n for n in notes) or "- (none)"}
|
||||
|
||||
## Safety notes
|
||||
- `firewall_runtime_sync_ipsets_exact` defaults to `true`; it flushes captured set members before replaying the saved members so stale entries are removed. This applies only when no persistent ipset config was found.
|
||||
- `firewall_runtime_restore_iptables` defaults to `true`; `iptables-restore`/`ip6tables-restore` replace only captured families. A family is captured only when no corresponding persistent iptables config was found.
|
||||
"""
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifested_firewall_runtime_roles.append(role)
|
||||
|
||||
# -------------------------
|
||||
# etc_custom role (unowned /etc not already attributed)
|
||||
# -------------------------
|
||||
|
|
@ -2012,6 +2203,7 @@ Generated for package `{pkg}`.
|
|||
+ manifested_extra_paths_roles
|
||||
+ manifested_users_roles
|
||||
+ tail_roles
|
||||
+ manifested_firewall_runtime_roles
|
||||
)
|
||||
|
||||
if site_mode:
|
||||
|
|
|
|||
121
enroll/remote.py
121
enroll/remote.py
|
|
@ -18,6 +18,10 @@ class RemoteSudoPasswordRequired(RuntimeError):
|
|||
"""Raised when sudo requires a password but none was provided."""
|
||||
|
||||
|
||||
class RemoteSSHKeyPassphraseRequired(RuntimeError):
|
||||
"""Raised when SSH private key decryption needs a passphrase."""
|
||||
|
||||
|
||||
def _sudo_password_required(out: str, err: str) -> bool:
|
||||
"""Return True if sudo output indicates it needs a password/TTY."""
|
||||
blob = (out + "\n" + err).lower()
|
||||
|
|
@ -68,11 +72,42 @@ def _resolve_become_password(
|
|||
return None
|
||||
|
||||
|
||||
def _resolve_ssh_key_passphrase(
|
||||
ask_key_passphrase: bool,
|
||||
*,
|
||||
env_var: Optional[str] = None,
|
||||
prompt: str = "SSH key passphrase: ",
|
||||
getpass_fn: Callable[[str], str] = getpass.getpass,
|
||||
) -> Optional[str]:
|
||||
"""Resolve SSH private-key passphrase from env and/or prompt.
|
||||
|
||||
Precedence:
|
||||
1) --ssh-key-passphrase-env style input (env_var)
|
||||
2) --ask-key-passphrase style interactive prompt
|
||||
3) None
|
||||
"""
|
||||
if env_var:
|
||||
val = os.environ.get(str(env_var))
|
||||
if val is None:
|
||||
raise RuntimeError(
|
||||
"SSH key passphrase environment variable is not set: " f"{env_var}"
|
||||
)
|
||||
return val
|
||||
|
||||
if ask_key_passphrase:
|
||||
return getpass_fn(prompt)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def remote_harvest(
|
||||
*,
|
||||
ask_become_pass: bool = False,
|
||||
ask_key_passphrase: bool = False,
|
||||
ssh_key_passphrase_env: Optional[str] = None,
|
||||
no_sudo: bool = False,
|
||||
prompt: str = "sudo password: ",
|
||||
key_prompt: str = "SSH key passphrase: ",
|
||||
getpass_fn: Optional[Callable[[str], str]] = None,
|
||||
stdin: Optional[TextIO] = None,
|
||||
**kwargs,
|
||||
|
|
@ -97,21 +132,52 @@ def remote_harvest(
|
|||
prompt=prompt,
|
||||
getpass_fn=getpass_fn,
|
||||
)
|
||||
ssh_key_passphrase = _resolve_ssh_key_passphrase(
|
||||
ask_key_passphrase,
|
||||
env_var=ssh_key_passphrase_env,
|
||||
prompt=key_prompt,
|
||||
getpass_fn=getpass_fn,
|
||||
)
|
||||
|
||||
try:
|
||||
return _remote_harvest(sudo_password=sudo_password, no_sudo=no_sudo, **kwargs)
|
||||
except RemoteSudoPasswordRequired:
|
||||
if sudo_password is not None:
|
||||
raise
|
||||
while True:
|
||||
try:
|
||||
return _remote_harvest(
|
||||
sudo_password=sudo_password,
|
||||
no_sudo=no_sudo,
|
||||
ssh_key_passphrase=ssh_key_passphrase,
|
||||
**kwargs,
|
||||
)
|
||||
except RemoteSSHKeyPassphraseRequired:
|
||||
# Already tried a passphrase and still failed.
|
||||
if ssh_key_passphrase is not None:
|
||||
raise RemoteSSHKeyPassphraseRequired(
|
||||
"SSH private key could not be decrypted with the supplied "
|
||||
"passphrase."
|
||||
) from None
|
||||
|
||||
# Fallback prompt if interactive
|
||||
if stdin is not None and getattr(stdin, "isatty", lambda: False)():
|
||||
pw = getpass_fn(prompt)
|
||||
return _remote_harvest(sudo_password=pw, no_sudo=no_sudo, **kwargs)
|
||||
# Fallback prompt if interactive.
|
||||
if stdin is not None and getattr(stdin, "isatty", lambda: False)():
|
||||
ssh_key_passphrase = getpass_fn(key_prompt)
|
||||
continue
|
||||
|
||||
raise RemoteSudoPasswordRequired(
|
||||
"Remote sudo requires a password. Re-run with --ask-become-pass."
|
||||
)
|
||||
raise RemoteSSHKeyPassphraseRequired(
|
||||
"SSH private key is encrypted and needs a passphrase. "
|
||||
"Re-run with --ask-key-passphrase or "
|
||||
"--ssh-key-passphrase-env VAR."
|
||||
)
|
||||
|
||||
except RemoteSudoPasswordRequired:
|
||||
if sudo_password is not None:
|
||||
raise
|
||||
|
||||
# Fallback prompt if interactive.
|
||||
if stdin is not None and getattr(stdin, "isatty", lambda: False)():
|
||||
sudo_password = getpass_fn(prompt)
|
||||
continue
|
||||
|
||||
raise RemoteSudoPasswordRequired(
|
||||
"Remote sudo requires a password. Re-run with --ask-become-pass."
|
||||
)
|
||||
|
||||
|
||||
def _safe_extract_tar(tar: tarfile.TarFile, dest: Path) -> None:
|
||||
|
|
@ -337,6 +403,7 @@ def _remote_harvest(
|
|||
dangerous: bool = False,
|
||||
no_sudo: bool = False,
|
||||
sudo_password: Optional[str] = None,
|
||||
ssh_key_passphrase: Optional[str] = None,
|
||||
include_paths: Optional[list[str]] = None,
|
||||
exclude_paths: Optional[list[str]] = None,
|
||||
) -> Path:
|
||||
|
|
@ -467,18 +534,24 @@ def _remote_harvest(
|
|||
|
||||
# If we created a socket (sock!=None), pass hostkey_name as hostname so
|
||||
# known_hosts lookup uses HostKeyAlias (or whatever hostkey_name resolved to).
|
||||
ssh.connect(
|
||||
hostname=hostkey_name if sock is not None else connect_host,
|
||||
port=connect_port,
|
||||
username=connect_user,
|
||||
key_filename=key_filename,
|
||||
sock=sock,
|
||||
allow_agent=True,
|
||||
look_for_keys=True,
|
||||
timeout=connect_timeout,
|
||||
banner_timeout=connect_timeout,
|
||||
auth_timeout=connect_timeout,
|
||||
)
|
||||
try:
|
||||
ssh.connect(
|
||||
hostname=hostkey_name if sock is not None else connect_host,
|
||||
port=connect_port,
|
||||
username=connect_user,
|
||||
key_filename=key_filename,
|
||||
sock=sock,
|
||||
allow_agent=True,
|
||||
look_for_keys=True,
|
||||
timeout=connect_timeout,
|
||||
banner_timeout=connect_timeout,
|
||||
auth_timeout=connect_timeout,
|
||||
passphrase=ssh_key_passphrase,
|
||||
)
|
||||
except paramiko.PasswordRequiredException as e: # type: ignore[attr-defined]
|
||||
raise RemoteSSHKeyPassphraseRequired(
|
||||
"SSH private key is encrypted and no passphrase was provided."
|
||||
) from e
|
||||
|
||||
# If no username was explicitly provided, SSH may have selected a default.
|
||||
# We need a concrete username for the (sudo) chown step below.
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
"enum": [
|
||||
"user_excluded",
|
||||
"unreadable",
|
||||
"backup_file",
|
||||
"backup_file",
|
||||
"log_file",
|
||||
"denied_path",
|
||||
"too_large",
|
||||
|
|
@ -315,6 +315,23 @@
|
|||
"ref"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "firewall_runtime"
|
||||
},
|
||||
"ref": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"ref"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -579,6 +596,62 @@
|
|||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"FirewallRuntimeSnapshot": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"role_name": {
|
||||
"const": "firewall_runtime"
|
||||
},
|
||||
"packages": {
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"ipset_save": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"ipset_sets": {
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"iptables_v4_save": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"iptables_v6_save": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"notes": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role_name",
|
||||
"packages",
|
||||
"ipset_save",
|
||||
"ipset_sets",
|
||||
"iptables_v4_save",
|
||||
"iptables_v6_save",
|
||||
"notes"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"$id": "https://enroll.sh/schema/state.schema.json",
|
||||
|
|
@ -686,6 +759,9 @@
|
|||
},
|
||||
"usr_local_custom": {
|
||||
"$ref": "#/$defs/UsrLocalCustomSnapshot"
|
||||
},
|
||||
"firewall_runtime": {
|
||||
"$ref": "#/$defs/FirewallRuntimeSnapshot"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -197,6 +197,37 @@ def validate_harvest(
|
|||
f"artifact is not a file for role {role_name}: artifacts/{role_name}/{src_rel}"
|
||||
)
|
||||
|
||||
# Runtime firewall snapshots are generated artifacts rather than managed files.
|
||||
fw = (state.get("roles") or {}).get("firewall_runtime") or {}
|
||||
if isinstance(fw, dict):
|
||||
for key in ("ipset_save", "iptables_v4_save", "iptables_v6_save"):
|
||||
src_rel = str(fw.get(key) or "")
|
||||
if not src_rel:
|
||||
continue
|
||||
if src_rel.startswith("/") or ".." in src_rel.split("/"):
|
||||
errors.append(
|
||||
f"firewall_runtime {key} has suspicious src_rel: {src_rel!r}"
|
||||
)
|
||||
continue
|
||||
referenced.add(
|
||||
(str(fw.get("role_name") or "firewall_runtime"), src_rel)
|
||||
)
|
||||
p = (
|
||||
artifacts_dir
|
||||
/ str(fw.get("role_name") or "firewall_runtime")
|
||||
/ src_rel
|
||||
)
|
||||
if not p.exists():
|
||||
errors.append(
|
||||
"missing firewall runtime artifact: "
|
||||
f"artifacts/{fw.get('role_name') or 'firewall_runtime'}/{src_rel}"
|
||||
)
|
||||
elif not p.is_file():
|
||||
errors.append(
|
||||
"firewall runtime artifact is not a file: "
|
||||
f"artifacts/{fw.get('role_name') or 'firewall_runtime'}/{src_rel}"
|
||||
)
|
||||
|
||||
# Warn if there are extra files in artifacts not referenced.
|
||||
if artifacts_dir.exists() and artifacts_dir.is_dir():
|
||||
for fp in artifacts_dir.rglob("*"):
|
||||
|
|
|
|||
737
poetry.lock
generated
737
poetry.lock
generated
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
version = "26.1.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"},
|
||||
{file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"},
|
||||
{file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
|
||||
{file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -89,13 +89,13 @@ typecheck = ["mypy"]
|
|||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
version = "2026.4.22"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"},
|
||||
{file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"},
|
||||
{file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"},
|
||||
{file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -196,124 +196,140 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
|
|||
|
||||
[[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"
|
||||
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]]
|
||||
|
|
@ -329,103 +345,117 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.1"
|
||||
version = "7.14.0"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f"},
|
||||
{file = "coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a"},
|
||||
{file = "coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6"},
|
||||
{file = "coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500"},
|
||||
{file = "coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2"},
|
||||
{file = "coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a"},
|
||||
{file = "coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79"},
|
||||
{file = "coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4"},
|
||||
{file = "coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573"},
|
||||
{file = "coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"},
|
||||
{file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"},
|
||||
{file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"},
|
||||
{file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"},
|
||||
{file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"},
|
||||
{file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"},
|
||||
{file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"},
|
||||
{file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"},
|
||||
{file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"},
|
||||
{file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -436,80 +466,68 @@ toml = ["tomli"]
|
|||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
version = "48.0.0"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.9"
|
||||
files = [
|
||||
{file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
|
||||
{file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
|
||||
{file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
|
||||
{file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
|
||||
{file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
|
||||
{file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
|
||||
{file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
|
||||
{file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"},
|
||||
{file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"},
|
||||
{file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"},
|
||||
{file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"},
|
||||
{file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"},
|
||||
{file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"},
|
||||
{file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"},
|
||||
{file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"},
|
||||
{file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"},
|
||||
{file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"},
|
||||
{file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\""}
|
||||
cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""}
|
||||
typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
|
||||
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
|
||||
nox = ["nox[uv] (>=2024.4.15)"]
|
||||
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
|
||||
sdist = ["build (>=1.0.0)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
name = "desktop-entry-lib"
|
||||
|
|
@ -544,17 +562,17 @@ test = ["pytest (>=6)"]
|
|||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.15"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
||||
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
||||
{file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"},
|
||||
{file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"},
|
||||
]
|
||||
|
||||
[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"
|
||||
|
|
@ -569,13 +587,13 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "invoke"
|
||||
version = "2.2.1"
|
||||
version = "3.0.3"
|
||||
description = "Pythonic task execution"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8"},
|
||||
{file = "invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707"},
|
||||
{file = "invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053"},
|
||||
{file = "invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -615,24 +633,24 @@ referencing = ">=0.31.0"
|
|||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
version = "26.2"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
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]]
|
||||
name = "paramiko"
|
||||
version = "4.0.0"
|
||||
version = "5.0.0"
|
||||
description = "SSH2 protocol library"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9"},
|
||||
{file = "paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f"},
|
||||
{file = "paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c"},
|
||||
{file = "paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -641,9 +659,6 @@ cryptography = ">=3.3"
|
|||
invoke = ">=2.0"
|
||||
pynacl = ">=1.5"
|
||||
|
||||
[package.extras]
|
||||
gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
|
@ -661,24 +676,24 @@ testing = ["coverage", "pytest", "pytest-benchmark"]
|
|||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
version = "3.0"
|
||||
description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.10"
|
||||
files = [
|
||||
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
|
||||
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
|
||||
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
|
||||
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
|
||||
]
|
||||
|
||||
[[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"
|
||||
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]
|
||||
|
|
@ -882,24 +897,24 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
|
|||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.34.1"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
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.1-py3-none-any.whl", hash = "sha256:bf38a3ff993960d3dd819c08862c40b3c703306eb7c744fcd9f4ddbb95b548f0"},
|
||||
{file = "requests-2.34.1.tar.gz", hash = "sha256:0fc5669f2b69704449fe1552360bd2a73a54512dfd03e65529157f1513322beb"},
|
||||
]
|
||||
|
||||
[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 = "rpds-py"
|
||||
|
|
@ -1027,58 +1042,58 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.0"
|
||||
version = "2.4.1"
|
||||
description = "A lil' TOML parser"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867"},
|
||||
{file = "tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9"},
|
||||
{file = "tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95"},
|
||||
{file = "tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76"},
|
||||
{file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d"},
|
||||
{file = "tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576"},
|
||||
{file = "tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a"},
|
||||
{file = "tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa"},
|
||||
{file = "tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da"},
|
||||
{file = "tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87"},
|
||||
{file = "tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475"},
|
||||
{file = "tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd"},
|
||||
{file = "tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4"},
|
||||
{file = "tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a"},
|
||||
{file = "tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c"},
|
||||
{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"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1094,13 +1109,13 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
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"
|
||||
files = [
|
||||
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
|
||||
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
|
||||
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
|
||||
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "enroll"
|
||||
version = "0.4.3"
|
||||
version = "0.6.0"
|
||||
description = "Enroll a server's running state retrospectively into Ansible"
|
||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ done
|
|||
# RPM
|
||||
sudo apt-get -y install createrepo-c rpm
|
||||
BUILD_OUTPUT="${HOME}/git/enroll/dist"
|
||||
KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D"
|
||||
KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99"
|
||||
REPO_ROOT="${HOME}/git/repo_rpm"
|
||||
REMOTE="letessier.mig5.net:/opt/repo_rpm"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
%global upstream_version 0.4.3
|
||||
%global upstream_version 0.6.0
|
||||
|
||||
Name: enroll
|
||||
Version: %{upstream_version}
|
||||
|
|
@ -43,6 +43,13 @@ Enroll a server's running state retrospectively into Ansible.
|
|||
%{_bindir}/enroll
|
||||
|
||||
%changelog
|
||||
* Thu May 14 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||
- Add support for capturing ipset and iptables configuration files
|
||||
- Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role)
|
||||
* Tue May 12 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||
- Add ssh config support where JinjaTurtle is used
|
||||
* Tue Feb 16 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||
- Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
|
||||
* Fri Jan 16 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||
- Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
|
||||
* Tue Jan 13 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||
|
|
|
|||
|
|
@ -141,3 +141,174 @@ def test_collect_non_system_users(monkeypatch, tmp_path: Path):
|
|||
assert u.primary_group == "users"
|
||||
assert u.supplementary_groups == ["admins"]
|
||||
assert u.ssh_files == ["/home/alice/.ssh/authorized_keys"]
|
||||
|
||||
|
||||
def test_parse_login_defs_file_not_found(tmp_path: Path):
|
||||
from enroll.accounts import parse_login_defs
|
||||
|
||||
nonexistent = tmp_path / "nonexistent" / "login.defs"
|
||||
vals = parse_login_defs(str(nonexistent))
|
||||
assert vals == {}
|
||||
|
||||
|
||||
def test_parse_login_defs_handles_invalid_numbers(tmp_path: Path):
|
||||
from enroll.accounts import parse_login_defs
|
||||
|
||||
p = tmp_path / "login.defs"
|
||||
p.write_text("UID_MIN not_a_number\nUID_MAX 60000\n", encoding="utf-8")
|
||||
vals = parse_login_defs(str(p))
|
||||
assert "UID_MIN" not in vals
|
||||
assert vals["UID_MAX"] == 60000
|
||||
|
||||
|
||||
def test_parse_group_handles_invalid_gid(tmp_path: Path):
|
||||
from enroll.accounts import parse_group
|
||||
|
||||
p = tmp_path / "group"
|
||||
p.write_text(
|
||||
"valid:x:1000:user1\n" "invalid_gid:x:notanint:user2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
gid_to_name, name_to_gid, members = parse_group(str(p))
|
||||
assert 1000 in gid_to_name
|
||||
assert gid_to_name[1000] == "valid"
|
||||
assert "invalid_gid" not in name_to_gid
|
||||
|
||||
|
||||
def test_parse_group_line_too_short(tmp_path: Path):
|
||||
from enroll.accounts import parse_group
|
||||
|
||||
p = tmp_path / "group"
|
||||
p.write_text(
|
||||
"valid:x:1000:user1\n" "shortline:x:1001\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
gid_to_name, name_to_gid, members = parse_group(str(p))
|
||||
assert 1000 in gid_to_name
|
||||
assert 1001 not in gid_to_name
|
||||
|
||||
|
||||
def test_is_human_user_filters_by_uid_and_shell():
|
||||
from enroll.accounts import is_human_user
|
||||
|
||||
assert is_human_user(1000, "/bin/bash", 1000) is True
|
||||
assert is_human_user(999, "/bin/bash", 1000) is False
|
||||
assert is_human_user(1000, "/usr/sbin/nologin", 1000) is False
|
||||
assert is_human_user(1000, "/usr/bin/nologin", 1000) is False
|
||||
assert is_human_user(1000, "/bin/false", 1000) is False
|
||||
assert is_human_user(1000, "", 1000) is True
|
||||
|
||||
|
||||
def test_find_user_ssh_files_no_ssh_dir(tmp_path: Path):
|
||||
from enroll.accounts import find_user_ssh_files
|
||||
|
||||
home = tmp_path / "home" / "user"
|
||||
home.mkdir(parents=True)
|
||||
assert find_user_ssh_files(str(home)) == []
|
||||
|
||||
|
||||
def test_find_user_ssh_files_ignores_symlink(tmp_path: Path):
|
||||
from enroll.accounts import find_user_ssh_files
|
||||
|
||||
home = tmp_path / "home" / "user"
|
||||
sshdir = home / ".ssh"
|
||||
sshdir.mkdir(parents=True)
|
||||
target = sshdir / "real_file"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
os.symlink(str(target), str(sshdir / "authorized_keys"))
|
||||
|
||||
result = find_user_ssh_files(str(home))
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_find_user_ssh_files_handles_home_not_starting_with_slash():
|
||||
from enroll.accounts import find_user_ssh_files
|
||||
|
||||
assert find_user_ssh_files("relative/path") == []
|
||||
assert find_user_ssh_files("") == []
|
||||
|
||||
|
||||
def test_collect_non_system_users_skips_nologin_users(tmp_path: Path):
|
||||
import enroll.accounts as a
|
||||
|
||||
orig_parse_login_defs = a.parse_login_defs
|
||||
orig_parse_passwd = a.parse_passwd
|
||||
orig_parse_group = a.parse_group
|
||||
|
||||
passwd = tmp_path / "passwd"
|
||||
passwd.write_text(
|
||||
"root:x:0:0:root:/root:/bin/bash\n"
|
||||
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
|
||||
"nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n"
|
||||
"sysuser:x:100:100:Sys:/home/sys:/bin/bash\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
group = tmp_path / "group"
|
||||
group.write_text("users:x:1000:alice\n", encoding="utf-8")
|
||||
defs = tmp_path / "login.defs"
|
||||
defs.write_text("UID_MIN 1000\n", encoding="utf-8")
|
||||
|
||||
monkeypatch_wrapper = lambda fn, p: lambda path=str(p): fn(path)
|
||||
|
||||
a.parse_login_defs = monkeypatch_wrapper(orig_parse_login_defs, defs)
|
||||
a.parse_passwd = monkeypatch_wrapper(orig_parse_passwd, passwd)
|
||||
a.parse_group = monkeypatch_wrapper(orig_parse_group, group)
|
||||
a.find_user_ssh_files = lambda home: []
|
||||
|
||||
users = a.collect_non_system_users()
|
||||
assert [u.name for u in users] == ["alice"]
|
||||
|
||||
|
||||
def test_collect_non_system_users_skips_below_uid_min(tmp_path: Path):
|
||||
import enroll.accounts as a
|
||||
|
||||
orig_parse_login_defs = a.parse_login_defs
|
||||
orig_parse_passwd = a.parse_passwd
|
||||
orig_parse_group = a.parse_group
|
||||
|
||||
passwd = tmp_path / "passwd"
|
||||
passwd.write_text(
|
||||
"root:x:0:0:root:/root:/bin/bash\n"
|
||||
"sysuser:x:999:999:Sys:/home/sys:/bin/bash\n"
|
||||
"alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
group = tmp_path / "group"
|
||||
group.write_text("users:x:1000:alice\n", encoding="utf-8")
|
||||
defs = tmp_path / "login.defs"
|
||||
defs.write_text("UID_MIN 1000\n", encoding="utf-8")
|
||||
|
||||
a.parse_login_defs = lambda path=str(defs): orig_parse_login_defs(path)
|
||||
a.parse_passwd = lambda path=str(passwd): orig_parse_passwd(path)
|
||||
a.parse_group = lambda path=str(group): orig_parse_group(path)
|
||||
a.find_user_ssh_files = lambda home: []
|
||||
|
||||
users = a.collect_non_system_users()
|
||||
assert [u.name for u in users] == ["alice"]
|
||||
|
||||
|
||||
def test_parse_group_handles_empty_lines(tmp_path: Path):
|
||||
from enroll.accounts import parse_group
|
||||
|
||||
p = tmp_path / "group"
|
||||
p.write_text(
|
||||
"valid:x:1000:user1\n" "\n" "another:x:1001:user2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
gid_to_name, name_to_gid, members = parse_group(str(p))
|
||||
assert 1000 in gid_to_name
|
||||
assert 1001 in gid_to_name
|
||||
|
||||
|
||||
def test_parse_group_handles_short_lines(tmp_path: Path):
|
||||
from enroll.accounts import parse_group
|
||||
|
||||
p = tmp_path / "group"
|
||||
p.write_text(
|
||||
"valid:x:1000:user1\n" "short:x:1001\n" "another:x:1002:user2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
gid_to_name, name_to_gid, members = parse_group(str(p))
|
||||
assert 1000 in gid_to_name
|
||||
assert 1001 not in gid_to_name # skipped due to short line
|
||||
assert 1002 in gid_to_name
|
||||
|
|
|
|||
|
|
@ -31,3 +31,67 @@ def test_ensure_dir_secure_ignores_chmod_failures(tmp_path: Path, monkeypatch):
|
|||
# Should not raise.
|
||||
_ensure_dir_secure(d)
|
||||
assert d.exists() and d.is_dir()
|
||||
|
||||
|
||||
def test_safe_component_returns_unknown_for_empty_string():
|
||||
from enroll.cache import _safe_component
|
||||
|
||||
assert _safe_component("") == "unknown"
|
||||
assert _safe_component(" ") == "unknown"
|
||||
|
||||
|
||||
def test_safe_component_truncates_long_strings():
|
||||
from enroll.cache import _safe_component
|
||||
|
||||
long_str = "a" * 100
|
||||
result = _safe_component(long_str)
|
||||
assert len(result) <= 64
|
||||
|
||||
|
||||
def test_safe_component_replaces_special_chars():
|
||||
from enroll.cache import _safe_component
|
||||
|
||||
result = _safe_component("hello world!")
|
||||
assert result == "hello_world_"
|
||||
|
||||
|
||||
def test_enroll_cache_dir_uses_xdg_cache_home(monkeypatch):
|
||||
from enroll.cache import enroll_cache_dir
|
||||
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", "/custom/cache")
|
||||
result = enroll_cache_dir()
|
||||
assert str(result) == "/custom/cache/enroll"
|
||||
|
||||
|
||||
def test_harvest_cache_state_json_property():
|
||||
from enroll.cache import HarvestCache
|
||||
|
||||
cache_dir = HarvestCache(dir=Path("/tmp/test"))
|
||||
assert cache_dir.state_json == Path("/tmp/test/state.json")
|
||||
|
||||
|
||||
def test_new_harvest_cache_dir_chmod_fails(tmp_path: Path, monkeypatch):
|
||||
from enroll.cache import new_harvest_cache_dir
|
||||
|
||||
def fake_enroll_cache_dir():
|
||||
return tmp_path / "enroll"
|
||||
|
||||
def fake_chmod(path, mode):
|
||||
raise OSError("no")
|
||||
|
||||
monkeypatch.setattr("enroll.cache.enroll_cache_dir", fake_enroll_cache_dir)
|
||||
monkeypatch.setattr(os, "chmod", fake_chmod)
|
||||
|
||||
# Should not raise even though chmod fails
|
||||
cache = new_harvest_cache_dir(hint="test")
|
||||
assert cache.dir.exists()
|
||||
assert isinstance(cache.dir, Path)
|
||||
|
||||
|
||||
def test_enroll_cache_dir_uses_default_when_xdg_not_set(monkeypatch):
|
||||
from enroll.cache import enroll_cache_dir
|
||||
|
||||
# Remove XDG_CACHE_HOME if it exists
|
||||
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
||||
result = enroll_cache_dir()
|
||||
assert str(result).endswith("/.local/cache/enroll")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
def test_dpkg_owner_parses_output(monkeypatch):
|
||||
|
|
@ -96,3 +97,441 @@ def test_parse_status_conffiles_handles_continuations(tmp_path: Path):
|
|||
assert m["nginx"]["/etc/nginx/nginx.conf"] == "abcdef"
|
||||
assert m["nginx"]["/etc/nginx/mime.types"] == "123456"
|
||||
assert "other" not in m
|
||||
|
||||
|
||||
def test_dpkg_owner_returns_none_on_diversion_only(monkeypatch):
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = ""
|
||||
|
||||
def fake_run(cmd, text, capture_output):
|
||||
return P(0, "diversion by foo from: /etc/something\n")
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
assert d.dpkg_owner("/etc/something") is None
|
||||
|
||||
|
||||
def test_dpkg_owner_handles_line_without_colon(monkeypatch):
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = ""
|
||||
|
||||
def fake_run(cmd, text, capture_output):
|
||||
return P(0, "invalid line without colon\n")
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
assert d.dpkg_owner("/etc/foo") is None
|
||||
|
||||
|
||||
def test_list_manual_packages_returns_empty_on_error(monkeypatch):
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = ""
|
||||
|
||||
def fake_run(cmd, text, capture_output):
|
||||
return P(1, "error")
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
assert d.list_manual_packages() == []
|
||||
|
||||
|
||||
def test_list_installed_packages_handles_exception(monkeypatch):
|
||||
import enroll.debian as d
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
raise Exception("simulated error")
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
assert d.list_installed_packages() == {}
|
||||
|
||||
|
||||
def test_list_installed_packages_parses_output():
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = ""
|
||||
|
||||
original_run = d.subprocess.run
|
||||
|
||||
def fake_run(cmd, text, capture_output, check):
|
||||
return P(0, "nginx\t1.18.0\tamd64\nvim\t8.2\tamd64\n")
|
||||
|
||||
d.subprocess.run = fake_run
|
||||
try:
|
||||
result = d.list_installed_packages()
|
||||
assert "nginx" in result
|
||||
assert result["nginx"][0]["version"] == "1.18.0"
|
||||
assert result["nginx"][0]["arch"] == "amd64"
|
||||
assert "vim" in result
|
||||
finally:
|
||||
d.subprocess.run = original_run
|
||||
|
||||
|
||||
def test_list_installed_packages_skips_invalid_lines():
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = ""
|
||||
|
||||
original_run = d.subprocess.run
|
||||
|
||||
def fake_run(cmd, text, capture_output, check):
|
||||
return P(0, "nginx\t1.18.0\tamd64\ninvalid_line\n\t1.0\tamd64\n")
|
||||
|
||||
d.subprocess.run = fake_run
|
||||
try:
|
||||
result = d.list_installed_packages()
|
||||
assert "nginx" in result
|
||||
assert "invalid_line" not in result
|
||||
finally:
|
||||
d.subprocess.run = original_run
|
||||
|
||||
|
||||
def test_list_installed_packages_handles_empty_name():
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = ""
|
||||
|
||||
original_run = d.subprocess.run
|
||||
|
||||
def fake_run(cmd, text, capture_output, check):
|
||||
return P(0, "\t1.0\tamd64\nnginx\t1.18.0\tamd64\n")
|
||||
|
||||
d.subprocess.run = fake_run
|
||||
try:
|
||||
result = d.list_installed_packages()
|
||||
assert "" not in result
|
||||
assert "nginx" in result
|
||||
finally:
|
||||
d.subprocess.run = original_run
|
||||
|
||||
|
||||
def test_list_installed_packages_sorts_output():
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = ""
|
||||
|
||||
original_run = d.subprocess.run
|
||||
|
||||
def fake_run(cmd, text, capture_output, check):
|
||||
return P(0, "nginx\t1.18.0\tamd64\nnginx\t1.19.0\tarm64\n")
|
||||
|
||||
d.subprocess.run = fake_run
|
||||
try:
|
||||
result = d.list_installed_packages()
|
||||
assert len(result["nginx"]) == 2
|
||||
assert result["nginx"][0]["arch"] == "amd64"
|
||||
assert result["nginx"][1]["arch"] == "arm64"
|
||||
finally:
|
||||
d.subprocess.run = original_run
|
||||
|
||||
|
||||
def test_build_dpkg_etc_index_handles_missing_file(tmp_path: Path):
|
||||
import enroll.debian as d
|
||||
|
||||
info = tmp_path / "info"
|
||||
info.mkdir()
|
||||
# Don't create any .list files
|
||||
|
||||
owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
|
||||
assert owned == set()
|
||||
assert owner_map == {}
|
||||
assert topdir_to_pkgs == {}
|
||||
assert pkg_to_etc == {}
|
||||
|
||||
|
||||
def test_build_dpkg_etc_index_skips_non_etc_paths(tmp_path: Path):
|
||||
import enroll.debian as d
|
||||
|
||||
info = tmp_path / "info"
|
||||
info.mkdir()
|
||||
(info / "foo.list").write_text("/usr/bin/foo\n/etc/bar\n", encoding="utf-8")
|
||||
|
||||
owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
|
||||
assert "/usr/bin/foo" not in owned
|
||||
assert "/etc/bar" in owned
|
||||
assert "foo" not in topdir_to_pkgs
|
||||
|
||||
|
||||
def test_parse_status_conffiles_handles_empty_status(tmp_path: Path):
|
||||
import enroll.debian as d
|
||||
|
||||
status = tmp_path / "status"
|
||||
status.write_text("", encoding="utf-8")
|
||||
m = d.parse_status_conffiles(str(status))
|
||||
assert m == {}
|
||||
|
||||
|
||||
def test_parse_status_conffiles_handles_package_without_conffiles(tmp_path: Path):
|
||||
import enroll.debian as d
|
||||
|
||||
status = tmp_path / "status"
|
||||
status.write_text(
|
||||
"Package: nginx\nVersion: 1\nStatus: install ok installed\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = d.parse_status_conffiles(str(status))
|
||||
assert m == {}
|
||||
|
||||
|
||||
def test_read_pkg_md5sums_returns_empty_if_file_not_exists(tmp_path: Path):
|
||||
import enroll.debian as d
|
||||
|
||||
result = d.read_pkg_md5sums("nonexistent_package")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_read_pkg_md5sums_parses_md5sums_file(tmp_path: Path, monkeypatch):
|
||||
import enroll.debian as d
|
||||
|
||||
info_dir = tmp_path / "info"
|
||||
info_dir.mkdir()
|
||||
md5_file = info_dir / "nginx.md5sums"
|
||||
md5_file.write_text(
|
||||
"abcdef1234567890abcdef1234567890 etc/nginx/nginx.conf\n"
|
||||
"1234567890abcdef1234567890abcdef etc/nginx/sites-enabled/default\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_exists(path):
|
||||
return str(path).endswith("nginx.md5sums")
|
||||
|
||||
monkeypatch.setattr(d.os.path, "exists", fake_exists)
|
||||
|
||||
original_open = open
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if "nginx.md5sums" in str(path):
|
||||
return original_open(md5_file, *args, **kwargs)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.open", fake_open, raising=False)
|
||||
|
||||
result = d.read_pkg_md5sums("nginx")
|
||||
assert result["etc/nginx/nginx.conf"] == "abcdef1234567890abcdef1234567890"
|
||||
assert (
|
||||
result["etc/nginx/sites-enabled/default"] == "1234567890abcdef1234567890abcdef"
|
||||
)
|
||||
|
||||
|
||||
def test_dpkg_owner_raises_on_command_failure(monkeypatch):
|
||||
"""Test _run raises RuntimeError on non-zero exit."""
|
||||
import enroll.debian as d
|
||||
|
||||
class P:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "command failed"
|
||||
|
||||
def fake_run(cmd, text, capture_output, check=False):
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
d._run(["fake", "command"])
|
||||
|
||||
assert "Command failed" in str(exc_info.value)
|
||||
assert "fake" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_build_dpkg_etc_index_skips_invalid_line_formats(tmp_path: Path):
|
||||
"""Test that lines with less than 3 parts are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
info = tmp_path / "info"
|
||||
info.mkdir()
|
||||
# Create a .list file with invalid format (missing tab-separated fields)
|
||||
(info / "foo.list").write_text(
|
||||
"/etc/foo/bar\n" # This is a path, not a tab-separated line
|
||||
"/etc/foo/baz\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Should handle gracefully
|
||||
owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
|
||||
# The path lines should be processed normally
|
||||
assert "/etc/foo/bar" in owned or "/etc/foo/baz" in owned
|
||||
|
||||
|
||||
def test_build_dpkg_etc_index_handles_file_not_found(tmp_path: Path):
|
||||
"""Test that FileNotFoundError is handled gracefully."""
|
||||
import enroll.debian as d
|
||||
|
||||
info = tmp_path / "info"
|
||||
info.mkdir()
|
||||
# Create a .list file that references a non-existent path
|
||||
(info / "foo.list").write_text(
|
||||
"/nonexistent/path\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Should not raise
|
||||
owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
|
||||
# The non-existent path should be skipped
|
||||
assert "/nonexistent/path" not in owned
|
||||
|
||||
|
||||
def test_parse_status_conffiles_skips_empty_lines(tmp_path: Path):
|
||||
"""Test that empty lines in conffiles are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
status = tmp_path / "status"
|
||||
status.write_text(
|
||||
"Package: nginx\n"
|
||||
"Version: 1\n"
|
||||
"Conffiles:\n"
|
||||
" /etc/nginx/nginx.conf abcdef\n"
|
||||
" /etc/nginx/mime.types 123456\n"
|
||||
"\n", # Empty line to trigger flush
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
m = d.parse_status_conffiles(str(status))
|
||||
assert "/etc/nginx/nginx.conf" in m["nginx"]
|
||||
assert "/etc/nginx/mime.types" in m["nginx"]
|
||||
|
||||
|
||||
def test_read_pkg_md5sums_skips_invalid_md5_lines(tmp_path: Path, monkeypatch):
|
||||
"""Test that lines without proper MD5 format are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
info_dir = tmp_path / "info"
|
||||
info_dir.mkdir()
|
||||
md5_file = info_dir / "foo.md5sums"
|
||||
md5_file.write_text(
|
||||
"abcdef1234567890abcdef1234567890 etc/foo/bar\n"
|
||||
"invalid line without proper format\n"
|
||||
"1234567890abcdef1234567890abcdef etc/foo/baz\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_exists(path):
|
||||
return str(path).endswith("foo.md5sums")
|
||||
|
||||
monkeypatch.setattr(d.os.path, "exists", fake_exists)
|
||||
|
||||
original_open = open
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if "foo.md5sums" in str(path):
|
||||
return original_open(md5_file, *args, **kwargs)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.open", fake_open, raising=False)
|
||||
|
||||
result = d.read_pkg_md5sums("foo")
|
||||
assert "etc/foo/bar" in result
|
||||
assert "etc/foo/baz" in result
|
||||
|
||||
|
||||
def test_build_dpkg_etc_index_skips_lines_without_tabs(tmp_path: Path):
|
||||
"""Test that lines without tab separators are skipped (parts < 3)."""
|
||||
import enroll.debian as d
|
||||
|
||||
info = tmp_path / "info"
|
||||
info.mkdir()
|
||||
# Create file with lines that don't have tab separators
|
||||
(info / "foo.list").write_text(
|
||||
"notabseparator\n" # No tab - should be skipped
|
||||
"/etc/foo/bar\n", # This is a path line, processed differently
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
|
||||
# Path lines are still processed
|
||||
assert "/etc/foo/bar" in owned
|
||||
|
||||
|
||||
def test_read_pkg_md5sums_skips_empty_lines(tmp_path: Path, monkeypatch):
|
||||
"""Test that empty lines in md5sums are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
info_dir = tmp_path / "info"
|
||||
info_dir.mkdir()
|
||||
md5_file = info_dir / "bar.md5sums"
|
||||
md5_file.write_text(
|
||||
"abcdef1234567890abcdef1234567890 etc/bar/file1\n"
|
||||
"\n" # Empty line
|
||||
"1234567890abcdef1234567890abcdef etc/bar/file2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_exists(path):
|
||||
return str(path).endswith("bar.md5sums")
|
||||
|
||||
monkeypatch.setattr(d.os.path, "exists", fake_exists)
|
||||
|
||||
original_open = open
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if "bar.md5sums" in str(path):
|
||||
return original_open(md5_file, *args, **kwargs)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.open", fake_open, raising=False)
|
||||
|
||||
result = d.read_pkg_md5sums("bar")
|
||||
assert "etc/bar/file1" in result
|
||||
assert "etc/bar/file2" in result
|
||||
|
||||
|
||||
def test_read_pkg_md5sums_skips_lines_not_starting_with_path(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Test that lines not starting with / are skipped."""
|
||||
import enroll.debian as d
|
||||
|
||||
info_dir = tmp_path / "info"
|
||||
info_dir.mkdir()
|
||||
md5_file = info_dir / "baz.md5sums"
|
||||
md5_file.write_text(
|
||||
"abcdef1234567890abcdef1234567890 etc/baz/file1\n"
|
||||
"invalid line\n" # Doesn't start with /
|
||||
"1234567890abcdef1234567890abcdef etc/baz/file2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def fake_exists(path):
|
||||
return str(path).endswith("baz.md5sums")
|
||||
|
||||
monkeypatch.setattr(d.os.path, "exists", fake_exists)
|
||||
|
||||
original_open = open
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if "baz.md5sums" in str(path):
|
||||
return original_open(md5_file, *args, **kwargs)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("builtins.open", fake_open, raising=False)
|
||||
|
||||
result = d.read_pkg_md5sums("baz")
|
||||
assert "etc/baz/file1" in result
|
||||
assert "etc/baz/file2" in result
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,28 @@
|
|||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import enroll.harvest as h
|
||||
import enroll.harvest as harvest
|
||||
from enroll.platform import PlatformInfo
|
||||
from enroll.systemd import UnitInfo
|
||||
from enroll.pathfilter import PathFilter
|
||||
from enroll.harvest import (
|
||||
_is_confish,
|
||||
_hint_names,
|
||||
_topdirs_for_package,
|
||||
_iter_matching_files,
|
||||
_parse_apt_signed_by,
|
||||
_capture_link,
|
||||
_capture_file,
|
||||
ManagedFile,
|
||||
ManagedLink,
|
||||
ExcludedFile,
|
||||
IgnorePolicy,
|
||||
)
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class AllowAllPolicy:
|
||||
|
|
@ -154,17 +173,17 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
|
|||
else:
|
||||
yield (root, [], [])
|
||||
|
||||
monkeypatch.setattr(h.os.path, "isfile", fake_isfile)
|
||||
monkeypatch.setattr(h.os.path, "isdir", fake_isdir)
|
||||
monkeypatch.setattr(h.os.path, "islink", fake_islink)
|
||||
monkeypatch.setattr(h.os.path, "exists", fake_exists)
|
||||
monkeypatch.setattr(h.os, "walk", fake_walk)
|
||||
monkeypatch.setattr(harvest.os.path, "isfile", fake_isfile)
|
||||
monkeypatch.setattr(harvest.os.path, "isdir", fake_isdir)
|
||||
monkeypatch.setattr(harvest.os.path, "islink", fake_islink)
|
||||
monkeypatch.setattr(harvest.os.path, "exists", fake_exists)
|
||||
monkeypatch.setattr(harvest.os, "walk", fake_walk)
|
||||
|
||||
# Avoid real system access
|
||||
monkeypatch.setattr(h, "list_enabled_services", lambda: ["openvpn.service"])
|
||||
monkeypatch.setattr(h, "list_enabled_timers", lambda: [])
|
||||
monkeypatch.setattr(harvest, "list_enabled_services", lambda: ["openvpn.service"])
|
||||
monkeypatch.setattr(harvest, "list_enabled_timers", lambda: [])
|
||||
monkeypatch.setattr(
|
||||
h,
|
||||
harvest,
|
||||
"get_unit_info",
|
||||
lambda unit: UnitInfo(
|
||||
name=unit,
|
||||
|
|
@ -199,11 +218,11 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
|
|||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
h, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
|
||||
harvest, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
|
||||
)
|
||||
monkeypatch.setattr(h, "get_backend", lambda info=None: backend)
|
||||
monkeypatch.setattr(harvest, "get_backend", lambda info=None: backend)
|
||||
|
||||
monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
|
||||
monkeypatch.setattr(harvest, "collect_non_system_users", lambda: [])
|
||||
|
||||
def fake_stat_triplet(p: str):
|
||||
if p == "/usr/local/bin/myscript":
|
||||
|
|
@ -211,7 +230,7 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
|
|||
# /usr/local/bin/readme.txt remains non-executable
|
||||
return ("root", "root", "0644")
|
||||
|
||||
monkeypatch.setattr(h, "stat_triplet", fake_stat_triplet)
|
||||
monkeypatch.setattr(harvest, "stat_triplet", fake_stat_triplet)
|
||||
|
||||
# Avoid needing source files on disk by implementing our own bundle copier
|
||||
def fake_copy(bundle_dir: str, role_name: str, abs_path: str, src_rel: str):
|
||||
|
|
@ -219,9 +238,9 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
|
|||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_bytes(files.get(abs_path, b""))
|
||||
|
||||
monkeypatch.setattr(h, "_copy_into_bundle", fake_copy)
|
||||
monkeypatch.setattr(harvest, "_copy_into_bundle", fake_copy)
|
||||
|
||||
state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
|
||||
state_path = harvest.harvest(str(bundle), policy=AllowAllPolicy())
|
||||
st = json.loads(Path(state_path).read_text(encoding="utf-8"))
|
||||
|
||||
inv = st["inventory"]["packages"]
|
||||
|
|
@ -274,21 +293,25 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
|
|||
files = {"/etc/cron.d/ntpsec": b"# cron\n"}
|
||||
dirs = {"/etc", "/etc/cron.d"}
|
||||
|
||||
monkeypatch.setattr(h.os.path, "isfile", lambda p: p in files)
|
||||
monkeypatch.setattr(h.os.path, "islink", lambda p: False)
|
||||
monkeypatch.setattr(h.os.path, "isdir", lambda p: p in dirs)
|
||||
monkeypatch.setattr(h.os.path, "exists", lambda p: p in files or p in dirs)
|
||||
monkeypatch.setattr(h.os, "walk", lambda root: [("/etc/cron.d", [], ["ntpsec"])])
|
||||
monkeypatch.setattr(harvest.os.path, "isfile", lambda p: p in files)
|
||||
monkeypatch.setattr(harvest.os.path, "islink", lambda p: False)
|
||||
monkeypatch.setattr(harvest.os.path, "isdir", lambda p: p in dirs)
|
||||
monkeypatch.setattr(harvest.os.path, "exists", lambda p: p in files or p in dirs)
|
||||
monkeypatch.setattr(
|
||||
harvest.os, "walk", lambda root: [("/etc/cron.d", [], ["ntpsec"])]
|
||||
)
|
||||
|
||||
# Only include the cron snippet in the system capture set.
|
||||
monkeypatch.setattr(
|
||||
h, "_iter_system_capture_paths", lambda: [("/etc/cron.d/ntpsec", "system_cron")]
|
||||
harvest,
|
||||
"_iter_system_capture_paths",
|
||||
lambda: [("/etc/cron.d/ntpsec", "system_cron")],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
h, "list_enabled_services", lambda: ["apparmor.service", "ntpsec.service"]
|
||||
harvest, "list_enabled_services", lambda: ["apparmor.service", "ntpsec.service"]
|
||||
)
|
||||
monkeypatch.setattr(h, "list_enabled_timers", lambda: [])
|
||||
monkeypatch.setattr(harvest, "list_enabled_timers", lambda: [])
|
||||
|
||||
def fake_unit_info(unit: str) -> UnitInfo:
|
||||
if unit == "apparmor.service":
|
||||
|
|
@ -315,7 +338,7 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
|
|||
condition_result=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(h, "get_unit_info", fake_unit_info)
|
||||
monkeypatch.setattr(harvest, "get_unit_info", fake_unit_info)
|
||||
|
||||
# Make apparmor *also* claim the ntpsec package (simulates overly-broad
|
||||
# package inference). The snippet routing should still prefer role 'ntpsec'.
|
||||
|
|
@ -340,21 +363,21 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
|
|||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
h, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
|
||||
harvest, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
|
||||
)
|
||||
monkeypatch.setattr(h, "get_backend", lambda info=None: backend)
|
||||
monkeypatch.setattr(harvest, "get_backend", lambda info=None: backend)
|
||||
|
||||
monkeypatch.setattr(h, "stat_triplet", lambda p: ("root", "root", "0644"))
|
||||
monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
|
||||
monkeypatch.setattr(harvest, "stat_triplet", lambda p: ("root", "root", "0644"))
|
||||
monkeypatch.setattr(harvest, "collect_non_system_users", lambda: [])
|
||||
|
||||
def fake_copy(bundle_dir: str, role_name: str, abs_path: str, src_rel: str):
|
||||
dst = Path(bundle_dir) / "artifacts" / role_name / src_rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_bytes(files[abs_path])
|
||||
|
||||
monkeypatch.setattr(h, "_copy_into_bundle", fake_copy)
|
||||
monkeypatch.setattr(harvest, "_copy_into_bundle", fake_copy)
|
||||
|
||||
state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
|
||||
state_path = harvest.harvest(str(bundle), policy=AllowAllPolicy())
|
||||
st = json.loads(Path(state_path).read_text(encoding="utf-8"))
|
||||
|
||||
# Cron snippet should end up attached to the ntpsec role, not apparmor.
|
||||
|
|
@ -367,3 +390,647 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
|
|||
assert all(
|
||||
mf["path"] != "/etc/cron.d/ntpsec" for mf in svc_apparmor["managed_files"]
|
||||
)
|
||||
|
||||
|
||||
def test_files_differ_binary(tmp_path: Path):
|
||||
file1 = tmp_path / "file1.bin"
|
||||
file2 = tmp_path / "file2.bin"
|
||||
file1.write_bytes(b"\x00\x01\x02\x03")
|
||||
file2.write_bytes(b"\x00\x01\x02\x03")
|
||||
assert harvest._files_differ(str(file1), str(file2)) is False
|
||||
|
||||
|
||||
def test_files_differ_binary_different(tmp_path: Path):
|
||||
file1 = tmp_path / "file1.bin"
|
||||
file2 = tmp_path / "file2.bin"
|
||||
file1.write_bytes(b"\x00\x01\x02\x03")
|
||||
file2.write_bytes(b"\x00\x01\x02\x04")
|
||||
assert harvest._files_differ(str(file1), str(file2)) is True
|
||||
|
||||
|
||||
def test_files_differ_non_regular_a(tmp_path: Path):
|
||||
directory = tmp_path / "dir"
|
||||
directory.mkdir()
|
||||
file1 = tmp_path / "file1.txt"
|
||||
file1.write_text("content", encoding="utf-8")
|
||||
assert harvest._files_differ(str(directory), str(file1)) is True
|
||||
|
||||
|
||||
def test_topdirs_for_package_with_multiple_paths():
|
||||
pkg_to_etc_paths = {
|
||||
"nginx": ["/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/default"],
|
||||
}
|
||||
result = harvest._topdirs_for_package("nginx", pkg_to_etc_paths)
|
||||
assert result == {"nginx"}
|
||||
|
||||
|
||||
def test_topdirs_for_package_with_multiple_topdirs():
|
||||
pkg_to_etc_paths = {
|
||||
"multi": ["/etc/nginx/nginx.conf", "/etc/ssh/sshd_config"],
|
||||
}
|
||||
result = harvest._topdirs_for_package("multi", pkg_to_etc_paths)
|
||||
assert result == {"nginx", "ssh"}
|
||||
|
||||
|
||||
def test_topdirs_for_package_empty():
|
||||
result = harvest._topdirs_for_package("empty", {})
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_topdirs_for_package_no_etc():
|
||||
pkg_to_etc_paths = {
|
||||
"other": ["/usr/share/doc/file"],
|
||||
}
|
||||
result = harvest._topdirs_for_package("other", pkg_to_etc_paths)
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_files_differ_same_content(tmp_path: Path):
|
||||
"""Test that _files_differ returns False for identical content."""
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_b = tmp_path / "b.txt"
|
||||
file_a.write_text("same content", encoding="utf-8")
|
||||
file_b.write_text("same content", encoding="utf-8")
|
||||
assert harvest._files_differ(str(file_a), str(file_b)) is False
|
||||
|
||||
|
||||
def test_files_differ_different_content(tmp_path: Path):
|
||||
"""Test that _files_differ returns True for different content."""
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_b = tmp_path / "b.txt"
|
||||
file_a.write_text("content a", encoding="utf-8")
|
||||
file_b.write_text("content b", encoding="utf-8")
|
||||
assert harvest._files_differ(str(file_a), str(file_b)) is True
|
||||
|
||||
|
||||
def test_files_differ_missing_file(tmp_path: Path):
|
||||
"""Test that _files_differ returns True when one file is missing."""
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_a.write_text("content", encoding="utf-8")
|
||||
file_b = tmp_path / "b.txt"
|
||||
assert harvest._files_differ(str(file_a), str(file_b)) is True
|
||||
|
||||
|
||||
def test_files_differ_both_missing(tmp_path: Path):
|
||||
"""Test that _files_differ returns True when both files are missing."""
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_b = tmp_path / "b.txt"
|
||||
# Both missing - should return True (they differ in the sense that neither exists)
|
||||
assert harvest._files_differ(str(file_a), str(file_b)) is True
|
||||
|
||||
|
||||
def test_files_differ_non_regular_b(tmp_path: Path):
|
||||
"""Test that _files_differ handles non-regular file (symlink)."""
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_a.write_text("content", encoding="utf-8")
|
||||
link_b = tmp_path / "link"
|
||||
link_b.symlink_to(file_a)
|
||||
# Symlinks are followed, so content is the same
|
||||
assert harvest._files_differ(str(file_a), str(link_b)) is False
|
||||
|
||||
|
||||
def test_files_differ_oserror_on_read(tmp_path: Path, monkeypatch):
|
||||
"""Test that _files_differ returns True on OSError during read."""
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_b = tmp_path / "b.txt"
|
||||
file_a.write_text("content", encoding="utf-8")
|
||||
file_b.write_text("content", encoding="utf-8")
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
monkeypatch.setattr("builtins.open", fake_open, raising=False)
|
||||
assert harvest._files_differ(str(file_a), str(file_b)) is True
|
||||
|
||||
|
||||
def test_files_differ_large_file_returns_true(tmp_path: Path):
|
||||
"""Test that _files_differ returns True for files larger than max_bytes."""
|
||||
file_a = tmp_path / "a.bin"
|
||||
file_b = tmp_path / "b.bin"
|
||||
# Create files larger than default max_bytes (2MB)
|
||||
data = b"x" * 3_000_000
|
||||
file_a.write_bytes(data)
|
||||
file_b.write_bytes(data)
|
||||
# Should return True because files are too large
|
||||
assert harvest._files_differ(str(file_a), str(file_b), max_bytes=1_000_000) is True
|
||||
|
||||
|
||||
def test_files_differ_size_mismatch(tmp_path: Path):
|
||||
"""Test that _files_differ detects size mismatch quickly."""
|
||||
file_a = tmp_path / "a.txt"
|
||||
file_b = tmp_path / "b.txt"
|
||||
file_a.write_text("short", encoding="utf-8")
|
||||
file_b.write_text("much longer content here", encoding="utf-8")
|
||||
assert harvest._files_differ(str(file_a), str(file_b)) is True
|
||||
|
||||
|
||||
def test_files_differ_large_files(tmp_path: Path):
|
||||
"""Test that _files_differ handles large files efficiently."""
|
||||
file_a = tmp_path / "a.bin"
|
||||
file_b = tmp_path / "b.bin"
|
||||
# Create files with same content but large
|
||||
data = b"x" * 10000
|
||||
file_a.write_bytes(data)
|
||||
file_b.write_bytes(data)
|
||||
assert harvest._files_differ(str(file_a), str(file_b)) is False
|
||||
|
||||
|
||||
def test_hint_names_with_unit_and_packages():
|
||||
"""Test _hint_names extracts hints from unit and packages."""
|
||||
result = harvest._hint_names("nginx.service", {"nginx-common", "nginx-core"})
|
||||
assert "nginx" in result
|
||||
assert "nginx-common" in result
|
||||
assert "nginx-core" in result
|
||||
|
||||
|
||||
def test_hint_names_with_template_unit():
|
||||
"""Test _hint_names handles template units."""
|
||||
result = harvest._hint_names("getty@tty1.service", set())
|
||||
assert "getty" in result
|
||||
assert "getty@tty1" in result
|
||||
|
||||
|
||||
def test_hint_names_with_dotted_unit():
|
||||
"""Test _hint_names handles dotted unit names."""
|
||||
result = harvest._hint_names("nginx.service", set())
|
||||
assert "nginx" in result
|
||||
|
||||
|
||||
def test_hint_names_empty():
|
||||
"""Test _hint_names with empty inputs."""
|
||||
result = harvest._hint_names("", set())
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_add_pkgs_from_etc_topdirs():
|
||||
"""Test _add_pkgs_from_etc_topdirs expands hints."""
|
||||
hints = {"nginx"}
|
||||
topdir_to_pkgs = {
|
||||
"nginx": {"nginx-common", "nginx-core"},
|
||||
"ssh": {"openssh-server"},
|
||||
}
|
||||
pkgs = set()
|
||||
harvest._add_pkgs_from_etc_topdirs(hints, topdir_to_pkgs, pkgs)
|
||||
# Should add packages from matching topdirs
|
||||
assert "nginx-common" in pkgs or "nginx-core" in pkgs
|
||||
|
||||
|
||||
def test_add_pkgs_from_etc_topdirs_empty():
|
||||
"""Test _add_pkgs_from_etc_topdirs with empty inputs."""
|
||||
hints = set()
|
||||
topdir_to_pkgs = {}
|
||||
pkgs = set()
|
||||
harvest._add_pkgs_from_etc_topdirs(hints, topdir_to_pkgs, pkgs)
|
||||
assert pkgs == set()
|
||||
|
||||
|
||||
def test_is_confish_with_conf(tmp_path: Path):
|
||||
"""Test _is_confish recognizes .conf files."""
|
||||
file1 = tmp_path / "test.conf"
|
||||
file1.write_text("[Unit]", encoding="utf-8")
|
||||
assert harvest._is_confish(str(file1)) is True
|
||||
|
||||
|
||||
def test_is_confish_with_yaml(tmp_path: Path):
|
||||
"""Test _is_confish recognizes .yaml files."""
|
||||
file1 = tmp_path / "test.yaml"
|
||||
file1.write_text("key: value", encoding="utf-8")
|
||||
assert harvest._is_confish(str(file1)) is True
|
||||
|
||||
|
||||
def test_is_confish_with_json(tmp_path: Path):
|
||||
"""Test _is_confish recognizes .json files."""
|
||||
file1 = tmp_path / "test.json"
|
||||
file1.write_text('{"key": "value"}', encoding="utf-8")
|
||||
assert harvest._is_confish(str(file1)) is True
|
||||
|
||||
|
||||
def test_is_confish_with_service(tmp_path: Path):
|
||||
"""Test _is_confish recognizes .service files."""
|
||||
file1 = tmp_path / "test.service"
|
||||
file1.write_text("[Unit]", encoding="utf-8")
|
||||
assert harvest._is_confish(str(file1)) is True
|
||||
|
||||
|
||||
def test_is_confish_with_extensionless(tmp_path: Path):
|
||||
"""Test _is_confish recognizes extensionless config files."""
|
||||
file1 = tmp_path / "default"
|
||||
file1.write_text("OPTIONS=", encoding="utf-8")
|
||||
assert harvest._is_confish(str(file1)) is True
|
||||
|
||||
|
||||
def test_is_confish_not_config(tmp_path: Path):
|
||||
"""Test _is_confish rejects non-config files."""
|
||||
file1 = tmp_path / "test.log"
|
||||
file1.write_text("log", encoding="utf-8")
|
||||
assert harvest._is_confish(str(file1)) is False
|
||||
|
||||
|
||||
def test_is_confish_nonexistent():
|
||||
"""Test _is_confish returns False for nonexistent files."""
|
||||
assert harvest._is_confish("/nonexistent/file.xyz") is False
|
||||
|
||||
|
||||
"""Additional coverage tests for harvest.py"""
|
||||
|
||||
|
||||
class TestIsConfish:
|
||||
"""Tests for _is_confish function"""
|
||||
|
||||
def test_is_confish_true_extensions(self, tmp_path):
|
||||
"""Test files with config extensions are detected."""
|
||||
for ext in [".conf", ".cfg", ".ini", ".yaml", ".json", ".cnf"]:
|
||||
f = tmp_path / f"test{ext}"
|
||||
f.write_text("test", encoding="utf-8")
|
||||
assert _is_confish(str(f)) is True
|
||||
|
||||
def test_is_confish_false(self, tmp_path):
|
||||
"""Test non-config files are not detected."""
|
||||
for name in ["data.txt", "script.sh"]:
|
||||
f = tmp_path / name
|
||||
f.write_text("test", encoding="utf-8")
|
||||
assert _is_confish(str(f)) is False
|
||||
|
||||
|
||||
class TestHintNames:
|
||||
"""Tests for _hint_names function"""
|
||||
|
||||
def test_hint_names_simple(self):
|
||||
"""Test simple hint name extraction."""
|
||||
result = _hint_names("nginx", {"nginx"})
|
||||
assert "nginx" in result
|
||||
|
||||
def test_hint_names_multiple(self):
|
||||
"""Test multiple hint names."""
|
||||
result = _hint_names("nginx", {"apache"})
|
||||
assert "nginx" in result
|
||||
assert "apache" in result
|
||||
|
||||
def test_hint_names_empty(self):
|
||||
"""Test empty hint names."""
|
||||
result = _hint_names("", set())
|
||||
assert result == set()
|
||||
|
||||
def test_hint_names_with_service(self):
|
||||
"""Test hint names with .service suffix."""
|
||||
result = _hint_names("nginx.service", set())
|
||||
assert "nginx" in result
|
||||
|
||||
def test_hint_names_with_template(self):
|
||||
"""Test hint names with template unit."""
|
||||
result = _hint_names("nginx@.service", set())
|
||||
assert "nginx" in result
|
||||
|
||||
|
||||
class TestTopdirsForPackage:
|
||||
"""Tests for _topdirs_for_package function"""
|
||||
|
||||
def test_topdirs_single_level(self):
|
||||
"""Test topdirs with single level paths."""
|
||||
pkg_to_etc = {"nginx": ["/etc/nginx/nginx.conf"]}
|
||||
result = _topdirs_for_package("nginx", pkg_to_etc)
|
||||
assert result == {"nginx"}
|
||||
|
||||
def test_topdirs_multiple_paths(self):
|
||||
"""Test topdirs with multiple paths."""
|
||||
pkg_to_etc = {"nginx": ["/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled"]}
|
||||
result = _topdirs_for_package("nginx", pkg_to_etc)
|
||||
assert result == {"nginx"}
|
||||
|
||||
def test_topdirs_empty(self):
|
||||
"""Test topdirs with empty package."""
|
||||
result = _topdirs_for_package("nonexistent", {})
|
||||
assert result == set()
|
||||
|
||||
|
||||
class TestIterMatchingFiles:
|
||||
"""Tests for _iter_matching_files function"""
|
||||
|
||||
def test_iter_matching_files_glob(self, tmp_path):
|
||||
"""Test glob pattern matching."""
|
||||
(tmp_path / "a.txt").write_text("a", encoding="utf-8")
|
||||
(tmp_path / "b.txt").write_text("b", encoding="utf-8")
|
||||
(tmp_path / "c.py").write_text("c", encoding="utf-8")
|
||||
|
||||
os.chdir(tmp_path)
|
||||
result = _iter_matching_files("*.txt")
|
||||
assert len(result) == 2
|
||||
assert any("a.txt" in p for p in result)
|
||||
assert any("b.txt" in p for p in result)
|
||||
|
||||
def test_iter_matching_files_directory_walk(self, tmp_path):
|
||||
"""Test directory walking."""
|
||||
subdir = tmp_path / "sub"
|
||||
subdir.mkdir()
|
||||
(tmp_path / "a.txt").write_text("a", encoding="utf-8")
|
||||
(subdir / "b.txt").write_text("b", encoding="utf-8")
|
||||
|
||||
os.chdir(tmp_path)
|
||||
result = _iter_matching_files(str(tmp_path))
|
||||
assert len(result) == 2
|
||||
|
||||
def test_iter_matching_files_cap(self, tmp_path):
|
||||
"""Test file cap limit."""
|
||||
for i in range(100):
|
||||
(tmp_path / f"file{i}.txt").write_text(str(i), encoding="utf-8")
|
||||
|
||||
os.chdir(tmp_path)
|
||||
result = _iter_matching_files("*.txt", cap=10)
|
||||
assert len(result) == 10
|
||||
|
||||
|
||||
class TestParseAptSignedBy:
|
||||
"""Tests for _parse_apt_signed_by function"""
|
||||
|
||||
def test_parse_apt_signed_by_bracket(self, tmp_path):
|
||||
"""Test parsing signed-by from bracket notation."""
|
||||
sources_list = tmp_path / "sources.list"
|
||||
sources_list.write_text(
|
||||
"deb [signed-by=/usr/share/keyrings/nginx.gpg] http://nginx.net stable main\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _parse_apt_signed_by([str(sources_list)])
|
||||
assert "/usr/share/keyrings/nginx.gpg" in result
|
||||
|
||||
def test_parse_apt_signed_by_header(self, tmp_path):
|
||||
"""Test parsing signed-by from header."""
|
||||
sources_file = tmp_path / "sources.list"
|
||||
sources_file.write_text(
|
||||
"Signed-By: /usr/share/keyrings/foo.gpg\n", encoding="utf-8"
|
||||
)
|
||||
result = _parse_apt_signed_by([str(sources_file)])
|
||||
assert "/usr/share/keyrings/foo.gpg" in result
|
||||
|
||||
def test_parse_apt_signed_by_multiple(self, tmp_path):
|
||||
"""Test parsing multiple signed-by paths."""
|
||||
sources_file = tmp_path / "sources.list"
|
||||
sources_file.write_text(
|
||||
"Signed-By: /usr/share/keyrings/a.gpg, /usr/share/keyrings/b.gpg\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _parse_apt_signed_by([str(sources_file)])
|
||||
assert "/usr/share/keyrings/a.gpg" in result
|
||||
assert "/usr/share/keyrings/b.gpg" in result
|
||||
|
||||
def test_parse_apt_signed_by_oserror(self, tmp_path):
|
||||
"""Test handling of unreadable files."""
|
||||
result = _parse_apt_signed_by(["/nonexistent/file"])
|
||||
assert result == set()
|
||||
|
||||
|
||||
class TestCaptureLink:
|
||||
"""Tests for _capture_link function"""
|
||||
|
||||
def test_capture_link_basic(self, tmp_path):
|
||||
"""Test basic link capture."""
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("content", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
link.symlink_to(target)
|
||||
|
||||
policy = MagicMock(spec=IgnorePolicy)
|
||||
policy.deny_reason_link = None
|
||||
policy.deny_reason = MagicMock(return_value=None)
|
||||
policy.deny_reason_link = None # No special link denial
|
||||
|
||||
managed: list[ManagedLink] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
path_filter = PathFilter([], [])
|
||||
|
||||
result = _capture_link(
|
||||
role_name="test_role",
|
||||
abs_path=str(link),
|
||||
reason="test",
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=set(),
|
||||
seen_global=set(),
|
||||
)
|
||||
assert result is True
|
||||
assert len(managed) == 1
|
||||
assert managed[0].path == str(link)
|
||||
|
||||
def test_capture_link_deny(self, tmp_path):
|
||||
"""Test link capture with deny policy."""
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("content", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
link.symlink_to(target)
|
||||
|
||||
policy = MagicMock(spec=IgnorePolicy)
|
||||
policy.deny_reason_link = None
|
||||
policy.deny_reason = MagicMock(return_value="policy_deny")
|
||||
|
||||
managed: list[ManagedLink] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
path_filter = PathFilter([], [])
|
||||
|
||||
result = _capture_link(
|
||||
role_name="test_role",
|
||||
abs_path=str(link),
|
||||
reason="test",
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=set(),
|
||||
seen_global=set(),
|
||||
)
|
||||
assert result is False
|
||||
assert len(excluded) == 1
|
||||
|
||||
def test_capture_link_not_symlink(self, tmp_path):
|
||||
"""Test that regular files are rejected."""
|
||||
f = tmp_path / "file.txt"
|
||||
f.write_text("content", encoding="utf-8")
|
||||
|
||||
policy = MagicMock(spec=IgnorePolicy)
|
||||
policy.deny_reason_link = None
|
||||
policy.deny_reason = MagicMock(return_value=None)
|
||||
|
||||
managed: list[ManagedLink] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
path_filter = PathFilter([], [])
|
||||
|
||||
result = _capture_link(
|
||||
role_name="test_role",
|
||||
abs_path=str(f),
|
||||
reason="test",
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=set(),
|
||||
seen_global=set(),
|
||||
)
|
||||
assert result is False
|
||||
assert len(excluded) == 1
|
||||
|
||||
def test_capture_link_seen_role(self, tmp_path):
|
||||
"""Test link capture with seen_role deduplication."""
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("content", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
link.symlink_to(target)
|
||||
|
||||
policy = MagicMock(spec=IgnorePolicy)
|
||||
policy.deny_reason_link = None
|
||||
policy.deny_reason = MagicMock(return_value=None)
|
||||
|
||||
managed: list[ManagedLink] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
path_filter = PathFilter([], [])
|
||||
seen_role = {str(link)}
|
||||
|
||||
result = _capture_link(
|
||||
role_name="test_role",
|
||||
abs_path=str(link),
|
||||
reason="test",
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=seen_role,
|
||||
seen_global=None,
|
||||
)
|
||||
assert result is False
|
||||
assert len(managed) == 0
|
||||
|
||||
def test_capture_link_seen_global(self, tmp_path):
|
||||
"""Test link capture with seen_global deduplication."""
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("content", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
link.symlink_to(target)
|
||||
|
||||
policy = MagicMock(spec=IgnorePolicy)
|
||||
policy.deny_reason_link = None
|
||||
policy.deny_reason = MagicMock(return_value=None)
|
||||
|
||||
managed: list[ManagedLink] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
path_filter = PathFilter([], [])
|
||||
seen_global = {str(link)}
|
||||
|
||||
result = _capture_link(
|
||||
role_name="test_role",
|
||||
abs_path=str(link),
|
||||
reason="test",
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=None,
|
||||
seen_global=seen_global,
|
||||
)
|
||||
assert result is False
|
||||
assert len(managed) == 0
|
||||
|
||||
|
||||
class TestCaptureFile:
|
||||
"""Tests for _capture_file function"""
|
||||
|
||||
def test_capture_file_basic(self, tmp_path):
|
||||
"""Test basic file capture."""
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
(bundle / "artifacts").mkdir()
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("content", encoding="utf-8")
|
||||
|
||||
policy = MagicMock(spec=IgnorePolicy)
|
||||
policy.deny_reason_link = None
|
||||
policy.deny_reason = MagicMock(return_value=None)
|
||||
|
||||
managed: list[ManagedFile] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
path_filter = PathFilter([], [])
|
||||
|
||||
result = _capture_file(
|
||||
bundle_dir=str(bundle),
|
||||
role_name="test_role",
|
||||
abs_path=str(source),
|
||||
reason="test",
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=set(),
|
||||
seen_global=set(),
|
||||
metadata=None,
|
||||
)
|
||||
assert result is True
|
||||
assert len(managed) == 1
|
||||
|
||||
def test_capture_file_seen_role(self, tmp_path):
|
||||
"""Test file capture with seen_role deduplication."""
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("content", encoding="utf-8")
|
||||
|
||||
policy = MagicMock(spec=IgnorePolicy)
|
||||
policy.deny_reason_link = None
|
||||
policy.deny_reason = MagicMock(return_value=None)
|
||||
|
||||
managed: list[ManagedFile] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
path_filter = PathFilter([], [])
|
||||
seen_role = {str(source)}
|
||||
|
||||
result = _capture_file(
|
||||
bundle_dir=str(bundle),
|
||||
role_name="test_role",
|
||||
abs_path=str(source),
|
||||
reason="test",
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=seen_role,
|
||||
seen_global=None,
|
||||
metadata=None,
|
||||
)
|
||||
assert result is False
|
||||
assert len(managed) == 0
|
||||
|
||||
def test_capture_file_seen_global(self, tmp_path):
|
||||
"""Test file capture with seen_global deduplication."""
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("content", encoding="utf-8")
|
||||
|
||||
policy = MagicMock(spec=IgnorePolicy)
|
||||
policy.deny_reason_link = None
|
||||
policy.deny_reason = MagicMock(return_value=None)
|
||||
|
||||
managed: list[ManagedFile] = []
|
||||
excluded: list[ExcludedFile] = []
|
||||
path_filter = PathFilter([], [])
|
||||
seen_global = {str(source)}
|
||||
|
||||
result = _capture_file(
|
||||
bundle_dir=str(bundle),
|
||||
role_name="test_role",
|
||||
abs_path=str(source),
|
||||
reason="test",
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed,
|
||||
excluded_out=excluded,
|
||||
seen_role=None,
|
||||
seen_global=seen_global,
|
||||
metadata=None,
|
||||
)
|
||||
assert result is False
|
||||
assert len(managed) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
|
|||
|
|
@ -168,3 +168,121 @@ def test_iter_system_capture_paths_dedupes_first_reason(monkeypatch):
|
|||
)
|
||||
out = h._iter_system_capture_paths()
|
||||
assert out == [("/dup", "r1")]
|
||||
|
||||
|
||||
def test_ipset_and_iptables_state_helpers(tmp_path: Path):
|
||||
ipset_save = """create blocklist hash:ip family inet hashsize 1024 maxelem 65536
|
||||
add blocklist 203.0.113.10
|
||||
create nets hash:net family inet
|
||||
"""
|
||||
assert h._ipset_save_has_state(ipset_save)
|
||||
assert h._parse_ipset_set_names(ipset_save) == ["blocklist", "nets"]
|
||||
assert not h._ipset_save_has_state("# empty\n")
|
||||
|
||||
empty_iptables = """*filter
|
||||
:INPUT ACCEPT [0:0]
|
||||
:FORWARD ACCEPT [0:0]
|
||||
:OUTPUT ACCEPT [0:0]
|
||||
COMMIT
|
||||
"""
|
||||
assert not h._iptables_save_has_state(empty_iptables)
|
||||
|
||||
native_rule = empty_iptables.replace(
|
||||
"COMMIT", "-A INPUT -p tcp --dport 22 -j ACCEPT\nCOMMIT"
|
||||
)
|
||||
assert h._iptables_save_has_state(native_rule)
|
||||
|
||||
changed_policy = empty_iptables.replace(":INPUT ACCEPT", ":INPUT DROP")
|
||||
assert h._iptables_save_has_state(changed_policy)
|
||||
|
||||
|
||||
def test_collect_firewall_runtime_snapshot_writes_generated_artifacts(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
outputs = {
|
||||
"ipset_save": (
|
||||
"create blocklist hash:ip family inet\nadd blocklist 203.0.113.10\n",
|
||||
None,
|
||||
),
|
||||
"iptables_v4_save": (
|
||||
"*filter\n:INPUT DROP [0:0]\n-A INPUT -m set --match-set blocklist src -j DROP\nCOMMIT\n",
|
||||
None,
|
||||
),
|
||||
"iptables_v6_save": ("*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n", None),
|
||||
}
|
||||
|
||||
def fake_run(command_key, *, timeout=10):
|
||||
return outputs[command_key]
|
||||
|
||||
monkeypatch.setattr(h, "_run_capture_command", fake_run)
|
||||
|
||||
snap = h._collect_firewall_runtime_snapshot(str(tmp_path))
|
||||
assert snap.role_name == "firewall_runtime"
|
||||
assert snap.packages == ["ipset", "iptables"]
|
||||
assert snap.ipset_save == "firewall/ipset.save"
|
||||
assert snap.ipset_sets == ["blocklist"]
|
||||
assert snap.iptables_v4_save == "firewall/iptables.v4"
|
||||
assert snap.iptables_v6_save is None
|
||||
|
||||
assert (
|
||||
(tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "ipset.save")
|
||||
.read_text(encoding="utf-8")
|
||||
.startswith("create blocklist")
|
||||
)
|
||||
assert (
|
||||
(tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v4")
|
||||
.read_text(encoding="utf-8")
|
||||
.startswith("*filter")
|
||||
)
|
||||
|
||||
|
||||
def test_collect_firewall_runtime_snapshot_is_per_family_fallback(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
calls = []
|
||||
outputs = {
|
||||
"ipset_save": (
|
||||
"create blocklist hash:ip family inet\nadd blocklist 203.0.113.10\n",
|
||||
None,
|
||||
),
|
||||
"iptables_v4_save": (
|
||||
"*filter\n:INPUT DROP [0:0]\n-A INPUT -p tcp --dport 22 -j ACCEPT\nCOMMIT\n",
|
||||
None,
|
||||
),
|
||||
"iptables_v6_save": (
|
||||
"*filter\n:INPUT DROP [0:0]\n-A INPUT -p tcp --dport 22 -j ACCEPT\nCOMMIT\n",
|
||||
None,
|
||||
),
|
||||
}
|
||||
|
||||
def fake_run(command_key, *, timeout=10):
|
||||
calls.append(command_key)
|
||||
return outputs[command_key]
|
||||
|
||||
monkeypatch.setattr(h, "_run_capture_command", fake_run)
|
||||
|
||||
snap = h._collect_firewall_runtime_snapshot(
|
||||
str(tmp_path),
|
||||
persistent_ipset_files=["/etc/ipset.conf"],
|
||||
persistent_iptables_v4_files=["/etc/iptables/rules.v4"],
|
||||
persistent_iptables_v6_files=[],
|
||||
)
|
||||
|
||||
assert "ipset_save" not in calls
|
||||
assert "iptables_v4_save" not in calls
|
||||
assert "iptables_v6_save" in calls
|
||||
assert snap.ipset_save is None
|
||||
assert snap.iptables_v4_save is None
|
||||
assert snap.iptables_v6_save == "firewall/iptables.v6"
|
||||
assert snap.packages == ["iptables"]
|
||||
assert any("persistent ipset configuration" in note for note in snap.notes)
|
||||
assert any("persistent IPv4 iptables configuration" in note for note in snap.notes)
|
||||
assert not (
|
||||
tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "ipset.save"
|
||||
).exists()
|
||||
assert not (
|
||||
tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v4"
|
||||
).exists()
|
||||
assert (
|
||||
tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v6"
|
||||
).exists()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from enroll.ignore import IgnorePolicy
|
||||
|
||||
|
||||
|
|
@ -8,3 +13,238 @@ def test_ignore_policy_denies_common_backup_files():
|
|||
assert pol.deny_reason("/etc/group-") == "backup_file"
|
||||
assert pol.deny_reason("/etc/something~") == "backup_file"
|
||||
assert pol.deny_reason("/foobar") == "unreadable"
|
||||
|
||||
|
||||
def test_deny_reason_dir_with_denied_path():
|
||||
pol = IgnorePolicy()
|
||||
assert pol.deny_reason_dir("/etc/ssl/private/key") == "denied_path"
|
||||
assert pol.deny_reason_dir("/etc/ssh/ssh_host_key") == "denied_path"
|
||||
assert pol.deny_reason_dir("/etc/ssh") is None
|
||||
|
||||
|
||||
def test_deny_reason_dir_unreadable(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
assert pol.deny_reason_dir(str(nonexistent)) == "unreadable"
|
||||
|
||||
|
||||
def test_deny_reason_dir_symlink(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
real_dir = tmp_path / "real"
|
||||
real_dir.mkdir()
|
||||
link = tmp_path / "link"
|
||||
os.symlink(str(real_dir), str(link))
|
||||
assert pol.deny_reason_dir(str(link)) == "symlink"
|
||||
|
||||
|
||||
def test_deny_reason_dir_not_directory(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
regular_file = tmp_path / "file.txt"
|
||||
regular_file.write_text("content", encoding="utf-8")
|
||||
assert pol.deny_reason_dir(str(regular_file)) == "not_directory"
|
||||
|
||||
|
||||
def test_deny_reason_dir_dangerous_mode(tmp_path: Path):
|
||||
pol = IgnorePolicy(dangerous=True)
|
||||
real_dir = tmp_path / "private"
|
||||
real_dir.mkdir()
|
||||
assert pol.deny_reason_dir(str(real_dir)) is None
|
||||
|
||||
|
||||
def test_deny_reason_link_basic(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
real_file = tmp_path / "real"
|
||||
real_file.write_text("content", encoding="utf-8")
|
||||
link = tmp_path / "link"
|
||||
os.symlink(str(real_file), str(link))
|
||||
assert pol.deny_reason_link(str(link)) is None
|
||||
|
||||
|
||||
def test_deny_reason_link_denied_path():
|
||||
pol = IgnorePolicy()
|
||||
assert pol.deny_reason_link("/etc/ssh/ssh_host_rsa_key") == "denied_path"
|
||||
|
||||
|
||||
def test_deny_reason_link_unreadable(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
# Create a symlink in a directory that doesn't exist
|
||||
# This simulates an unreadable path
|
||||
broken_link = tmp_path / "broken_link"
|
||||
os.symlink("/nonexistent/target", str(broken_link))
|
||||
# Broken symlinks are still readable (we can readlink them)
|
||||
# So they return None (allowed) unless they match deny globs
|
||||
result = pol.deny_reason_link(str(broken_link))
|
||||
# Broken symlinks are allowed - we can still read the link target
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_deny_reason_link_not_symlink(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
regular_file = tmp_path / "file.txt"
|
||||
regular_file.write_text("content", encoding="utf-8")
|
||||
assert pol.deny_reason_link(str(regular_file)) == "not_symlink"
|
||||
|
||||
|
||||
def test_deny_reason_link_log_file():
|
||||
pol = IgnorePolicy()
|
||||
assert pol.deny_reason_link("/var/log/something.log") == "log_file"
|
||||
|
||||
|
||||
def test_deny_reason_link_backup_file():
|
||||
pol = IgnorePolicy()
|
||||
assert pol.deny_reason_link("/etc/passwd-") == "backup_file"
|
||||
assert pol.deny_reason_link("/etc/something~") == "backup_file"
|
||||
|
||||
|
||||
def test_deny_reason_link_dangerous_mode(tmp_path: Path):
|
||||
pol = IgnorePolicy(dangerous=True)
|
||||
real_file = tmp_path / "real"
|
||||
real_file.write_text("content", encoding="utf-8")
|
||||
link = tmp_path / "link"
|
||||
os.symlink(str(real_file), str(link))
|
||||
assert pol.deny_reason_link(str(link)) is None
|
||||
|
||||
|
||||
def test_iter_effective_lines_with_comments():
|
||||
pol = IgnorePolicy()
|
||||
content = b"""
|
||||
# This is a comment
|
||||
; This is also a comment
|
||||
* continuation
|
||||
def main():
|
||||
pass
|
||||
"""
|
||||
lines = list(pol.iter_effective_lines(content))
|
||||
assert b"def main():" in lines
|
||||
assert b"# This is a comment" not in lines
|
||||
|
||||
|
||||
def test_iter_effective_lines_with_block_comments():
|
||||
pol = IgnorePolicy()
|
||||
content = b"""
|
||||
/* This is a block comment
|
||||
spanning multiple lines */
|
||||
int x = 5;
|
||||
"""
|
||||
lines = list(pol.iter_effective_lines(content))
|
||||
assert b"int x = 5;" in lines
|
||||
assert b"/*" not in lines
|
||||
|
||||
|
||||
def test_iter_effective_lines_empty():
|
||||
pol = IgnorePolicy()
|
||||
content = b""
|
||||
lines = list(pol.iter_effective_lines(content))
|
||||
assert lines == []
|
||||
|
||||
|
||||
def test_deny_reason_binary_not_allowed(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
binary = tmp_path / "random.bin"
|
||||
binary.write_bytes(b"\x00\x01\x02\x03")
|
||||
reason = pol.deny_reason(str(binary))
|
||||
assert reason == "binary_like"
|
||||
|
||||
|
||||
def test_deny_reason_sensitive_content(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
config = tmp_path / "config.txt"
|
||||
config.write_text("password=secret123", encoding="utf-8")
|
||||
reason = pol.deny_reason(str(config))
|
||||
assert reason == "sensitive_content"
|
||||
|
||||
|
||||
def test_deny_reason_sensitive_api_key(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
config = tmp_path / "config.txt"
|
||||
config.write_text("api_key=abc123", encoding="utf-8")
|
||||
reason = pol.deny_reason(str(config))
|
||||
assert reason == "sensitive_content"
|
||||
|
||||
|
||||
def test_deny_reason_private_key(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
key = tmp_path / "key.pem"
|
||||
key.write_text(
|
||||
"-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...", encoding="utf-8"
|
||||
)
|
||||
reason = pol.deny_reason(str(key))
|
||||
assert reason == "sensitive_content"
|
||||
|
||||
|
||||
def test_deny_reason_too_large(tmp_path: Path):
|
||||
pol = IgnorePolicy(max_file_bytes=100)
|
||||
large = tmp_path / "large.txt"
|
||||
large.write_bytes(b"x" * 200)
|
||||
reason = pol.deny_reason(str(large))
|
||||
assert reason == "too_large"
|
||||
|
||||
|
||||
def test_deny_reason_unreadable(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
nonexistent = tmp_path / "nonexistent"
|
||||
reason = pol.deny_reason(str(nonexistent))
|
||||
assert reason == "unreadable"
|
||||
|
||||
|
||||
def test_deny_reason_not_regular_file(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
directory = tmp_path / "dir"
|
||||
directory.mkdir()
|
||||
reason = pol.deny_reason(str(directory))
|
||||
assert reason == "not_regular_file"
|
||||
|
||||
|
||||
def test_deny_reason_symlink_file(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
real_file = tmp_path / "real"
|
||||
real_file.write_text("content", encoding="utf-8")
|
||||
link = tmp_path / "link"
|
||||
os.symlink(str(real_file), str(link))
|
||||
reason = pol.deny_reason(str(link))
|
||||
assert reason == "not_regular_file"
|
||||
|
||||
|
||||
def test_deny_reason_logs(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
log = tmp_path / "test.log"
|
||||
log.write_text("log content", encoding="utf-8")
|
||||
assert pol.deny_reason(str(log)) == "log_file"
|
||||
|
||||
|
||||
def test_deny_reason_backup_file(tmp_path: Path):
|
||||
pol = IgnorePolicy()
|
||||
backup = tmp_path / "file~"
|
||||
backup.write_text("backup", encoding="utf-8")
|
||||
assert pol.deny_reason(str(backup)) == "backup_file"
|
||||
|
||||
|
||||
def test_deny_reason_shadow_file():
|
||||
pol = IgnorePolicy()
|
||||
assert pol.deny_reason("/etc/shadow") == "denied_path"
|
||||
assert pol.deny_reason("/etc/gshadow") == "denied_path"
|
||||
|
||||
|
||||
def test_deny_reason_ssl_private():
|
||||
pol = IgnorePolicy()
|
||||
assert pol.deny_reason("/etc/ssl/private/key.pem") == "denied_path"
|
||||
|
||||
|
||||
def test_deny_reason_ssh_host_keys():
|
||||
pol = IgnorePolicy()
|
||||
assert pol.deny_reason("/etc/ssh/ssh_host_rsa_key") == "denied_path"
|
||||
assert pol.deny_reason("/etc/ssh/ssh_host_ed25519_key") == "denied_path"
|
||||
|
||||
|
||||
def test_deny_reason_letsencrypt():
|
||||
pol = IgnorePolicy()
|
||||
assert (
|
||||
pol.deny_reason("/etc/letsencrypt/live/example.com/fullchain.pem")
|
||||
== "denied_path"
|
||||
)
|
||||
|
||||
|
||||
def test_deny_reason_shadow_backup():
|
||||
pol = IgnorePolicy()
|
||||
assert pol.deny_reason("/etc/shadow-") == "backup_file"
|
||||
assert pol.deny_reason("/etc/passwd-") == "backup_file"
|
||||
|
|
|
|||
|
|
@ -131,3 +131,15 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
|
|||
encoding="utf-8"
|
||||
)
|
||||
assert "foo_key: 1" in defaults
|
||||
|
||||
|
||||
def test_openssh_paths_are_jinjaturtle_supported_and_forced_to_ssh() -> None:
|
||||
from enroll.jinjaturtle import can_jinjify_path, infer_other_formats
|
||||
|
||||
assert infer_other_formats("/etc/ssh/sshd_config") == "ssh"
|
||||
assert infer_other_formats("/etc/ssh/ssh_config") == "ssh"
|
||||
assert infer_other_formats("/etc/ssh/sshd_config.d/50-hardening.conf") == "ssh"
|
||||
assert infer_other_formats("/etc/ssh/ssh_config.d/99-proxy.conf") == "ssh"
|
||||
|
||||
assert can_jinjify_path("/etc/ssh/sshd_config")
|
||||
assert can_jinjify_path("/etc/ssh/ssh_config")
|
||||
|
|
|
|||
|
|
@ -795,3 +795,272 @@ def test_manifest_applies_jinjaturtle_to_jinjifyable_managed_file(
|
|||
assert not (
|
||||
out_dir / "roles" / "apt_config" / "files" / "etc" / "apt" / "foo.ini"
|
||||
).exists()
|
||||
|
||||
|
||||
def test_manifest_writes_firewall_runtime_role(tmp_path: Path):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "ansible"
|
||||
(bundle / "artifacts" / "firewall_runtime" / "firewall").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
(bundle / "artifacts" / "firewall_runtime" / "firewall" / "ipset.save").write_text(
|
||||
"create blocklist hash:ip family inet\nadd blocklist 203.0.113.10\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bundle / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v4").write_text(
|
||||
"*filter\n:INPUT DROP [0:0]\n-A INPUT -m set --match-set blocklist src -j DROP\nCOMMIT\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
|
||||
"inventory": {"packages": {}},
|
||||
"roles": {
|
||||
"users": {
|
||||
"role_name": "users",
|
||||
"users": [],
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"services": [],
|
||||
"packages": [],
|
||||
"apt_config": {
|
||||
"role_name": "apt_config",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"dnf_config": {
|
||||
"role_name": "dnf_config",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"firewall_runtime": {
|
||||
"role_name": "firewall_runtime",
|
||||
"packages": ["ipset", "iptables"],
|
||||
"ipset_save": "firewall/ipset.save",
|
||||
"ipset_sets": ["blocklist"],
|
||||
"iptables_v4_save": "firewall/iptables.v4",
|
||||
"iptables_v6_save": None,
|
||||
"notes": [],
|
||||
},
|
||||
"etc_custom": {
|
||||
"role_name": "etc_custom",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"usr_local_custom": {
|
||||
"role_name": "usr_local_custom",
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"extra_paths": {
|
||||
"role_name": "extra_paths",
|
||||
"include_patterns": [],
|
||||
"exclude_patterns": [],
|
||||
"managed_files": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
|
||||
manifest.manifest(str(bundle), str(out))
|
||||
|
||||
tasks = (out / "roles" / "firewall_runtime" / "tasks" / "main.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "ipset restore -exist" in tasks
|
||||
assert "iptables-restore /etc/enroll/firewall/iptables.v4" in tasks
|
||||
assert "ipset flush {{ item }}" in tasks
|
||||
|
||||
defaults = (out / "roles" / "firewall_runtime" / "defaults" / "main.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "firewall_runtime_ipset_sets:" in defaults
|
||||
assert "- blocklist" in defaults
|
||||
assert "firewall_runtime_restore_iptables: true" in defaults
|
||||
|
||||
pb = (out / "playbook.yml").read_text(encoding="utf-8")
|
||||
assert "role: firewall_runtime" in pb
|
||||
assert (
|
||||
out / "roles" / "firewall_runtime" / "files" / "firewall" / "ipset.save"
|
||||
).exists()
|
||||
|
||||
|
||||
def test_try_yaml_with_yaml_installed():
|
||||
result = manifest._try_yaml()
|
||||
# PyYAML should be installed for tests
|
||||
if result is None:
|
||||
pytest.skip("PyYAML not installed")
|
||||
assert hasattr(result, "safe_load")
|
||||
assert hasattr(result, "dump")
|
||||
|
||||
|
||||
def test_yaml_load_mapping_with_yaml(tmp_path: Path):
|
||||
text = """
|
||||
key1: value1
|
||||
key2:
|
||||
nested: value
|
||||
list:
|
||||
- item1
|
||||
- item2
|
||||
"""
|
||||
result = manifest._yaml_load_mapping(text)
|
||||
assert result["key1"] == "value1"
|
||||
assert result["key2"]["nested"] == "value"
|
||||
assert result["list"] == ["item1", "item2"]
|
||||
|
||||
|
||||
def test_yaml_load_mapping_empty():
|
||||
result = manifest._yaml_load_mapping("")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_yaml_load_mapping_invalid():
|
||||
result = manifest._yaml_load_mapping("invalid: yaml: :")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_yaml_load_mapping_not_dict():
|
||||
result = manifest._yaml_load_mapping("- item1\n- item2")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_yaml_load_mapping_none():
|
||||
result = manifest._yaml_load_mapping("~")
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_yaml_dump_mapping_with_yaml(tmp_path: Path):
|
||||
obj = {"key1": "value1", "key2": 123}
|
||||
result = manifest._yaml_dump_mapping(obj)
|
||||
assert "key1: value1" in result
|
||||
assert "key2:" in result
|
||||
|
||||
|
||||
def test_yaml_dump_mapping_empty():
|
||||
result = manifest._yaml_dump_mapping({})
|
||||
# Empty dict produces '{}'
|
||||
assert result.strip() == "{}"
|
||||
|
||||
|
||||
def test_yaml_dump_mapping_with_nested(tmp_path: Path):
|
||||
obj = {"key1": {"nested": "value"}}
|
||||
result = manifest._yaml_dump_mapping(obj)
|
||||
assert "nested:" in result
|
||||
|
||||
|
||||
def test_merge_mappings_overwrite_simple():
|
||||
existing = {"key1": "old", "key2": "keep"}
|
||||
incoming = {"key1": "new", "key3": "added"}
|
||||
result = manifest._merge_mappings_overwrite(existing, incoming)
|
||||
assert result["key1"] == "new"
|
||||
assert result["key2"] == "keep"
|
||||
assert result["key3"] == "added"
|
||||
|
||||
|
||||
def test_merge_mappings_overwrite_nested():
|
||||
existing = {"key1": {"a": 1}}
|
||||
incoming = {"key1": {"b": 2}}
|
||||
result = manifest._merge_mappings_overwrite(existing, incoming)
|
||||
# Nested dicts are replaced, not merged
|
||||
assert result["key1"] == {"b": 2}
|
||||
|
||||
|
||||
def test_merge_mappings_overwrite_empty():
|
||||
result = manifest._merge_mappings_overwrite({}, {"key": "value"})
|
||||
assert result == {"key": "value"}
|
||||
|
||||
result = manifest._merge_mappings_overwrite({"key": "value"}, {})
|
||||
assert result == {"key": "value"}
|
||||
|
||||
|
||||
def test_copy2_replace(tmp_path: Path):
|
||||
src = tmp_path / "src.txt"
|
||||
src.write_text("content", encoding="utf-8")
|
||||
dst = tmp_path / "dst" / "subdir" / "dst.txt"
|
||||
|
||||
manifest._copy2_replace(str(src), str(dst))
|
||||
|
||||
assert dst.exists()
|
||||
assert dst.read_text(encoding="utf-8") == "content"
|
||||
|
||||
|
||||
def test_copy2_replace_preserves_metadata(tmp_path: Path):
|
||||
src = tmp_path / "src.txt"
|
||||
src.write_text("content", encoding="utf-8")
|
||||
os.chmod(str(src), 0o644)
|
||||
dst = tmp_path / "dst.txt"
|
||||
|
||||
manifest._copy2_replace(str(src), str(dst))
|
||||
|
||||
assert dst.exists()
|
||||
st = dst.stat()
|
||||
assert stat.S_IMODE(st.st_mode) == 0o644
|
||||
|
||||
|
||||
def test_copy2_replace_atomic(tmp_path: Path):
|
||||
src = tmp_path / "src.txt"
|
||||
src.write_text("content", encoding="utf-8")
|
||||
dst = tmp_path / "dst.txt"
|
||||
|
||||
# Write initial content
|
||||
dst.write_text("old", encoding="utf-8")
|
||||
|
||||
manifest._copy2_replace(str(src), str(dst))
|
||||
|
||||
assert dst.read_text(encoding="utf-8") == "content"
|
||||
|
||||
|
||||
def test_render_firewall_runtime_tasks_empty():
|
||||
state = {"roles": {}}
|
||||
result = manifest._render_firewall_runtime_tasks(state)
|
||||
# Function always returns at least a basic playbook structure
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_render_firewall_runtime_tasks_with_iptables():
|
||||
state = {
|
||||
"roles": {
|
||||
"firewall_runtime": {
|
||||
"role_name": "firewall_runtime",
|
||||
"iptables_v4_save": "artifacts/firewall_runtime/iptables.save",
|
||||
}
|
||||
}
|
||||
}
|
||||
result = manifest._render_firewall_runtime_tasks(state)
|
||||
assert len(result) >= 1
|
||||
|
||||
|
||||
def test_render_firewall_runtime_tasks_with_ipset():
|
||||
state = {
|
||||
"roles": {
|
||||
"firewall_runtime": {
|
||||
"role_name": "firewall_runtime",
|
||||
"ipset_save": "artifacts/firewall_runtime/ipset.save",
|
||||
}
|
||||
}
|
||||
}
|
||||
result = manifest._render_firewall_runtime_tasks(state)
|
||||
assert len(result) >= 1
|
||||
|
||||
|
||||
def test_render_firewall_runtime_tasks_with_ipv6():
|
||||
state = {
|
||||
"roles": {
|
||||
"firewall_runtime": {
|
||||
"role_name": "firewall_runtime",
|
||||
"iptables_v6_save": "artifacts/firewall_runtime/ip6tables.save",
|
||||
}
|
||||
}
|
||||
}
|
||||
result = manifest._render_firewall_runtime_tasks(state)
|
||||
assert len(result) >= 1
|
||||
|
|
|
|||
|
|
@ -1,416 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from enroll.cache import _safe_component, new_harvest_cache_dir
|
||||
from enroll.ignore import IgnorePolicy
|
||||
from enroll.sopsutil import (
|
||||
SopsError,
|
||||
_pgp_arg,
|
||||
decrypt_file_binary_to,
|
||||
encrypt_file_binary,
|
||||
)
|
||||
|
||||
|
||||
def test_safe_component_sanitizes_and_bounds_length():
|
||||
assert _safe_component(" ") == "unknown"
|
||||
assert _safe_component("a/b c") == "a_b_c"
|
||||
assert _safe_component("x" * 200) == "x" * 64
|
||||
|
||||
|
||||
def test_new_harvest_cache_dir_uses_xdg_cache_home(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
||||
hc = new_harvest_cache_dir(hint="my host/01")
|
||||
assert hc.dir.exists()
|
||||
assert "my_host_01" in hc.dir.name
|
||||
assert str(hc.dir).startswith(str(tmp_path / "xdg"))
|
||||
# best-effort: ensure directory is not world-readable on typical FS
|
||||
try:
|
||||
mode = stat.S_IMODE(hc.dir.stat().st_mode)
|
||||
assert mode & 0o077 == 0
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def test_ignore_policy_denies_binary_and_sensitive_content(tmp_path: Path):
|
||||
p_bin = tmp_path / "binfile"
|
||||
p_bin.write_bytes(b"abc\x00def")
|
||||
assert IgnorePolicy().deny_reason(str(p_bin)) == "binary_like"
|
||||
|
||||
p_secret = tmp_path / "secret.conf"
|
||||
p_secret.write_text("password=foo\n", encoding="utf-8")
|
||||
assert IgnorePolicy().deny_reason(str(p_secret)) == "sensitive_content"
|
||||
|
||||
# dangerous mode disables heuristic scanning (but still checks file-ness/size)
|
||||
assert IgnorePolicy(dangerous=True).deny_reason(str(p_secret)) is None
|
||||
|
||||
|
||||
def test_ignore_policy_denies_usr_local_shadow_by_glob():
|
||||
# This should short-circuit before stat() (path doesn't need to exist).
|
||||
assert IgnorePolicy().deny_reason("/usr/local/etc/shadow") == "denied_path"
|
||||
|
||||
|
||||
def test_sops_pgp_arg_and_encrypt_decrypt_roundtrip(tmp_path: Path, monkeypatch):
|
||||
assert _pgp_arg([" ABC ", "DEF"]) == "ABC,DEF"
|
||||
with pytest.raises(SopsError):
|
||||
_pgp_arg([])
|
||||
|
||||
# Stub out sops and subprocess.
|
||||
import enroll.sopsutil as s
|
||||
|
||||
monkeypatch.setattr(s, "require_sops_cmd", lambda: "sops")
|
||||
|
||||
class R:
|
||||
def __init__(self, rc: int, out: bytes, err: bytes = b""):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = err
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
calls.append(cmd)
|
||||
# Return a deterministic payload so we can assert file writes.
|
||||
if "--encrypt" in cmd:
|
||||
return R(0, b"ENCRYPTED")
|
||||
if "--decrypt" in cmd:
|
||||
return R(0, b"PLAINTEXT")
|
||||
return R(1, b"", b"bad")
|
||||
|
||||
monkeypatch.setattr(s.subprocess, "run", fake_run)
|
||||
|
||||
src = tmp_path / "src.bin"
|
||||
src.write_bytes(b"x")
|
||||
enc = tmp_path / "out.sops"
|
||||
dec = tmp_path / "out.bin"
|
||||
|
||||
encrypt_file_binary(src, enc, pgp_fingerprints=["ABC"], mode=0o600)
|
||||
assert enc.read_bytes() == b"ENCRYPTED"
|
||||
|
||||
decrypt_file_binary_to(enc, dec, mode=0o644)
|
||||
assert dec.read_bytes() == b"PLAINTEXT"
|
||||
|
||||
# Sanity: we invoked encrypt and decrypt.
|
||||
assert any("--encrypt" in c for c in calls)
|
||||
assert any("--decrypt" in c for c in calls)
|
||||
|
||||
|
||||
def test_cache_dir_defaults_to_home_cache(monkeypatch, tmp_path: Path):
|
||||
# Ensure default path uses ~/.cache when XDG_CACHE_HOME is unset.
|
||||
from enroll.cache import enroll_cache_dir
|
||||
|
||||
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
p = enroll_cache_dir()
|
||||
assert str(p).startswith(str(tmp_path))
|
||||
assert p.name == "enroll"
|
||||
|
||||
|
||||
def test_harvest_cache_state_json_property(tmp_path: Path):
|
||||
from enroll.cache import HarvestCache
|
||||
|
||||
hc = HarvestCache(tmp_path / "h1")
|
||||
assert hc.state_json == hc.dir / "state.json"
|
||||
|
||||
|
||||
def test_cache_dir_security_rejects_symlink(tmp_path: Path):
|
||||
from enroll.cache import _ensure_dir_secure
|
||||
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Refusing to use symlink"):
|
||||
_ensure_dir_secure(link)
|
||||
|
||||
|
||||
def test_cache_dir_chmod_failures_are_ignored(monkeypatch, tmp_path: Path):
|
||||
from enroll import cache
|
||||
|
||||
# Make the cache base path deterministic and writable.
|
||||
monkeypatch.setattr(cache, "enroll_cache_dir", lambda: tmp_path)
|
||||
|
||||
# Force os.chmod to fail to cover the "except OSError: pass" paths.
|
||||
monkeypatch.setattr(
|
||||
os, "chmod", lambda *a, **k: (_ for _ in ()).throw(OSError("nope"))
|
||||
)
|
||||
|
||||
hc = cache.new_harvest_cache_dir()
|
||||
assert hc.dir.exists()
|
||||
assert hc.dir.is_dir()
|
||||
|
||||
|
||||
def test_stat_triplet_falls_back_to_numeric_ids(monkeypatch, tmp_path: Path):
|
||||
from enroll.fsutil import stat_triplet
|
||||
import pwd
|
||||
import grp
|
||||
|
||||
p = tmp_path / "x"
|
||||
p.write_text("x", encoding="utf-8")
|
||||
|
||||
# Force username/group resolution failures.
|
||||
monkeypatch.setattr(
|
||||
pwd, "getpwuid", lambda _uid: (_ for _ in ()).throw(KeyError("no user"))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
grp, "getgrgid", lambda _gid: (_ for _ in ()).throw(KeyError("no group"))
|
||||
)
|
||||
|
||||
owner, group, mode = stat_triplet(str(p))
|
||||
assert owner.isdigit()
|
||||
assert group.isdigit()
|
||||
assert len(mode) == 4
|
||||
|
||||
|
||||
def test_ignore_policy_iter_effective_lines_removes_block_comments():
|
||||
from enroll.ignore import IgnorePolicy
|
||||
|
||||
pol = IgnorePolicy()
|
||||
data = b"""keep1
|
||||
/*
|
||||
drop me
|
||||
*/
|
||||
keep2
|
||||
"""
|
||||
assert list(pol.iter_effective_lines(data)) == [b"keep1", b"keep2"]
|
||||
|
||||
|
||||
def test_ignore_policy_deny_reason_dir_variants(tmp_path: Path):
|
||||
from enroll.ignore import IgnorePolicy
|
||||
|
||||
pol = IgnorePolicy()
|
||||
|
||||
# denied by glob
|
||||
assert pol.deny_reason_dir("/etc/shadow") == "denied_path"
|
||||
|
||||
# symlink rejected
|
||||
d = tmp_path / "d"
|
||||
d.mkdir()
|
||||
link = tmp_path / "l"
|
||||
link.symlink_to(d, target_is_directory=True)
|
||||
assert pol.deny_reason_dir(str(link)) == "symlink"
|
||||
|
||||
# not a directory
|
||||
f = tmp_path / "f"
|
||||
f.write_text("x", encoding="utf-8")
|
||||
assert pol.deny_reason_dir(str(f)) == "not_directory"
|
||||
|
||||
# ok
|
||||
assert pol.deny_reason_dir(str(d)) is None
|
||||
|
||||
|
||||
def test_run_jinjaturtle_parses_outputs(monkeypatch, tmp_path: Path):
|
||||
# Fully unit-test enroll.jinjaturtle.run_jinjaturtle by stubbing subprocess.run.
|
||||
from enroll.jinjaturtle import run_jinjaturtle
|
||||
|
||||
def fake_run(cmd, **kwargs): # noqa: ARG001
|
||||
# cmd includes "-d <defaults> -t <template>"
|
||||
d_idx = cmd.index("-d") + 1
|
||||
t_idx = cmd.index("-t") + 1
|
||||
defaults = Path(cmd[d_idx])
|
||||
template = Path(cmd[t_idx])
|
||||
defaults.write_text("---\nfoo: 1\n", encoding="utf-8")
|
||||
template.write_text("value={{ foo }}\n", encoding="utf-8")
|
||||
return SimpleNamespace(returncode=0, stdout="ok", stderr="")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
src = tmp_path / "src.ini"
|
||||
src.write_text("foo=1\n", encoding="utf-8")
|
||||
|
||||
res = run_jinjaturtle("/bin/jinjaturtle", str(src), role_name="role1")
|
||||
assert "foo: 1" in res.vars_text
|
||||
assert "value=" in res.template_text
|
||||
|
||||
|
||||
def test_run_jinjaturtle_raises_on_failure(monkeypatch, tmp_path: Path):
|
||||
from enroll.jinjaturtle import run_jinjaturtle
|
||||
|
||||
def fake_run(cmd, **kwargs): # noqa: ARG001
|
||||
return SimpleNamespace(returncode=2, stdout="out", stderr="bad")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
src = tmp_path / "src.ini"
|
||||
src.write_text("x", encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="jinjaturtle failed"):
|
||||
run_jinjaturtle("/bin/jinjaturtle", str(src), role_name="role1")
|
||||
|
||||
|
||||
def test_require_sops_cmd_errors_when_missing(monkeypatch):
|
||||
from enroll.sopsutil import require_sops_cmd, SopsError
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.shutil.which", lambda _: None)
|
||||
with pytest.raises(SopsError, match="not found on PATH"):
|
||||
require_sops_cmd()
|
||||
|
||||
|
||||
def test_get_enroll_version_reports_unknown_on_metadata_failure(monkeypatch):
|
||||
import enroll.version as v
|
||||
|
||||
fake_meta = types.ModuleType("importlib.metadata")
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
fake_meta.packages_distributions = boom
|
||||
fake_meta.version = lambda _dist: boom()
|
||||
|
||||
monkeypatch.setitem(sys.modules, "importlib.metadata", fake_meta)
|
||||
assert v.get_enroll_version() == "unknown"
|
||||
|
||||
|
||||
def test_get_enroll_version_returns_unknown_if_importlib_metadata_unavailable(
|
||||
monkeypatch,
|
||||
):
|
||||
import builtins
|
||||
import enroll.version as v
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(
|
||||
name, globals=None, locals=None, fromlist=(), level=0
|
||||
): # noqa: A002
|
||||
if name == "importlib.metadata":
|
||||
raise ImportError("no metadata")
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
assert v.get_enroll_version() == "unknown"
|
||||
|
||||
|
||||
def test_compare_harvests_and_format_report(tmp_path: Path):
|
||||
from enroll.diff import compare_harvests, format_report
|
||||
|
||||
old = tmp_path / "old"
|
||||
new = tmp_path / "new"
|
||||
(old / "artifacts").mkdir(parents=True)
|
||||
(new / "artifacts").mkdir(parents=True)
|
||||
|
||||
def write_state(base: Path, state: dict) -> None:
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
(base / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
|
||||
# Old bundle: pkg a@1.0, pkg b@1.0, one service, one user, one managed file.
|
||||
old_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h1"},
|
||||
"inventory": {"packages": {"a": {"version": "1.0"}, "b": {"version": "1.0"}}},
|
||||
"roles": {
|
||||
"services": [
|
||||
{
|
||||
"unit": "svc.service",
|
||||
"role_name": "svc",
|
||||
"packages": ["a"],
|
||||
"active_state": "inactive",
|
||||
"sub_state": "dead",
|
||||
"unit_file_state": "enabled",
|
||||
"condition_result": None,
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/etc/foo.conf",
|
||||
"src_rel": "etc/foo.conf",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
"reason": "modified_conffile",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"packages": [],
|
||||
"users": {
|
||||
"role_name": "users",
|
||||
"users": [{"name": "alice", "shell": "/bin/sh"}],
|
||||
},
|
||||
"apt_config": {"role_name": "apt_config", "managed_files": []},
|
||||
"etc_custom": {"role_name": "etc_custom", "managed_files": []},
|
||||
"usr_local_custom": {"role_name": "usr_local_custom", "managed_files": []},
|
||||
"extra_paths": {"role_name": "extra_paths", "managed_files": []},
|
||||
},
|
||||
}
|
||||
(old / "artifacts" / "svc" / "etc").mkdir(parents=True, exist_ok=True)
|
||||
(old / "artifacts" / "svc" / "etc" / "foo.conf").write_text("old", encoding="utf-8")
|
||||
write_state(old, old_state)
|
||||
|
||||
# New bundle: pkg a@2.0, pkg c@1.0, service changed, user changed, file moved role+content.
|
||||
new_state = {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "h2"},
|
||||
"inventory": {"packages": {"a": {"version": "2.0"}, "c": {"version": "1.0"}}},
|
||||
"roles": {
|
||||
"services": [
|
||||
{
|
||||
"unit": "svc.service",
|
||||
"role_name": "svc",
|
||||
"packages": ["a", "c"],
|
||||
"active_state": "active",
|
||||
"sub_state": "running",
|
||||
"unit_file_state": "enabled",
|
||||
"condition_result": None,
|
||||
"managed_files": [],
|
||||
}
|
||||
],
|
||||
"packages": [],
|
||||
"users": {
|
||||
"role_name": "users",
|
||||
"users": [{"name": "alice", "shell": "/bin/bash"}, {"name": "bob"}],
|
||||
},
|
||||
"apt_config": {"role_name": "apt_config", "managed_files": []},
|
||||
"etc_custom": {"role_name": "etc_custom", "managed_files": []},
|
||||
"usr_local_custom": {"role_name": "usr_local_custom", "managed_files": []},
|
||||
"extra_paths": {
|
||||
"role_name": "extra_paths",
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/etc/foo.conf",
|
||||
"src_rel": "etc/foo.conf",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0600",
|
||||
"reason": "user_include",
|
||||
},
|
||||
{
|
||||
"path": "/etc/added.conf",
|
||||
"src_rel": "etc/added.conf",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
"reason": "user_include",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
(new / "artifacts" / "extra_paths" / "etc").mkdir(parents=True, exist_ok=True)
|
||||
(new / "artifacts" / "extra_paths" / "etc" / "foo.conf").write_text(
|
||||
"new", encoding="utf-8"
|
||||
)
|
||||
(new / "artifacts" / "extra_paths" / "etc" / "added.conf").write_text(
|
||||
"x", encoding="utf-8"
|
||||
)
|
||||
write_state(new, new_state)
|
||||
|
||||
report, changed = compare_harvests(str(old), str(new))
|
||||
assert changed is True
|
||||
|
||||
txt = format_report(report, fmt="text")
|
||||
assert "Packages" in txt
|
||||
|
||||
md = format_report(report, fmt="markdown")
|
||||
assert "# enroll diff report" in md
|
||||
|
||||
js = format_report(report, fmt="json")
|
||||
parsed = json.loads(js)
|
||||
assert parsed["packages"]["added"] == ["c"]
|
||||
|
|
@ -184,3 +184,157 @@ def test_expand_includes_respects_max_files(monkeypatch):
|
|||
paths, notes = pf.expand_includes(include, max_files=2)
|
||||
assert len(paths) == 2
|
||||
assert "/root/c" not in paths
|
||||
|
||||
|
||||
def test_has_glob_chars():
|
||||
assert pf._has_glob_chars("*.txt") is True
|
||||
assert pf._has_glob_chars("file?.log") is True
|
||||
assert pf._has_glob_chars("[abc]") is True
|
||||
assert pf._has_glob_chars("file.txt") is False
|
||||
assert pf._has_glob_chars("") is False
|
||||
|
||||
|
||||
def test_compile_path_pattern_regex_valid():
|
||||
result = pf.compile_path_pattern("re:^/home/.*$")
|
||||
assert result.kind == "regex"
|
||||
assert result.regex is not None
|
||||
assert result.regex.search("/home/user/file.txt") is not None
|
||||
assert result.regex.search("/var/file.txt") is None
|
||||
|
||||
|
||||
def test_compile_path_pattern_glob_forced():
|
||||
result = pf.compile_path_pattern("glob:/etc/*.conf")
|
||||
assert result.kind == "glob"
|
||||
assert result.value == "/etc/*.conf"
|
||||
|
||||
|
||||
def test_compile_path_pattern_glob_heuristic():
|
||||
result = pf.compile_path_pattern("/etc/*.conf")
|
||||
assert result.kind == "glob"
|
||||
|
||||
|
||||
def test_compile_path_pattern_prefix():
|
||||
result = pf.compile_path_pattern("/etc/nginx")
|
||||
assert result.kind == "prefix"
|
||||
assert result.value == "/etc/nginx"
|
||||
|
||||
|
||||
def test_compiled_pattern_matches_prefix():
|
||||
pat = pf.compile_path_pattern("/etc/nginx")
|
||||
assert pat.matches("/etc/nginx") is True
|
||||
assert pat.matches("/etc/nginx/conf.d") is True
|
||||
assert pat.matches("/etc/ssh") is False
|
||||
|
||||
|
||||
def test_compiled_pattern_matches_glob():
|
||||
pat = pf.compile_path_pattern("/etc/*.conf")
|
||||
assert pat.matches("/etc/ssh.conf") is True
|
||||
assert pat.matches("/etc/ssh/sshd.conf") is False
|
||||
|
||||
|
||||
def test_compiled_pattern_matches_regex():
|
||||
pat = pf.compile_path_pattern("re:^/home/[^/]+/.bashrc$")
|
||||
assert pat.matches("/home/alice/.bashrc") is True
|
||||
assert pat.matches("/home/bob/.bashrc") is True
|
||||
assert pat.matches("/home/alice/.profile") is False
|
||||
assert pat.matches("/var/.bashrc") is False
|
||||
|
||||
|
||||
def test_path_filter_is_excluded():
|
||||
pf_filter = pf.PathFilter(exclude=["/tmp/*", "/var/log"])
|
||||
assert pf_filter.is_excluded("/tmp/file.txt") is True
|
||||
assert pf_filter.is_excluded("/var/log/syslog") is True
|
||||
assert pf_filter.is_excluded("/etc/ssh") is False
|
||||
|
||||
|
||||
def test_path_filter_empty():
|
||||
pf_filter = pf.PathFilter()
|
||||
assert pf_filter.is_excluded("/anything") is False
|
||||
assert pf_filter.iter_include_patterns() == []
|
||||
|
||||
|
||||
def test_expand_includes_prefix_existing(tmp_path: Path):
|
||||
etc_dir = tmp_path / "etc"
|
||||
etc_dir.mkdir()
|
||||
(etc_dir / "file1.txt").write_text("a")
|
||||
(etc_dir / "file2.txt").write_text("b")
|
||||
|
||||
patterns = [pf.compile_path_pattern(str(etc_dir))]
|
||||
paths, notes = pf.expand_includes(patterns, max_files=10)
|
||||
|
||||
assert len(paths) == 2
|
||||
assert notes == []
|
||||
|
||||
|
||||
def test_expand_includes_prefix_nonexistent():
|
||||
patterns = [pf.compile_path_pattern("/nonexistent/path")]
|
||||
paths, notes = pf.expand_includes(patterns, max_files=10)
|
||||
|
||||
assert paths == []
|
||||
assert len(notes) == 1
|
||||
assert "matched no files" in notes[0]
|
||||
|
||||
|
||||
def test_expand_includes_glob_no_matches():
|
||||
patterns = [pf.compile_path_pattern("/nonexistent/*.txt")]
|
||||
paths, notes = pf.expand_includes(patterns, max_files=10)
|
||||
|
||||
assert paths == []
|
||||
assert len(notes) == 1
|
||||
|
||||
|
||||
def test_expand_includes_skips_symlinks(tmp_path: Path):
|
||||
real_file = tmp_path / "real.txt"
|
||||
real_file.write_text("x")
|
||||
link = tmp_path / "link.txt"
|
||||
os.symlink(str(real_file), str(link))
|
||||
|
||||
patterns = [pf.compile_path_pattern(str(tmp_path))]
|
||||
paths, notes = pf.expand_includes(patterns, max_files=10)
|
||||
|
||||
assert len(paths) == 1
|
||||
assert paths[0].endswith("real.txt")
|
||||
|
||||
|
||||
def test_expand_includes_excludes_pattern(tmp_path: Path):
|
||||
etc_dir = tmp_path / "etc"
|
||||
etc_dir.mkdir()
|
||||
(etc_dir / "include.txt").write_text("a")
|
||||
(etc_dir / "exclude.txt").write_text("b")
|
||||
|
||||
patterns = [pf.compile_path_pattern(str(etc_dir))]
|
||||
exclude = pf.PathFilter(exclude=["*exclude*"])
|
||||
paths, notes = pf.expand_includes(patterns, exclude=exclude, max_files=10)
|
||||
|
||||
assert len(paths) == 1
|
||||
assert paths[0].endswith("include.txt")
|
||||
|
||||
|
||||
def test_expand_includes_skips_directories(tmp_path: Path):
|
||||
subdir = tmp_path / "subdir"
|
||||
subdir.mkdir()
|
||||
(tmp_path / "file.txt").write_text("x")
|
||||
|
||||
patterns = [pf.compile_path_pattern(str(subdir))]
|
||||
paths, notes = pf.expand_includes(patterns, max_files=10)
|
||||
|
||||
assert paths == []
|
||||
|
||||
|
||||
def test_regex_literal_prefix_simple():
|
||||
assert pf._regex_literal_prefix("/etc/nginx/") == "/etc/nginx/"
|
||||
|
||||
|
||||
def test_regex_literal_prefix_with_anchor():
|
||||
assert pf._regex_literal_prefix("^/etc/nginx/") == "/etc/nginx/"
|
||||
|
||||
|
||||
def test_regex_literal_prefix_with_regex_chars():
|
||||
assert pf._regex_literal_prefix("^/etc/.*\\.conf$") == "/etc/"
|
||||
|
||||
|
||||
def test_path_filter_with_include_patterns():
|
||||
pf_filter = pf.PathFilter(include=["/etc/*.conf"], exclude=["/etc/secret.conf"])
|
||||
patterns = pf_filter.iter_include_patterns()
|
||||
assert len(patterns) == 1
|
||||
assert patterns[0].kind == "glob"
|
||||
|
|
|
|||
|
|
@ -91,3 +91,176 @@ def test_specific_paths_for_hints_differs_between_backends():
|
|||
paths = set(r.specific_paths_for_hints({"nginx"}))
|
||||
assert "/etc/sysconfig/nginx" in paths
|
||||
assert "/etc/sysconfig/nginx.conf" in paths
|
||||
|
||||
|
||||
def test_read_os_release_file_not_found(tmp_path: Path):
|
||||
result = platform._read_os_release(str(tmp_path / "nonexistent"))
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_read_os_release_handles_invalid_line(tmp_path: Path):
|
||||
p = tmp_path / "os-release"
|
||||
p.write_text(
|
||||
"ID=ubuntu\n" "NO_EQUALS_SIGN\n" 'VERSION="22.04"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = platform._read_os_release(str(p))
|
||||
assert result["ID"] == "ubuntu"
|
||||
assert result["VERSION"] == "22.04"
|
||||
assert "NO_EQUALS_SIGN" not in result
|
||||
|
||||
|
||||
def test_detect_platform_debian(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "debian", "VERSION_ID": "11"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "debian"
|
||||
assert result.pkg_backend == "dpkg"
|
||||
|
||||
|
||||
def test_detect_platform_ubuntu(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "ubuntu", "VERSION_ID": "22.04"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "debian"
|
||||
assert result.pkg_backend == "dpkg"
|
||||
|
||||
|
||||
def test_detect_platform_fedora(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "fedora", "VERSION_ID": "38"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "redhat"
|
||||
assert result.pkg_backend == "rpm"
|
||||
|
||||
|
||||
def test_detect_platform_rocky(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "rocky", "VERSION_ID": "9"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "redhat"
|
||||
assert result.pkg_backend == "rpm"
|
||||
|
||||
|
||||
def test_detect_platform_unknown_fallback_to_dpkg(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "unknown"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
monkeypatch.setattr(platform.shutil, "which", lambda x: x == "dpkg")
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "debian"
|
||||
assert result.pkg_backend == "dpkg"
|
||||
|
||||
|
||||
def test_detect_platform_unknown_fallback_to_rpm(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "unknown"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
monkeypatch.setattr(platform.shutil, "which", lambda x: x == "rpm")
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "redhat"
|
||||
assert result.pkg_backend == "rpm"
|
||||
|
||||
|
||||
def test_detect_platform_completely_unknown(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "unknown"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
monkeypatch.setattr(platform.shutil, "which", lambda x: False)
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "unknown"
|
||||
assert result.pkg_backend == "unknown"
|
||||
|
||||
|
||||
def test_detect_platform_debian_like(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "linuxmint", "ID_LIKE": "debian"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "debian"
|
||||
assert result.pkg_backend == "dpkg"
|
||||
|
||||
|
||||
def test_detect_platform_rhel_like(monkeypatch):
|
||||
def fake_read_os_release(path: str = "/etc/os-release") -> dict:
|
||||
return {"ID": "centos", "ID_LIKE": "rhel fedora"}
|
||||
|
||||
monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
|
||||
result = platform.detect_platform()
|
||||
assert result.os_family == "redhat"
|
||||
assert result.pkg_backend == "rpm"
|
||||
|
||||
|
||||
def test_get_backend_returns_dpkg(monkeypatch):
|
||||
info = platform.PlatformInfo(os_family="debian", pkg_backend="dpkg", os_release={})
|
||||
backend = platform.get_backend(info)
|
||||
assert isinstance(backend, platform.DpkgBackend)
|
||||
assert backend.name == "dpkg"
|
||||
|
||||
|
||||
def test_get_backend_returns_rpm(monkeypatch):
|
||||
info = platform.PlatformInfo(os_family="redhat", pkg_backend="rpm", os_release={})
|
||||
backend = platform.get_backend(info)
|
||||
assert isinstance(backend, platform.RpmBackend)
|
||||
assert backend.name == "rpm"
|
||||
|
||||
|
||||
def test_get_backend_unknown_with_rpm(monkeypatch):
|
||||
info = platform.PlatformInfo(
|
||||
os_family="unknown", pkg_backend="unknown", os_release={}
|
||||
)
|
||||
monkeypatch.setattr(platform.shutil, "which", lambda x: x == "rpm")
|
||||
backend = platform.get_backend(info)
|
||||
assert isinstance(backend, platform.RpmBackend)
|
||||
|
||||
|
||||
def test_get_backend_unknown_with_dpkg(monkeypatch):
|
||||
info = platform.PlatformInfo(
|
||||
os_family="unknown", pkg_backend="unknown", os_release={}
|
||||
)
|
||||
monkeypatch.setattr(platform.shutil, "which", lambda x: x == "dpkg")
|
||||
backend = platform.get_backend(info)
|
||||
assert isinstance(backend, platform.DpkgBackend)
|
||||
|
||||
|
||||
def test_dpkg_backend_specific_paths():
|
||||
backend = platform.DpkgBackend()
|
||||
paths = backend.specific_paths_for_hints({"nginx"})
|
||||
assert "/etc/default/nginx" in paths
|
||||
assert "/etc/init.d/nginx" in paths
|
||||
assert "/etc/sysctl.d/nginx.conf" in paths
|
||||
|
||||
|
||||
def test_rpm_backend_specific_paths():
|
||||
backend = platform.RpmBackend()
|
||||
paths = backend.specific_paths_for_hints({"nginx"})
|
||||
assert "/etc/sysconfig/nginx" in paths
|
||||
assert "/etc/sysconfig/nginx.conf" in paths
|
||||
assert "/etc/sysctl.d/nginx.conf" in paths
|
||||
|
||||
|
||||
def test_is_pkg_config_path_dpkg():
|
||||
backend = platform.DpkgBackend()
|
||||
assert backend.is_pkg_config_path("/etc/apt/sources.list") is True
|
||||
assert backend.is_pkg_config_path("/etc/apt/trusted.gpg") is True
|
||||
assert backend.is_pkg_config_path("/etc/ssh/sshd_config") is False
|
||||
|
||||
|
||||
def test_is_pkg_config_path_rpm():
|
||||
backend = platform.RpmBackend()
|
||||
assert backend.is_pkg_config_path("/etc/dnf/dnf.conf") is True
|
||||
assert backend.is_pkg_config_path("/etc/yum.conf") is True
|
||||
assert backend.is_pkg_config_path("/etc/yum.repos.d/custom.repo") is True
|
||||
assert backend.is_pkg_config_path("/etc/ssh/sshd_config") is False
|
||||
|
|
|
|||
|
|
@ -565,3 +565,452 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
|
|||
|
||||
# Ensure the password was written to stdin for the -S invocation.
|
||||
assert stdin_by_cmd.get(sudo_s[0]) == ["s3cr3t\n"]
|
||||
|
||||
|
||||
def test_sudo_password_required_detection():
|
||||
from enroll.remote import _sudo_password_required
|
||||
|
||||
assert _sudo_password_required("", "a password is required") is True
|
||||
assert _sudo_password_required("", "password is required") is True
|
||||
assert (
|
||||
_sudo_password_required("", "a terminal is required to read the password")
|
||||
is True
|
||||
)
|
||||
assert (
|
||||
_sudo_password_required("", "no tty present and no askpass program specified")
|
||||
is True
|
||||
)
|
||||
assert _sudo_password_required("", "must have a tty to run sudo") is True
|
||||
assert _sudo_password_required("", "sudo: sorry, you must have a tty") is True
|
||||
assert _sudo_password_required("", "askpass") is True
|
||||
assert _sudo_password_required("success", "") is False
|
||||
|
||||
|
||||
def test_sudo_not_permitted_detection():
|
||||
from enroll.remote import _sudo_not_permitted
|
||||
|
||||
assert _sudo_not_permitted("", "user is not in the sudoers file") is True
|
||||
assert _sudo_not_permitted("", "not allowed to execute") is True
|
||||
assert _sudo_not_permitted("", "may not run sudo") is True
|
||||
assert _sudo_not_permitted("", "sorry, user") is True
|
||||
assert _sudo_not_permitted("success", "") is False
|
||||
|
||||
|
||||
def test_sudo_tty_required_detection():
|
||||
from enroll.remote import _sudo_tty_required
|
||||
|
||||
assert _sudo_tty_required("", "must have a tty") is True
|
||||
assert _sudo_tty_required("", "sorry, you must have a tty") is True
|
||||
assert _sudo_tty_required("", "sudo: sorry, you must have a tty") is True
|
||||
assert _sudo_tty_required("", "must have a tty to run sudo") is True
|
||||
assert _sudo_tty_required("success", "") is False
|
||||
|
||||
|
||||
def test_resolve_become_password_prompts_when_asked(monkeypatch):
|
||||
from enroll.remote import _resolve_become_password
|
||||
|
||||
prompted = []
|
||||
|
||||
def fake_getpass(prompt):
|
||||
prompted.append(prompt)
|
||||
return "secret"
|
||||
|
||||
result = _resolve_become_password(
|
||||
True, prompt="sudo password: ", getpass_fn=fake_getpass
|
||||
)
|
||||
assert result == "secret"
|
||||
assert len(prompted) == 1
|
||||
|
||||
|
||||
def test_resolve_become_password_returns_none_when_not_asked():
|
||||
from enroll.remote import _resolve_become_password
|
||||
|
||||
result = _resolve_become_password(False)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_ssh_key_passphrase_from_env(monkeypatch):
|
||||
from enroll.remote import _resolve_ssh_key_passphrase
|
||||
|
||||
monkeypatch.setenv("SSH_KEY_PASS", "env_secret")
|
||||
|
||||
result = _resolve_ssh_key_passphrase(False, env_var="SSH_KEY_PASS")
|
||||
assert result == "env_secret"
|
||||
|
||||
|
||||
def test_resolve_ssh_key_passphrase_raises_when_env_not_set(monkeypatch):
|
||||
from enroll.remote import _resolve_ssh_key_passphrase
|
||||
|
||||
monkeypatch.delenv("SSH_KEY_PASS", raising=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="SSH key passphrase environment variable"):
|
||||
_resolve_ssh_key_passphrase(False, env_var="SSH_KEY_PASS")
|
||||
|
||||
|
||||
def test_resolve_ssh_key_passphrase_prompts_when_asked(monkeypatch):
|
||||
from enroll.remote import _resolve_ssh_key_passphrase
|
||||
|
||||
prompted = []
|
||||
|
||||
def fake_getpass(prompt):
|
||||
prompted.append(prompt)
|
||||
return "prompt_secret"
|
||||
|
||||
result = _resolve_ssh_key_passphrase(
|
||||
True, prompt="SSH key passphrase: ", getpass_fn=fake_getpass
|
||||
)
|
||||
assert result == "prompt_secret"
|
||||
assert len(prompted) == 1
|
||||
|
||||
|
||||
def test_resolve_ssh_key_passphrase_returns_none_when_not_asked():
|
||||
from enroll.remote import _resolve_ssh_key_passphrase
|
||||
|
||||
result = _resolve_ssh_key_passphrase(False, env_var=None)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_safe_extract_tar_rejects_absolute_paths(tmp_path: Path):
|
||||
from enroll.remote import _safe_extract_tar
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
bio = io.BytesIO()
|
||||
with tarfile.open(fileobj=bio, mode="w:gz") as tf:
|
||||
ti = tarfile.TarInfo(name="/etc/passwd")
|
||||
ti.size = 1
|
||||
tf.addfile(ti, io.BytesIO(b"x"))
|
||||
|
||||
bio.seek(0)
|
||||
with tarfile.open(fileobj=bio, mode="r:gz") as tf:
|
||||
with pytest.raises(RuntimeError, match="Unsafe tar member path"):
|
||||
_safe_extract_tar(tf, tmp_path)
|
||||
|
||||
|
||||
def test_safe_extract_tar_rejects_hardlinks(tmp_path: Path):
|
||||
from enroll.remote import _safe_extract_tar
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
bio = io.BytesIO()
|
||||
with tarfile.open(fileobj=bio, mode="w:gz") as tf:
|
||||
ti = tarfile.TarInfo(name="hardlink")
|
||||
ti.type = tarfile.LNKTYPE
|
||||
ti.linkname = "/etc/passwd"
|
||||
tf.addfile(ti)
|
||||
|
||||
bio.seek(0)
|
||||
with tarfile.open(fileobj=bio, mode="r:gz") as tf:
|
||||
with pytest.raises(RuntimeError, match="Refusing to extract"):
|
||||
_safe_extract_tar(tf, tmp_path)
|
||||
|
||||
|
||||
def test_safe_extract_tar_rejects_device_nodes(tmp_path: Path):
|
||||
from enroll.remote import _safe_extract_tar
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
bio = io.BytesIO()
|
||||
with tarfile.open(fileobj=bio, mode="w:gz") as tf:
|
||||
ti = tarfile.TarInfo(name="device")
|
||||
ti.type = tarfile.CHRTYPE
|
||||
tf.addfile(ti)
|
||||
|
||||
bio.seek(0)
|
||||
with tarfile.open(fileobj=bio, mode="r:gz") as tf:
|
||||
with pytest.raises(RuntimeError, match="Refusing to extract"):
|
||||
_safe_extract_tar(tf, tmp_path)
|
||||
|
||||
|
||||
def test_safe_extract_tar_accepts_dot_entry(tmp_path: Path):
|
||||
from enroll.remote import _safe_extract_tar
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
bio = io.BytesIO()
|
||||
with tarfile.open(fileobj=bio, mode="w:gz") as tf:
|
||||
ti = tarfile.TarInfo(name=".")
|
||||
ti.size = 0
|
||||
tf.addfile(ti, io.BytesIO(b""))
|
||||
|
||||
bio.seek(0)
|
||||
with tarfile.open(fileobj=bio, mode="r:gz") as tf:
|
||||
_safe_extract_tar(tf, tmp_path)
|
||||
|
||||
|
||||
def test_safe_extract_tar_accepts_valid_files(tmp_path: Path):
|
||||
from enroll.remote import _safe_extract_tar
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
bio = io.BytesIO()
|
||||
with tarfile.open(fileobj=bio, mode="w:gz") as tf:
|
||||
ti = tarfile.TarInfo(name="foo/bar.txt")
|
||||
ti.size = 5
|
||||
tf.addfile(ti, io.BytesIO(b"hello"))
|
||||
|
||||
bio.seek(0)
|
||||
with tarfile.open(fileobj=bio, mode="r:gz") as tf:
|
||||
_safe_extract_tar(tf, tmp_path)
|
||||
|
||||
assert (tmp_path / "foo" / "bar.txt").read_bytes() == b"hello"
|
||||
|
||||
|
||||
def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path):
|
||||
import sys
|
||||
|
||||
import enroll.remote as r
|
||||
|
||||
monkeypatch.setattr(
|
||||
r,
|
||||
"_build_enroll_pyz",
|
||||
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
|
||||
or (Path(td) / "enroll.pyz"),
|
||||
)
|
||||
|
||||
tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
|
||||
|
||||
class _Chan:
|
||||
def __init__(self, out: bytes = b"", err: bytes = b"", rc: int = 0):
|
||||
self._out = out
|
||||
self._err = err
|
||||
self._out_i = 0
|
||||
self._err_i = 0
|
||||
self._rc = rc
|
||||
self._closed = False
|
||||
|
||||
def recv_ready(self) -> bool:
|
||||
return (not self._closed) and self._out_i < len(self._out)
|
||||
|
||||
def recv(self, n: int) -> bytes:
|
||||
if self._closed:
|
||||
return b""
|
||||
chunk = self._out[self._out_i : self._out_i + n]
|
||||
self._out_i += len(chunk)
|
||||
return chunk
|
||||
|
||||
def recv_stderr_ready(self) -> bool:
|
||||
return (not self._closed) and self._err_i < len(self._err)
|
||||
|
||||
def recv_stderr(self, n: int) -> bytes:
|
||||
if self._closed:
|
||||
return b""
|
||||
chunk = self._err[self._err_i : self._err_i + n]
|
||||
self._err_i += len(chunk)
|
||||
return chunk
|
||||
|
||||
def exit_status_ready(self) -> bool:
|
||||
return self._closed or (
|
||||
self._out_i >= len(self._out) and self._err_i >= len(self._err)
|
||||
)
|
||||
|
||||
def recv_exit_status(self) -> int:
|
||||
return self._rc
|
||||
|
||||
def shutdown_write(self) -> None:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
class _Stdout:
|
||||
def __init__(self, payload: bytes = b"", rc: int = 0, err: bytes = b""):
|
||||
self._bio = io.BytesIO(payload)
|
||||
self.channel = _Chan(out=payload, err=err, rc=rc)
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return self._bio.read(n)
|
||||
|
||||
class _Stderr:
|
||||
def __init__(self, payload: bytes = b""):
|
||||
self._bio = io.BytesIO(payload)
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return self._bio.read(n)
|
||||
|
||||
class _Stdin:
|
||||
def __init__(self, cmd: str):
|
||||
self._cmd = cmd
|
||||
|
||||
def write(self, s: str) -> None:
|
||||
pass
|
||||
|
||||
def flush(self) -> None:
|
||||
return
|
||||
|
||||
class _SFTP:
|
||||
def put(self, _local: str, _remote: str) -> None:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
return
|
||||
|
||||
class FakeSSH:
|
||||
def __init__(self):
|
||||
self._sftp = _SFTP()
|
||||
|
||||
def load_system_host_keys(self):
|
||||
return
|
||||
|
||||
def set_missing_host_key_policy(self, _policy):
|
||||
return
|
||||
|
||||
def connect(self, **_kwargs):
|
||||
return
|
||||
|
||||
def open_sftp(self):
|
||||
return self._sftp
|
||||
|
||||
def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
|
||||
if cmd.startswith("tar -cz -C"):
|
||||
return (_Stdin(cmd), _Stdout(tgz, rc=0), _Stderr(b""))
|
||||
if cmd == "mktemp -d":
|
||||
return (_Stdin(cmd), _Stdout(b"/tmp/enroll-remote-789\n"), _Stderr())
|
||||
if cmd.startswith("chmod 700"):
|
||||
return (_Stdin(cmd), _Stdout(b""), _Stderr())
|
||||
if " harvest " in cmd:
|
||||
return (_Stdin(cmd), _Stdout(b""), _Stderr())
|
||||
if cmd.startswith("rm -rf"):
|
||||
return (_Stdin(cmd), _Stdout(b""), _Stderr())
|
||||
return (_Stdin(cmd), _Stdout(b""), _Stderr())
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
RejectPolicy4 = type("RejectPolicy", (), {})
|
||||
|
||||
class FakeParamiko:
|
||||
SSHClient = FakeSSH
|
||||
RejectPolicy = RejectPolicy4 # type: ignore
|
||||
PasswordRequiredException = Exception # type: ignore
|
||||
|
||||
monkeypatch.setitem(sys.modules, "paramiko", FakeParamiko)
|
||||
|
||||
prompts = []
|
||||
|
||||
def fake_getpass(prompt):
|
||||
prompts.append(prompt)
|
||||
return "passphrase"
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
state_path = r.remote_harvest(
|
||||
ask_key_passphrase=True,
|
||||
getpass_fn=fake_getpass,
|
||||
local_out_dir=out_dir,
|
||||
remote_host="example.com",
|
||||
remote_user="alice",
|
||||
no_sudo=True,
|
||||
)
|
||||
|
||||
assert state_path.exists()
|
||||
assert len(prompts) == 1
|
||||
|
||||
|
||||
def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
import sys
|
||||
|
||||
import enroll.remote as r
|
||||
|
||||
monkeypatch.setattr(
|
||||
r,
|
||||
"_build_enroll_pyz",
|
||||
lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
|
||||
or (Path(td) / "enroll.pyz"),
|
||||
)
|
||||
|
||||
class _Chan:
|
||||
def __init__(self):
|
||||
self._closed = False
|
||||
|
||||
def recv_ready(self) -> bool:
|
||||
return False
|
||||
|
||||
def recv(self, n: int) -> bytes:
|
||||
return b""
|
||||
|
||||
def recv_stderr_ready(self) -> bool:
|
||||
return False
|
||||
|
||||
def recv_stderr(self, n: int) -> bytes:
|
||||
return b""
|
||||
|
||||
def exit_status_ready(self) -> bool:
|
||||
return True
|
||||
|
||||
def recv_exit_status(self) -> int:
|
||||
return 0
|
||||
|
||||
def shutdown_write(self) -> None:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
class _Stdout:
|
||||
def __init__(self):
|
||||
self.channel = _Chan()
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return b""
|
||||
|
||||
class _Stderr:
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return b""
|
||||
|
||||
class _SFTP:
|
||||
def put(self, _local: str, _remote: str) -> None:
|
||||
return
|
||||
|
||||
def close(self) -> None:
|
||||
return
|
||||
|
||||
class FakeSSH:
|
||||
def __init__(self):
|
||||
self._sftp = _SFTP()
|
||||
|
||||
def load_system_host_keys(self):
|
||||
return
|
||||
|
||||
def set_missing_host_key_policy(self, _policy):
|
||||
return
|
||||
|
||||
def connect(self, **_kwargs):
|
||||
raise Exception("PasswordRequired")
|
||||
|
||||
def open_sftp(self):
|
||||
return self._sftp
|
||||
|
||||
def exec_command(self, cmd: str, **_kwargs):
|
||||
return (_Stdout(), _Stdout(), _Stderr())
|
||||
|
||||
def close(self):
|
||||
return
|
||||
|
||||
class RejectPolicy:
|
||||
pass
|
||||
|
||||
RejectPolicy3 = RejectPolicy
|
||||
|
||||
class FakeParamiko:
|
||||
SSHClient = FakeSSH
|
||||
RejectPolicy = RejectPolicy3 # type: ignore
|
||||
PasswordRequiredException = Exception # type: ignore
|
||||
|
||||
monkeypatch.setitem(sys.modules, "paramiko", FakeParamiko)
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
|
||||
with pytest.raises(RuntimeError, match="SSH private key is encrypted"):
|
||||
r.remote_harvest(
|
||||
ask_key_passphrase=False,
|
||||
local_out_dir=out_dir,
|
||||
remote_host="example.com",
|
||||
stdin=io.StringIO(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
import enroll.rpm as rpm
|
||||
|
||||
|
|
@ -176,3 +177,33 @@ def test_rpm_owner_strips_epoch_prefix_when_present(monkeypatch):
|
|||
lambda cmd, allow_fail=False, merge_err=False: (0, "1:bash\n"),
|
||||
)
|
||||
assert rpm.rpm_owner("/bin/bash") == "bash"
|
||||
|
||||
|
||||
def test_strip_arch_no_suffix():
|
||||
assert rpm._strip_arch("vim") == "vim"
|
||||
assert rpm._strip_arch("nginx ") == "nginx"
|
||||
|
||||
|
||||
def test_strip_arch_with_unknown_suffix():
|
||||
assert rpm._strip_arch("package.unknown") == "package.unknown"
|
||||
|
||||
|
||||
def test_run_command_raises_on_fail():
|
||||
with pytest.raises(RuntimeError):
|
||||
rpm._run(["sh", "-c", "echo stderr >&2; exit 1"], allow_fail=False)
|
||||
|
||||
|
||||
def test_rpm_owner_empty_path():
|
||||
assert rpm.rpm_owner("") is None
|
||||
|
||||
|
||||
def test_rpm_modified_files_empty(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rpm, "_run", lambda cmd, allow_fail=False, merge_err=False: (0, "")
|
||||
)
|
||||
assert rpm.rpm_modified_files("vim") == set()
|
||||
|
||||
|
||||
def test_list_manual_packages_no_commands_available(monkeypatch):
|
||||
monkeypatch.setattr(rpm.shutil, "which", lambda exe: None)
|
||||
assert rpm.list_manual_packages() == []
|
||||
|
|
|
|||
234
tests/test_sopsutil.py
Normal file
234
tests/test_sopsutil.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
from enroll.sopsutil import SopsError, _pgp_arg, find_sops_cmd, require_sops_cmd
|
||||
|
||||
|
||||
def test_find_sops_cmd():
|
||||
result = find_sops_cmd()
|
||||
if result is None:
|
||||
pytest.skip("sops not installed")
|
||||
assert result.endswith("sops")
|
||||
|
||||
|
||||
def test_require_sops_cmd():
|
||||
exe = require_sops_cmd()
|
||||
assert exe is not None
|
||||
assert "sops" in exe
|
||||
|
||||
|
||||
def test_require_sops_cmd_raises_when_not_found(monkeypatch):
|
||||
import enroll.sopsutil as s
|
||||
|
||||
def fake_find():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(s, "find_sops_cmd", fake_find)
|
||||
|
||||
with pytest.raises(SopsError) as exc_info:
|
||||
require_sops_cmd()
|
||||
|
||||
assert "sops" in str(exc_info.value).lower()
|
||||
assert "not found" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_pgp_arg_with_empty_fingerprints():
|
||||
with pytest.raises(SopsError) as exc_info:
|
||||
_pgp_arg([])
|
||||
assert "No GPG fingerprints" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_pgp_arg_with_whitespace_fingerprints():
|
||||
result = _pgp_arg([" ", "ABC123", " DEF456 "])
|
||||
assert result == "ABC123,DEF456"
|
||||
|
||||
|
||||
def test_pgp_arg_with_single_fingerprint():
|
||||
result = _pgp_arg(["ABC123DEF456"])
|
||||
assert result == "ABC123DEF456"
|
||||
|
||||
|
||||
def test_pgp_arg_with_multiple_fingerprints():
|
||||
result = _pgp_arg(["ABC123", "DEF456", "GHI789"])
|
||||
assert result == "ABC123,DEF456,GHI789"
|
||||
|
||||
|
||||
def test_encrypt_file_binary_success(monkeypatch, tmp_path: Path):
|
||||
"""Test successful encryption path."""
|
||||
# Create source file
|
||||
src = tmp_path / "secret.txt"
|
||||
src.write_text("secret data", encoding="utf-8")
|
||||
dst = tmp_path / "encrypted.sops"
|
||||
|
||||
# Mock subprocess.run to succeed
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = b"encrypted data"
|
||||
stderr = b""
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
# Mock require_sops_cmd to return a fake path
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
|
||||
from enroll.sopsutil import encrypt_file_binary
|
||||
|
||||
encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
|
||||
|
||||
assert dst.exists()
|
||||
assert dst.read_bytes() == b"encrypted data"
|
||||
|
||||
|
||||
def test_encrypt_file_binary_fails(monkeypatch, tmp_path: Path):
|
||||
"""Test encryption failure path."""
|
||||
src = tmp_path / "secret.txt"
|
||||
src.write_text("secret data", encoding="utf-8")
|
||||
dst = tmp_path / "encrypted.sops"
|
||||
|
||||
class Result:
|
||||
returncode = 1
|
||||
stdout = b""
|
||||
stderr = b"sops: gpg error"
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
|
||||
from enroll.sopsutil import encrypt_file_binary, SopsError
|
||||
|
||||
with pytest.raises(SopsError) as exc_info:
|
||||
encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
|
||||
|
||||
assert "encryption failed" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_encrypt_file_binary_chmod_fails(monkeypatch, tmp_path: Path):
|
||||
"""Test when chmod fails but file is still written."""
|
||||
src = tmp_path / "secret.txt"
|
||||
src.write_text("secret data", encoding="utf-8")
|
||||
dst = tmp_path / "encrypted.sops"
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = b"encrypted data"
|
||||
stderr = b""
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
def fake_chmod(path, mode):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
monkeypatch.setattr("enroll.sopsutil.os.chmod", fake_chmod)
|
||||
|
||||
from enroll.sopsutil import encrypt_file_binary
|
||||
|
||||
# Should not raise even though chmod fails
|
||||
encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
|
||||
|
||||
assert dst.exists()
|
||||
|
||||
|
||||
def test_decrypt_file_binary_to_success(monkeypatch, tmp_path: Path):
|
||||
"""Test successful decryption path."""
|
||||
src = tmp_path / "encrypted.sops"
|
||||
src.write_bytes(b"encrypted data")
|
||||
dst = tmp_path / "decrypted.txt"
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = b"decrypted data"
|
||||
stderr = b""
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
|
||||
from enroll.sopsutil import decrypt_file_binary_to
|
||||
|
||||
decrypt_file_binary_to(src, dst)
|
||||
|
||||
assert dst.exists()
|
||||
assert dst.read_bytes() == b"decrypted data"
|
||||
|
||||
|
||||
def test_decrypt_file_binary_to_fails(monkeypatch, tmp_path: Path):
|
||||
"""Test decryption failure path."""
|
||||
src = tmp_path / "encrypted.sops"
|
||||
src.write_bytes(b"encrypted data")
|
||||
dst = tmp_path / "decrypted.txt"
|
||||
|
||||
class Result:
|
||||
returncode = 1
|
||||
stdout = b""
|
||||
stderr = b"sops: decryption failed"
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
|
||||
from enroll.sopsutil import decrypt_file_binary_to, SopsError
|
||||
|
||||
with pytest.raises(SopsError) as exc_info:
|
||||
decrypt_file_binary_to(src, dst)
|
||||
|
||||
assert "decryption failed" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_decrypt_file_binary_to_chmod_fails(monkeypatch, tmp_path: Path):
|
||||
"""Test when chmod fails during decryption but file is still written."""
|
||||
src = tmp_path / "encrypted.sops"
|
||||
src.write_bytes(b"encrypted data")
|
||||
dst = tmp_path / "decrypted.txt"
|
||||
|
||||
class Result:
|
||||
returncode = 0
|
||||
stdout = b"decrypted data"
|
||||
stderr = b""
|
||||
|
||||
def fake_run(cmd, capture_output, check):
|
||||
return Result()
|
||||
|
||||
def fake_require():
|
||||
return "/fake/sops"
|
||||
|
||||
def fake_chmod(path, mode):
|
||||
raise OSError("Permission denied")
|
||||
|
||||
monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
|
||||
monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
|
||||
monkeypatch.setattr("enroll.sopsutil.os.chmod", fake_chmod)
|
||||
|
||||
from enroll.sopsutil import decrypt_file_binary_to
|
||||
|
||||
# Should not raise even though chmod fails
|
||||
decrypt_file_binary_to(src, dst)
|
||||
|
||||
assert dst.exists()
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import enroll.systemd as s
|
||||
|
||||
|
||||
def test_list_enabled_services_and_timers_filters_templates(monkeypatch):
|
||||
import enroll.systemd as s
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
if "--type=service" in cmd:
|
||||
return "\n".join(
|
||||
|
|
@ -35,8 +34,6 @@ def test_list_enabled_services_and_timers_filters_templates(monkeypatch):
|
|||
|
||||
|
||||
def test_get_unit_info_parses_fields(monkeypatch):
|
||||
import enroll.systemd as s
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str, err: str = ""):
|
||||
self.returncode = rc
|
||||
|
|
@ -71,8 +68,6 @@ def test_get_unit_info_parses_fields(monkeypatch):
|
|||
|
||||
|
||||
def test_get_unit_info_raises_unit_query_error(monkeypatch):
|
||||
import enroll.systemd as s
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str, err: str):
|
||||
self.returncode = rc
|
||||
|
|
@ -90,8 +85,6 @@ def test_get_unit_info_raises_unit_query_error(monkeypatch):
|
|||
|
||||
|
||||
def test_get_timer_info_parses_fields(monkeypatch):
|
||||
import enroll.systemd as s
|
||||
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str, err: str = ""):
|
||||
self.returncode = rc
|
||||
|
|
@ -119,3 +112,210 @@ def test_get_timer_info_parses_fields(monkeypatch):
|
|||
ti = s.get_timer_info("apt-daily.timer")
|
||||
assert ti.trigger_unit == "apt-daily.service"
|
||||
assert "/etc/default/apt" in ti.env_files
|
||||
|
||||
|
||||
def test_list_enabled_services_empty_output(monkeypatch):
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_services()
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_list_enabled_timers_empty_output(monkeypatch):
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_timers()
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_list_enabled_services_with_only_templates(monkeypatch):
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "getty@.service enabled\n"
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_services()
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_list_enabled_timers_with_only_templates(monkeypatch):
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "foo@.timer enabled\n"
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_timers()
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_timer_info_raises_on_failure(monkeypatch):
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str, err: str):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = err
|
||||
|
||||
def fake_run(cmd, text, capture_output):
|
||||
return P(1, "", "timer not found")
|
||||
|
||||
monkeypatch.setattr(s.subprocess, "run", fake_run)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
s.get_timer_info("nonexistent.timer")
|
||||
|
||||
assert "nonexistent.timer" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_get_timer_info_with_empty_fields(monkeypatch):
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str, err: str = ""):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = err
|
||||
|
||||
def fake_run(cmd, text, capture_output):
|
||||
return P(
|
||||
0,
|
||||
"\n".join(
|
||||
[
|
||||
"FragmentPath=",
|
||||
"DropInPaths=",
|
||||
"EnvironmentFiles=",
|
||||
"Unit=",
|
||||
"ActiveState=",
|
||||
"SubState=",
|
||||
"UnitFileState=",
|
||||
"ConditionResult=",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s.subprocess, "run", fake_run)
|
||||
ti = s.get_timer_info("empty.timer")
|
||||
assert ti.fragment_path is None
|
||||
assert ti.dropin_paths == []
|
||||
assert ti.env_files == []
|
||||
assert ti.trigger_unit is None
|
||||
assert ti.active_state is None
|
||||
|
||||
|
||||
def test_get_unit_info_with_empty_fields(monkeypatch):
|
||||
class P:
|
||||
def __init__(self, rc: int, out: str, err: str = ""):
|
||||
self.returncode = rc
|
||||
self.stdout = out
|
||||
self.stderr = err
|
||||
|
||||
def fake_run(cmd, check, text, capture_output):
|
||||
return P(
|
||||
0,
|
||||
"\n".join(
|
||||
[
|
||||
"FragmentPath=",
|
||||
"DropInPaths=",
|
||||
"EnvironmentFiles=",
|
||||
"ExecStart=",
|
||||
"ActiveState=",
|
||||
"SubState=",
|
||||
"UnitFileState=",
|
||||
"ConditionResult=",
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s.subprocess, "run", fake_run)
|
||||
ui = s.get_unit_info("empty.service")
|
||||
assert ui.fragment_path is None
|
||||
assert ui.dropin_paths == []
|
||||
assert ui.env_files == []
|
||||
assert ui.exec_paths == []
|
||||
assert ui.active_state is None
|
||||
|
||||
|
||||
def test_run_command_raises_on_error(monkeypatch):
|
||||
"""Test _run raises RuntimeError on non-zero exit."""
|
||||
|
||||
class P:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "command failed"
|
||||
|
||||
def fake_run(cmd, check, text, capture_output):
|
||||
return P()
|
||||
|
||||
monkeypatch.setattr(s.subprocess, "run", fake_run)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
s._run(["fake", "command"])
|
||||
|
||||
assert "Command failed" in str(exc_info.value)
|
||||
assert "fake" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_list_enabled_services_filters_non_service_units(monkeypatch):
|
||||
"""Test that non-.service units are filtered out."""
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"nginx.service enabled",
|
||||
"network.target enabled", # not a service
|
||||
"multi-user.target enabled", # not a service
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_services()
|
||||
assert result == ["nginx.service"]
|
||||
|
||||
|
||||
def test_list_enabled_timers_filters_non_timer_units(monkeypatch):
|
||||
"""Test that non-.timer units are filtered out."""
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"apt-daily.timer enabled",
|
||||
"some.service enabled", # not a timer
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_timers()
|
||||
assert result == ["apt-daily.timer"]
|
||||
|
||||
|
||||
def test_list_enabled_services_filters_empty_lines(monkeypatch):
|
||||
"""Test that empty lines are skipped."""
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"nginx.service enabled",
|
||||
"", # empty line
|
||||
"ssh.service enabled",
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_services()
|
||||
assert result == ["nginx.service", "ssh.service"]
|
||||
|
||||
|
||||
def test_list_enabled_timers_filters_empty_lines(monkeypatch):
|
||||
"""Test that empty lines are skipped."""
|
||||
|
||||
def fake_run(cmd: list[str]) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"apt-daily.timer enabled",
|
||||
"", # empty line
|
||||
"daily.timer enabled",
|
||||
]
|
||||
)
|
||||
|
||||
monkeypatch.setattr(s, "_run", fake_run)
|
||||
result = s.list_enabled_timers()
|
||||
assert result == ["apt-daily.timer", "daily.timer"]
|
||||
|
|
|
|||
|
|
@ -180,3 +180,234 @@ def test_cli_validate_exits_1_on_validation_warning_with_flag(
|
|||
with pytest.raises(SystemExit) as e:
|
||||
cli.main()
|
||||
assert e.value.code == 1
|
||||
|
||||
|
||||
def test_validation_result_ok():
|
||||
from enroll.validate import ValidationResult
|
||||
|
||||
result = ValidationResult(errors=[], warnings=[])
|
||||
assert result.ok is True
|
||||
assert result.to_text() == "OK: harvest bundle validated\n"
|
||||
|
||||
|
||||
def test_validation_result_with_errors():
|
||||
from enroll.validate import ValidationResult
|
||||
|
||||
result = ValidationResult(errors=["error1", "error2"], warnings=[])
|
||||
assert result.ok is False
|
||||
text = result.to_text()
|
||||
assert "ERROR: 2 validation error(s)" in text
|
||||
assert "error1" in text
|
||||
assert "error2" in text
|
||||
|
||||
|
||||
def test_validation_result_with_warnings():
|
||||
from enroll.validate import ValidationResult
|
||||
|
||||
result = ValidationResult(errors=[], warnings=["warn1"])
|
||||
assert result.ok is True
|
||||
text = result.to_text()
|
||||
assert "WARN: 1 warning(s)" in text
|
||||
assert "warn1" in text
|
||||
|
||||
|
||||
def test_validation_result_to_dict():
|
||||
from enroll.validate import ValidationResult
|
||||
|
||||
result = ValidationResult(errors=["e1"], warnings=["w1"])
|
||||
d = result.to_dict()
|
||||
assert d["ok"] is False
|
||||
assert d["errors"] == ["e1"]
|
||||
assert d["warnings"] == ["w1"]
|
||||
|
||||
|
||||
def test_iter_managed_files_singleton_roles():
|
||||
from enroll.validate import _iter_managed_files
|
||||
|
||||
state = {
|
||||
"roles": {
|
||||
"users": {"managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}]},
|
||||
"packages": [
|
||||
{
|
||||
"role_name": "vim",
|
||||
"managed_files": [{"path": "/usr/bin/vim", "src_rel": "vim"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
files = _iter_managed_files(state)
|
||||
assert len(files) == 2
|
||||
assert ("users", {"path": "/etc/passwd", "src_rel": "passwd"}) in files
|
||||
|
||||
|
||||
def test_iter_managed_files_services_role():
|
||||
from enroll.validate import _iter_managed_files
|
||||
|
||||
state = {
|
||||
"roles": {
|
||||
"services": [
|
||||
{
|
||||
"role_name": "nginx",
|
||||
"managed_files": [
|
||||
{"path": "/etc/nginx/nginx.conf", "src_rel": "nginx.conf"}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
files = _iter_managed_files(state)
|
||||
assert len(files) == 1
|
||||
assert files[0][0] == "nginx"
|
||||
|
||||
|
||||
def test_iter_managed_files_handles_non_dict_items():
|
||||
from enroll.validate import _iter_managed_files
|
||||
|
||||
state = {
|
||||
"roles": {
|
||||
"users": {
|
||||
"managed_files": [
|
||||
"not_a_dict",
|
||||
{"path": "/etc/passwd", "src_rel": "passwd"},
|
||||
]
|
||||
},
|
||||
"services": ["not_a_dict", {"role_name": "nginx", "managed_files": []}],
|
||||
"packages": ["not_a_dict"],
|
||||
}
|
||||
}
|
||||
files = _iter_managed_files(state)
|
||||
assert len(files) == 1
|
||||
|
||||
|
||||
def test_iter_managed_files_empty_state():
|
||||
from enroll.validate import _iter_managed_files
|
||||
|
||||
state = {"roles": {}}
|
||||
files = _iter_managed_files(state)
|
||||
assert files == []
|
||||
|
||||
|
||||
def test_validate_harvest_missing_state_json(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
result = validate_harvest(str(bundle_dir))
|
||||
assert result.ok is False
|
||||
assert any("missing state.json" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_invalid_json(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text("not valid json", encoding="utf-8")
|
||||
result = validate_harvest(str(bundle_dir))
|
||||
assert result.ok is False
|
||||
assert any("failed to parse" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_schema_error(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text("{}", encoding="utf-8")
|
||||
result = validate_harvest(
|
||||
str(bundle_dir), schema="https://invalid.invalid/schema.json"
|
||||
)
|
||||
assert result.ok is False
|
||||
assert any("failed to load/validate schema" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_missing_artifact(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
artifacts_dir = bundle_dir / "artifacts"
|
||||
artifacts_dir.mkdir()
|
||||
state = {
|
||||
"roles": {
|
||||
"users": {"managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}]}
|
||||
}
|
||||
}
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
result = validate_harvest(str(bundle_dir))
|
||||
assert result.ok is False
|
||||
assert any("missing artifact" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_suspicious_src_rel(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
state = {
|
||||
"roles": {
|
||||
"users": {
|
||||
"managed_files": [
|
||||
{"path": "/etc/passwd", "src_rel": "../../../etc/passwd"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
result = validate_harvest(str(bundle_dir))
|
||||
assert result.ok is False
|
||||
assert any("suspicious src_rel" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_missing_src_rel(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
state = {"roles": {"users": {"managed_files": [{"path": "/etc/passwd"}]}}}
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
result = validate_harvest(str(bundle_dir))
|
||||
assert result.ok is False
|
||||
assert any("missing src_rel" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_firewall_runtime_missing(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
artifacts_dir = bundle_dir / "artifacts"
|
||||
fw_dir = artifacts_dir / "firewall_runtime"
|
||||
fw_dir.mkdir(parents=True)
|
||||
state = {
|
||||
"roles": {
|
||||
"firewall_runtime": {
|
||||
"role_name": "firewall_runtime",
|
||||
"iptables_v4_save": "iptables.save",
|
||||
}
|
||||
}
|
||||
}
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
result = validate_harvest(str(bundle_dir))
|
||||
assert result.ok is False
|
||||
assert any("missing firewall runtime artifact" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_firewall_runtime_suspicious(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
state = {
|
||||
"roles": {
|
||||
"firewall_runtime": {
|
||||
"role_name": "firewall_runtime",
|
||||
"iptables_v4_save": "../../../etc/passwd",
|
||||
}
|
||||
}
|
||||
}
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text(json.dumps(state), encoding="utf-8")
|
||||
result = validate_harvest(str(bundle_dir))
|
||||
assert result.ok is False
|
||||
assert any("suspicious src_rel" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_validate_harvest_no_schema_option(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
state_file = bundle_dir / "state.json"
|
||||
state_file.write_text("invalid json", encoding="utf-8")
|
||||
result = validate_harvest(str(bundle_dir), no_schema=True)
|
||||
assert result.ok is False
|
||||
assert any("failed to parse" in e for e in result.errors)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
# The version module is hard to test fully because it uses importlib.metadata
|
||||
# which is difficult to mock. We'll test what we can.
|
||||
|
||||
|
||||
def test_get_enroll_version_returns_unknown_when_import_fails(monkeypatch):
|
||||
def test_get_enroll_version_returns_string():
|
||||
from enroll.version import get_enroll_version
|
||||
|
||||
# Ensure both the module cache and the parent package attribute are redirected.
|
||||
import importlib
|
||||
|
||||
dummy = types.ModuleType("importlib.metadata")
|
||||
# Missing attributes will cause ImportError when importing names.
|
||||
monkeypatch.setitem(sys.modules, "importlib.metadata", dummy)
|
||||
monkeypatch.setattr(importlib, "metadata", dummy, raising=False)
|
||||
|
||||
assert get_enroll_version() == "unknown"
|
||||
|
||||
|
||||
def test_get_enroll_version_uses_packages_distributions(monkeypatch):
|
||||
# Restore the real module for this test.
|
||||
monkeypatch.delitem(sys.modules, "importlib.metadata", raising=False)
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from enroll.version import get_enroll_version
|
||||
|
||||
monkeypatch.setattr(
|
||||
importlib.metadata,
|
||||
"packages_distributions",
|
||||
lambda: {"enroll": ["enroll-dist"]},
|
||||
)
|
||||
monkeypatch.setattr(importlib.metadata, "version", lambda dist: "9.9.9")
|
||||
|
||||
assert get_enroll_version() == "9.9.9"
|
||||
result = get_enroll_version()
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue