Compare commits

...

115 commits
0.6.0 ... main

Author SHA1 Message Date
1d42b2bfb9
Fix typos
Some checks failed
CI / test (push) Successful in 52s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 4m16s
Lint / test (push) Successful in 53s
2026-06-22 20:51:52 +10:00
d99ba66951
So long, etc
Some checks failed
CI / test (push) Successful in 53s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m52s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 4m23s
Lint / test (push) Has been cancelled
2026-06-22 20:40:51 +10:00
958f8e3aa7
Byebye Enroll
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 54s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m46s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 4m25s
2026-06-22 20:29:47 +10:00
d96ad3dc02
Some more hardening to not process raw jinja inside salt/ansible cmd. But, I think this is the end of the road
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 57s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Has been cancelled
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-06-22 20:26:06 +10:00
c3c3608049
Validate state.json is a normal file 2026-06-22 17:47:36 +10:00
5757bf4275
Update DEVELOPMENT.md
All checks were successful
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 12m41s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 22m25s
Lint / test (push) Successful in 1m19s
2026-06-22 17:23:31 +10:00
992b8060a5
validation of artifact dir 2026-06-22 17:23:25 +10:00
efb6d7cc15
Be strict about XDG_CACHE_DIR ownership etc 2026-06-22 17:22:27 +10:00
4277e029d0
fix changelog
All checks were successful
CI / test (push) Successful in 52s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m50s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 21m39s
Lint / test (push) Successful in 47s
2026-06-22 15:39:22 +10:00
5930758398
Fix pyproject to make debian build happy 2026-06-22 15:39:17 +10:00
952687e15d
Ensure that --include-path records (but does not traverse) symlinks
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Failing after 44s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Has been cancelled
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-06-22 15:34:44 +10:00
07b07e60c5
Ensure paths are not followed through parent links 2026-06-22 15:32:40 +10:00
e10a3f62b0
Belts and braces: normalise paths before globbing 2026-06-22 15:06:46 +10:00
c4448226c0
Ensure tests run through the poetry env's pytest 2026-06-22 15:05:48 +10:00
00f960d01e
Upgrade to Poetry 2 2026-06-22 15:03:32 +10:00
70525e52d8
Doc updates
All checks were successful
CI / test (push) Successful in 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m32s
Lint / test (push) Successful in 47s
2026-06-22 14:49:56 +10:00
ad019f6b09
normalise control characters in generated manifest scalars 2026-06-22 14:45:12 +10:00
cec6023a40
Ensure that diff also runs through validate()
All checks were successful
CI / test (push) Successful in 48s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m15s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m51s
Lint / test (push) Successful in 46s
2026-06-22 14:14:51 +10:00
1312b7eac2
Add SECURITY.md 2026-06-22 13:33:30 +10:00
a1d7a9e4e6
Add warning about --dangerous mode if sops is not in use
All checks were successful
CI / test (push) Successful in 50s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 12m37s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m21s
Lint / test (push) Successful in 45s
2026-06-22 12:56:21 +10:00
bf1c72c542
CHANGELOG updates 2026-06-22 12:47:39 +10:00
d93de8a8a2
Fix for remote harvest tmp dir 2026-06-22 12:46:45 +10:00
21a3ef3447
More safety about writing output harvests/manifests to safe locations, including SOPS and diff. 2026-06-22 12:21:33 +10:00
3feba9a9f2
More information about use of --dangerous mode 2026-06-22 12:03:48 +10:00
d1e99db2df
Update the cli help info about enroll.ini location 2026-06-22 12:00:48 +10:00
def1c2bbc7
Add note about README.md 2026-06-22 11:59:38 +10:00
e78f61c5ed
Avoid TOCTOU issues, stronger perms on manifest dir, don't allow harvesting to existing dir by default, scan whole file for potential secrets
All checks were successful
CI / test (push) Successful in 48s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m19s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m40s
Lint / test (push) Successful in 48s
2026-06-22 11:41:11 +10:00
c7a6bfe979
Update tests
All checks were successful
CI / test (push) Successful in 51s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m30s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 19m55s
Lint / test (push) Successful in 44s
2026-06-22 11:06:24 +10:00
a0914e1369
Strict validation of PATH when running as root in case it could contain potentially unsafe binaries 2026-06-22 11:06:01 +10:00
205c419a7a
Sanity check on FQDN name to avoid accidental path traversal and similar woes 2026-06-22 10:59:17 +10:00
3e8ad600e2
Use shlex.quote on remote commands 2026-06-22 10:58:20 +10:00
0a0f067111
Add other common strings that could represent sensitive values to ignore unless in --dangerous mode 2026-06-22 10:57:54 +10:00
e2b61bcdf1
Ensure jinjifying an artifact passes through safe_artifact_file just in case 2026-06-22 10:57:08 +10:00
03dc467e32
Updates to DEVELOPMENT.md re: manifest and validate 2026-06-22 10:09:31 +10:00
1e61ae2ff9
Fix tests for deb build
All checks were successful
CI / test (push) Successful in 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m32s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m1s
Lint / test (push) Successful in 44s
2026-06-22 10:05:17 +10:00
67b92731f6
Update tests
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Failing after 49s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Has been cancelled
CI / test (debian, docker.io/library/debian:13, python3) (push) Has been cancelled
2026-06-22 09:58:54 +10:00
0384f8817b
Fail closed on SMTP STARTTLS credential failure before sending creds. Ensure diff's manifest dir works now that we don't remove the target location if it exists (temp dir) 2026-06-22 09:57:56 +10:00
5ffd4ee755
Perform harvest validation before trying to manifest from it 2026-06-22 09:56:55 +10:00
706604df74
Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree) 2026-06-22 09:55:38 +10:00
a85e8265f4
Don't allow .enroll.ini in CWD, rely on env var or XDG path 2026-06-22 09:52:33 +10:00
6ee8c60e64
Fix the almalinux tests - skip jinjaturtle and systemd in CI
All checks were successful
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Successful in 11m26s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 20m24s
Lint / test (push) Successful in 45s
2026-06-21 17:49:51 +10:00
ce2652a3b3
Handle gracefully debian stuff when testing on rhel-like
Some checks failed
CI / test (push) Has been cancelled
Lint / test (push) Has been cancelled
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 5m10s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 10m10s
2026-06-21 16:15:33 +10:00
b704a6c80b
Add node before checkout
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 3m14s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 11m33s
2026-06-21 16:07:03 +10:00
b3a9cd3fb9
Fix curl on almalinux
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 2m9s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 2m28s
2026-06-21 16:00:35 +10:00
429da3f4c1
Attempt to run tests on Alma Linux
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Successful in 46s
CI / test (almalinux, docker.io/library/almalinux:9, python3.11) (push) Failing after 30s
CI / test (debian, docker.io/library/debian:13, python3) (push) Failing after 2m47s
2026-06-21 15:57:41 +10:00
f21bac7d1c
Updates to CHANGELOG and release script
All checks were successful
CI / test (push) Successful in 26m56s
Lint / test (push) Successful in 42s
2026-06-21 13:40:07 +10:00
fc120f02a5
More test coverage 2026-06-21 13:37:37 +10:00
528176ad82
Enforce the galaxy requirements in tests
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-21 13:15:10 +10:00
90e863df40
Add DEVELOPMENT.md 2026-06-21 13:03:26 +10:00
a0ac28f213
Support '--enforce' mode in 'enroll diff' with '--target' to use a specific config manager to run to enforce
All checks were successful
CI / test (push) Successful in 27m26s
Lint / test (push) Successful in 45s
2026-06-21 12:38:10 +10:00
5b0e945c99
Fix jinjaturtle tests
All checks were successful
CI / test (push) Successful in 27m22s
Lint / test (push) Successful in 41s
2026-06-21 09:42:19 +10:00
d81c32ab7f
Require version 1.20.0 or higher of podman container collection, for the platform arg 2026-06-21 09:41:56 +10:00
c7c8b93e09
make tests.sh executable again, whoops
Some checks failed
CI / test (push) Failing after 3m49s
Lint / test (push) Successful in 44s
2026-06-21 09:30:15 +10:00
5bb22afefd
Run jinjaturtle unit tests across the three renderers
Some checks failed
CI / test (push) Failing after 3m9s
Lint / test (push) Has been cancelled
2026-06-21 09:17:29 +10:00
582679a523
0.7.0b7
Some checks failed
CI / test (push) Successful in 20m0s
Lint / test (push) Has been cancelled
2026-06-21 09:03:52 +10:00
97b64522c6
Merge branch 'erb' 2026-06-21 09:03:33 +10:00
eeb37be567
0.7.0b6
All checks were successful
CI / test (push) Successful in 19m38s
Lint / test (push) Successful in 44s
2026-06-20 18:39:28 +10:00
f335077e59
Fix salt rendering of yaml/json 2026-06-20 18:38:49 +10:00
8cbde1423a
erb support, and fix notify services in puppet/salt in fqdn mode 2026-06-20 18:22:08 +10:00
4fd0facaf8
0.7.0b5
All checks were successful
CI / test (push) Successful in 19m16s
Lint / test (push) Successful in 44s
2026-06-20 15:33:47 +10:00
5845ff58e4
Update pyproject comment 2026-06-20 15:33:24 +10:00
097022f782
Fix notification of individual services when related config changes, even when roles are grouped
All checks were successful
CI / test (push) Successful in 19m18s
Lint / test (push) Successful in 42s
2026-06-20 15:31:42 +10:00
08066595f1
README updates 2026-06-20 14:36:59 +10:00
eb286b1db0
0.7.0b4
All checks were successful
CI / test (push) Successful in 19m23s
Lint / test (push) Successful in 43s
2026-06-20 12:30:39 +10:00
ceb86c513c
Improve test coverage of salt and puppet
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-20 12:30:02 +10:00
899724097e
Standardise more into CMModule parent class for the 3 child renderers
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-20 12:19:04 +10:00
7379587a28
Don't enforce /etc/enroll if no firewall rules to set in subdir
All checks were successful
CI / test (push) Successful in 19m38s
Lint / test (push) Successful in 43s
2026-06-19 20:29:12 +10:00
d6371ccccd
Fixes for ensuring /etc/enroll exists if /etc/enroll/firewall is to be created 2026-06-19 20:18:19 +10:00
5644062040
0.7.0b2 2026-06-19 19:12:26 +10:00
de42e16510
loooots of fixes.
Some checks failed
CI / test (push) Failing after 20m26s
Lint / test (push) Successful in 44s
2026-06-19 18:55:30 +10:00
b8926f9a5f
Simplify the over-engineered ansible rendering. Simplify docker image mgmt on Puppet so it doesn't use that awful puppetlabs-docker module
All checks were successful
CI / test (push) Successful in 20m26s
Lint / test (push) Successful in 47s
2026-06-19 16:32:25 +10:00
05b2875c17
Oh, Salt now works with JinjaTurtle :)
All checks were successful
CI / test (push) Successful in 19m36s
Lint / test (push) Successful in 45s
2026-06-18 20:38:50 +10:00
adfeb21d4b
reintroduce Salt
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-18 20:35:38 +10:00
0d111caf62
Revert "Remove salt"
This reverts commit b149b2e5d7.
2026-06-18 20:12:56 +10:00
02feff014f
Version 0.7.0b1
All checks were successful
CI / test (push) Successful in 18m28s
Lint / test (push) Successful in 46s
2026-06-18 09:13:03 +10:00
37523514b0
Make clear that flatpak/snap config manifesting is Ansible only for now
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-18 09:12:24 +10:00
79e73584e9
Set version to beta for pypi
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-18 09:09:47 +10:00
bf0228a76a
Dependency updates 2026-06-18 09:09:40 +10:00
22723678bd
Note in CHANGELOG that 0.7.0 is not yet released 2026-06-18 09:09:32 +10:00
a4b0ef0544
Extra clarity on modulepath with Puppet for docker support 2026-06-18 09:09:16 +10:00
b149b2e5d7
Remove salt
All checks were successful
CI / test (push) Successful in 18m7s
Lint / test (push) Successful in 41s
2026-06-17 18:13:06 +10:00
ebc27e1111
Support for detecting Docker images
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-17 18:05:02 +10:00
e2be9a6239
Separate up the ansible renderer. Simplify the package management bits by using ansible.builtin.package
All checks were successful
CI / test (push) Successful in 22m12s
Lint / test (push) Successful in 44s
2026-06-17 16:40:36 +10:00
e448994470
No sudo needed in the CI test
All checks were successful
CI / test (push) Successful in 25m53s
Lint / test (push) Successful in 44s
2026-06-17 14:29:16 +10:00
845f8d9ad1
Refactor tests.sh
Some checks failed
CI / test (push) Failing after 8s
Lint / test (push) Successful in 44s
2026-06-17 14:25:41 +10:00
c7e3b94355
Add separate pytests.sh script for local use 2026-06-17 14:22:57 +10:00
ee08bf43ba
Support manifesting Salt 2026-06-17 14:19:25 +10:00
ceca3df83c
Fix hiera/fqdn support for Puppet
All checks were successful
CI / test (push) Successful in 16m41s
Lint / test (push) Successful in 48s
2026-06-17 11:47:47 +10:00
20cc48e1ce
More refactoring, support hiera and multi site mode for Puppet
All checks were successful
CI / test (push) Successful in 15m30s
Lint / test (push) Successful in 44s
2026-06-17 10:54:46 +10:00
ed9ec6893a
Try to resolve circular imports
All checks were successful
CI / test (push) Successful in 15m37s
Lint / test (push) Successful in 44s
2026-06-17 09:51:47 +10:00
de7531424d
Huge refactor to support extending a generic Config Manager class for different types (Ansible, Puppet... Salt soon?)
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-17 09:37:32 +10:00
5e6c8e6455
remove sudo call
All checks were successful
CI / test (push) Successful in 14m27s
Lint / test (push) Successful in 42s
2026-06-16 16:53:28 +10:00
3c84b3c070
Test puppet run
Some checks failed
CI / test (push) Failing after 3m29s
Lint / test (push) Successful in 42s
2026-06-16 16:47:47 +10:00
380a0b8ca2
Update the puppet reserved names
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-16 16:44:10 +10:00
33b9d44c55
Output the AppImage into the dir it's going into anyway
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-16 16:39:28 +10:00
f9e93cd6fd
Support manifesting Puppet :o 2026-06-16 16:39:18 +10:00
e682aae41e
Filter out more sysctl params that throw Invalid argument when executed on the fly 2026-06-16 16:30:33 +10:00
9546e1b8ed
Add sysctl detection 2026-06-16 14:23:44 +10:00
3c19ae54b2
Only capture user-specific .bashrc style files when using mode, in case they contain sensitive env vars.
All checks were successful
CI / test (push) Successful in 14m0s
Lint / test (push) Successful in 42s
2026-06-16 13:35:33 +10:00
8774d019d3
Fix tests
All checks were successful
CI / test (push) Successful in 14m26s
Lint / test (push) Successful in 43s
2026-06-14 19:21:32 +10:00
1e996f4a43
Group all package roles into Debian/RPM 'sections'
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
This includes managed config files and unit state.

This mode is not used if `--fqdn` or `--no-common-roles` is set,
in which case, the traditional behaviour of preserving one role
per package/unit is used instead.

This is a breaking change.
2026-06-14 19:19:59 +10:00
e2339616fb
remove flatpak tests which don't work great in CI 2026-06-14 18:49:26 +10:00
00329cdd33
install flatpak in test
Some checks failed
CI / test (push) Failing after 5m15s
Lint / test (push) Successful in 42s
2026-06-14 18:42:28 +10:00
9dfbd411de
Add some flatpak tests
Some checks failed
CI / test (push) Failing after 3m5s
Lint / test (push) Has been cancelled
2026-06-14 18:37:48 +10:00
8f425b595b
Remove newlines
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled
2026-06-14 18:34:22 +10:00
eb1d096c90
Add support for detecting flatpaks and snaps
Some checks failed
CI / test (push) Failing after 5m51s
Lint / test (push) Successful in 43s
2026-06-14 18:25:26 +10:00
11351cce87
Fix test 2026-06-14 16:23:06 +10:00
bbfc338734
Fix regression that enforced merge_simple_packages
Some checks failed
CI / test (push) Failing after 10m40s
Lint / test (push) Successful in 38s
2026-06-14 16:03:52 +10:00
76df10ee92
Add --merge-simple-packages to reduce the number of roles, for packages that have no config files or services to maintain.
Some checks failed
CI / test (push) Failing after 5m32s
Lint / test (push) Successful in 40s
2026-06-14 15:52:07 +10:00
a0fbed5ca5
Fix curl option
All checks were successful
CI / test (push) Successful in 11m55s
Lint / test (push) Successful in 41s
2026-06-07 14:44:36 +10:00
6c58beddfe
Attempt to install SOPS in tests
Some checks failed
CI / test (push) Failing after 2m11s
Lint / test (push) Successful in 41s
2026-06-07 14:40:08 +10:00
fbb06f1177
More coverage
Some checks failed
Lint / test (push) Successful in 45s
CI / test (push) Failing after 2m35s
2026-05-31 17:55:22 +10:00
62b2f2ffe6
More coverage
Some checks failed
CI / test (push) Failing after 1s
Lint / test (push) Failing after 1s
2026-05-31 17:21:45 +10:00
bf735c8328
More coverage
Some checks failed
CI / test (push) Failing after 1s
Lint / test (push) Failing after 1s
2026-05-31 17:15:22 +10:00
1544dc0295
more test coverage 2026-05-31 16:50:57 +10:00
91 changed files with 27832 additions and 5556 deletions

View file

@ -7,26 +7,104 @@ jobs:
test:
runs-on: docker
strategy:
fail-fast: false
matrix:
include:
- distro: debian
image: docker.io/library/debian:13
python: python3
- distro: almalinux
image: docker.io/library/almalinux:9
python: python3.11
container:
image: ${{ matrix.image }}
steps:
- name: Install system dependencies
env:
DISTRO: ${{ matrix.distro }}
PYTHON_BIN: ${{ matrix.python }}
run: |
set -eux
case "${DISTRO}" in
debian)
mkdir -m 755 -p /etc/apt/keyrings
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl gnupg git tar gzip findutils bash nodejs procps \
ansible ansible-lint python3 python3-venv python3-pip pipx systemctl python3-apt jq python3-jsonschema \
puppet hiera
curl -fsSL https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public | gpg --dearmor | tee /etc/apt/keyrings/salt-archive-keyring.pgp > /dev/null
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources | tee /etc/apt/sources.list.d/salt.sources
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api
;;
almalinux)
dnf -y upgrade --refresh
dnf -y install \
ca-certificates curl-minimal gnupg2 git tar gzip findutils bash which jq nodejs procps-ng \
dnf-plugins-core epel-release
dnf -y config-manager --set-enabled crb || true
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo > /etc/yum.repos.d/salt.repo
dnf -y install https://yum.puppet.com/puppet8-release-el-9.noarch.rpm
dnf -y makecache
dnf -y install \
python3.11 python3.11-devel python3.11-pip gcc make \
ansible-core ansible-lint systemd rpm httpd \
puppet-agent \
salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api
echo "/opt/puppetlabs/bin" >> "$GITHUB_PATH"
;;
*)
echo "Unsupported CI distro: ${DISTRO}" >&2
exit 1
;;
esac
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ansible ansible-lint python3-venv pipx systemctl python3-apt jq python3-jsonschema
- name: Install Poetry
env:
PYTHON_BIN: ${{ matrix.python }}
POETRY_VERSION: "2.4.1"
run: |
pipx install poetry==1.8.3
/root/.local/bin/poetry --version
set -eux
if ! command -v pipx >/dev/null 2>&1; then
"${PYTHON_BIN}" -m pip install --user pipx
fi
PIPX_BIN="$(command -v pipx || true)"
if [ -z "${PIPX_BIN}" ]; then
PIPX_BIN="${HOME}/.local/bin/pipx"
fi
"${PIPX_BIN}" install --python "${PYTHON_BIN}" "poetry==${POETRY_VERSION}"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.local/bin:$PATH"
poetry --version
poetry --version | grep -E "Poetry \(version 2\."
- name: Install project deps (including test extras)
env:
PYTHON_BIN: ${{ matrix.python }}
run: |
poetry env use "${PYTHON_BIN}"
poetry install --with dev
- name: Install sops
run: |
set -eux
case "$(uname -m)" in
x86_64) sops_arch=amd64 ;;
aarch64|arm64) sops_arch=arm64 ;;
*) echo "Unsupported architecture for sops: $(uname -m)" >&2; exit 1 ;;
esac
curl -L -o /usr/local/bin/sops "https://github.com/getsops/sops/releases/download/v3.13.1/sops-v3.13.1.linux.${sops_arch}"
chmod +x /usr/local/bin/sops
- name: Run test script
run: |
./tests.sh

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ dist
*.pdf
*.csv
*.html
coverage.xml
*.orig
*.rej

View file

@ -1,3 +1,22 @@
# 0.7.0 (unreleased)
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
* BREAKING CHANGE: Don't allow reading `.enroll.ini` in the CWD. Use only the ENROLL_CONFIG env var, an explicit `--config` path or else the XDG default location (or `~/.config/enroll/enroll.ini` if `XDG_CONFIG_HOME` is not set).
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
* Support manifesting Puppet code, as well as Ansible!
* Support manifesting Salt code, as well as Ansible and Puppet!
* Take advantage of Jinjaturtle 0.5.5 if it's present, to render .erb templates for Puppet (as well as j2 for Ansible and Salt)
* A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers (that don't suck) in future.
* Support for detecting Docker and Podman images and enforcing their presence (by SHA256 hash).
* Add support for detecting Flatpaks and Snaps.
* Stricter validation of harvests to ensure that they meet the schema and don't contain unsafe artifacts (e.g symlinks pointing outside the artifact tree)
* Perform harvest validation before trying to manifest from it.
* Stricter validation on FQDN name in multisite mode.
* Strict check of `$PATH` when running harvest as root, in case it could lead to execution of unsafe binaries during harvest. Override with `--assume-safe-path` for non-interactive or CI purposes.
* Stricter validation of the destination dirs that harvest or manifest write to, to prevent writing to a different user-controlled area. Stricter permissions on the output dirs too.
# 0.6.0
* Add support for capturing ipset and iptables configuration files

2007
DEVELOPMENT.md Normal file

File diff suppressed because it is too large Load diff

627
README.md
View file

@ -4,629 +4,10 @@
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
</div>
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible roles/playbooks (and optionally inventory) for what it finds.
Hi folks. I spent a lot of time working on what was to be 0.7.0 of Enroll, before finding too many potental security risks along the way. After tens of security audits by LLMs and the like, to be told over and over 'this is really solid engineering', I'd end up with one that would find a critical vulnerability. I could no longer assume there weren't more. I am not a good programmer, and AI is an echo chamber of optimism.
- Detects packages that have been installed.
- Detects package ownership of `/etc` files where possible
- Captures config that has **changed from packaged defaults** where possible (e.g dpkg conffile hashes + package md5sums when available).
- Also captures **service-relevant custom/unowned files** under `/etc/<service>/...` (e.g. drop-in config includes).
- 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.
I decided it was better that such a project didn't exist. To that end, I'm removing it from the repos and PyPI.
---
Please uninstall it.
## Mental model
`enroll` works in two phases:
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
2) **Manifest**: turn that harvest into Ansible roles/playbooks (and optionally inventory)
Additionally, some other functionalities exist:
- **Diff**: compare two harvests and report what changed (packages/services/users/files) since the previous snapshot.
- **Single-shot mode**: run both harvest and manifest at once.
---
## Output modes: single-site vs multi-site (`--fqdn`)
`enroll manifest` (and `enroll single-shot`) support two distinct output styles.
### Single-site mode (default: *no* `--fqdn`)
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
**Characteristics**
- Roles are more self-contained.
- Raw config files live in the role's `files/`.
- Template variables live in the role's `defaults/main.yml`.
### Multi-site mode (`--fqdn`)
Use when enrolling **several existing servers** quickly, especially if they differ.
**Characteristics**
- Roles are shared, host-specific state lives in inventory.
- Host inventory drives what gets managed (files/packages/services).
- Non-templated raw files live per-host under `inventory/host_vars/<fqdn>/<role>/.files/...`.
**Rule of thumb**
- “Make this one server reproducible/provisionable” → start with **single-site**
- “Get multiple already-running servers under management quickly” → use **multi-site**
---
## Subcommands
### `enroll harvest`
Harvest state about a host and write a harvest bundle.
**What it captures (high level)**
- Detected services + service-relevant packages
- “Manual” packages
- 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**
- Remote harvesting:
- `--remote-host`, `--remote-user`, `--remote-port`, `--remote-ssh-config`
- `--no-sudo` (if you don't want/need sudo)
- Sensitive-data behaviour:
- default: tries to avoid likely secrets
- `--dangerous`: disables secret-safety checks (see “Sensitive data” below)
- Encrypt bundles at rest:
- `--sops <FINGERPRINT...>`: writes a single encrypted `harvest.tar.gz.sops` instead of a plaintext directory
- Path selection (include/exclude):
- `--include-path <PATTERN>` (repeatable): add extra files/dirs to harvest (even from locations normally ignored, like `/home`). Still subject to secret-safety checks unless `--dangerous`.
- `--exclude-path <PATTERN>` (repeatable): skip files/dirs even if they would normally be harvested.
- Pattern syntax:
- plain path: matches that file; directories match the directory + everything under it
- glob (default): supports `*` and `**` (prefix with `glob:` to force)
- regex: prefix with `re:` or `regex:`
- Precedence: excludes win over includes.
* 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
```
---
### `enroll manifest`
Generate Ansible output from an existing harvest bundle.
**Inputs**
- `--harvest /path/to/harvest` (directory)
or `--harvest /path/to/harvest.tar.gz.sops` (if using `--sops`)
**Output**
- In plaintext mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
**Common flags**
- `--fqdn <host>`: enables **multi-site** output style
**Role tags**
Generated playbooks tag each role so you can target just the parts you need:
- Tag format: `role_<role_name>` (e.g. `role_services`, `role_users`)
- Fallback/safe tag: `role_other`
Example:
```bash
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_services,role_users
```
---
### `enroll single-shot`
Convenience wrapper that runs **harvest → manifest** in one command.
Use this when you want “get me something workable ASAP”.
Supports the same general flags as harvest/manifest, including `--fqdn`, remote harvest flags, and `--sops`.
---
### `enroll diff`
Compare two harvest bundles and report what changed.
**What it reports**
- Packages added/removed
- Services enabled added/removed, plus key state changes
- Users added/removed, plus field changes (uid/gid/home/shell/groups, etc.)
- Managed files added/removed/changed (metadata + content hash changes where available)
**Inputs**
- `--old <harvest>` and `--new <harvest>` (directories or `state.json` paths)
- `--sops` when comparing SOPS-encrypted harvest bundles
- `--exclude-path <PATTERN>` (repeatable) to ignore file/dir drift under matching paths (same pattern syntax as harvest)
- `--ignore-package-versions` to ignore package version-only drift (upgrades/downgrades)
- `--enforce` to apply the **old** harvest state locally (requires `ansible-playbook` on `PATH`)
**Noise suppression**
- `--exclude-path` is useful for things that change often but you still want in the harvest baseline (e.g. `/var/anacron`).
- `--ignore-package-versions` keeps routine upgrades from alerting; package add/remove drift is still reported.
**Enforcement (`--enforce`)**
If a diff exists and `ansible-playbook` is available, Enroll will:
1) generate a manifest from the **old** harvest into a temporary directory
2) run `ansible-playbook -i localhost, -c local <tmp>/playbook.yml` (often with `--tags role_<...>` to limit runtime)
3) record in the diff report that the old harvest was enforced
Enforcement is intentionally “safe”:
- reinstalls packages that were removed (`state: present`), but does **not** attempt downgrades/pinning
- restores users, files (contents + permissions/ownership), and service enable/start state
If `ansible-playbook` is not on `PATH`, Enroll returns an error and does not enforce.
**Output formats**
- `--format json` (default for webhooks)
- `--format markdown` / `--format text` (human-oriented)
**Notifications**
- Webhook:
- `--webhook <url>`
- `--webhook-format json|markdown|text`
- `--webhook-header 'Header-Name: value'` (repeatable)
- Email (optional):
- `--email-to <addr>` (plus optional SMTP/sendmail-related flags, depending on your install)
---
### `enroll explain`
Analyze a harvest and provide user-friendly explanations for what's in it and why.
This may also explain why something *wasn't* included (e.g a binary file, a file that was too large, unreadable due to permissions, or looked like a log file/secret.
Provide either the path to the harvest or the path to its state.json. It can also handle SOPS-encrypted harvests.
Output can be provided in plaintext or json.
---
### `enroll validate`
Validates a harvest by checking:
* state.json exists and is valid JSON
* state.json validates against a JSON Schema (by default the vendored one)
* Every `managed_file` entry has a corresponding artifact at: `artifacts/<role_name>/<src_rel>`
* That there are no **unreferenced files** sitting in `artifacts/` that aren't in the state.
#### Schema location + overrides
The master schema lives at: `enroll/schema/state.schema.json`.
You can override with a local file or URL:
```
enroll validate /path/to/harvest --schema ./state.schema.json
enroll validate /path/to/harvest --schema https://enroll.sh/schema/state.schema.json
```
Or skip schema checks (still does artifact consistency checks):
```
enroll validate /path/to/harvest --no-schema
```
#### CLI usage examples
Validate a local harvest:
```
enroll validate ./harvest
```
Validate a harvest tarball or a sops bundle:
```
enroll validate ./harvest.tar.gz
enroll validate ./harvest.sops --sops
```
JSON output + write to file:
```
enroll validate ./harvest --format json --out validate.json
```
Return exit code 1 for any warnings, not just errors (useful for CI):
```
enroll validate ./harvest --fail-on-warnings
```
---
## Sensitive data
By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage.
If you opt in to collecting everything:
### `--dangerous`
**WARNING:** disables “likely secret” safety checks. This can copy private keys, TLS key material, API tokens, database passwords, and other credentials into the harvest output **in plaintext**.
If you intend to keep harvests/manifests long-term (especially in git), strongly consider encrypting them at rest.
### Encrypt bundles at rest with `--sops`
`--sops` encrypts the harvest and/or manifest outputs into a single `.tar.gz.sops` file (GPG). This is for **storage-at-rest**, not for direct “Ansible SOPS inventory” workflows.
⚠️ Important: `manifest --sops` produces one encrypted file. You must decrypt + extract it before running `ansible-playbook`.
---
## JinjaTurtle integration (both modes)
If [JinjaTurtle](https://git.mig5.net/mig5/jinjaturtle) is installed, `enroll` can generate Jinja2 templates for ini/json/xml/toml-style config.
- Templates live in `roles/<role>/templates/...`
- Variables live in:
- single-site: `roles/<role>/defaults/main.yml`
- multi-site: `inventory/host_vars/<fqdn>/<role>.yml`
You can force it on with `--jinjaturtle` or disable with `--no-jinjaturtle`.
---
## How multi-site avoids “shared role breaks a host”
In multi-site mode, roles are **data-driven**. The role tasks are generic (“deploy the files listed for this host”, “install the packages listed for this host”, “apply systemd enable/start state listed for this host”). Host inventory decides what applies per-host, avoiding the classic “host2 adds config, host1 breaks” failure mode.
---
# Install
## Ubuntu/Debian apt repository
```bash
sudo mkdir -p /usr/share/keyrings
curl -fsSL https://mig5.net/static/mig5.asc | sudo gpg --dearmor -o /usr/share/keyrings/mig5.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/mig5.list
sudo apt update
sudo apt install enroll
```
## Fedora
```bash
sudo rpm --import https://mig5.net/static/mig5.asc
sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF'
[mig5]
name=mig5 Repository
baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://mig5.net/static/mig5.asc
EOF
sudo dnf upgrade --refresh
sudo dnf install enroll
```
## AppImage
Download it from my Releases page, then:
```bash
chmod +x Enroll.AppImage
./Enroll.AppImage
```
## Pip/PipX
```bash
pip install enroll
```
## Poetry (dev)
```bash
poetry install
poetry run enroll --help
```
---
## Found a bug / have a suggestion?
My Forgejo doesn't currently support federation, so I haven't opened registration/login for issues.
Instead, email me (see `pyproject.toml`) or contact me on the Fediverse:
https://goto.mig5.net/@mig5
---
# Examples
## Harvest
### Local harvest
```bash
enroll harvest --out /tmp/enroll-harvest
```
### Remote harvest over SSH
```bash
enroll harvest --remote-host myhost.example.com --remote-user myuser --out /tmp/enroll-harvest
```
### Remote harvest over SSH, where the SSH configuration is in ~/.ssh/config (e.g a different SSH key)
Note: you must still pass `--remote-host`, but in this case, its value can be the 'Host' alias of an entry in your `~/.ssh/config`.
```bash
enroll harvest --remote-host myhostalias --remote-ssh-config ~/.ssh/config --out /tmp/enroll-harvest
```
### Include paths (`--include-path`)
```bash
# Add a few dotfiles from /home (still secret-safe unless --dangerous)
enroll harvest --out /tmp/enroll-harvest --include-path '/home/*/.bashrc' --include-path '/home/*/.profile'
```
### Exclude paths (`--exclude-path`)
```bash
# Skip specific /usr/local/bin entries (or patterns)
enroll harvest --out /tmp/enroll-harvest --exclude-path '/usr/local/bin/docker-*' --exclude-path '/usr/local/bin/some-tool'
```
### Regex include
```bash
enroll harvest --out /tmp/enroll-harvest --include-path 're:^/home/[^/]+/\.config/myapp/.*$'
```
### `--dangerous`
```bash
enroll harvest --out /tmp/enroll-harvest --dangerous
```
### Remote + dangerous:
```bash
enroll harvest --remote-host myhost.example.com --remote-user myuser --dangerous
```
### `--sops` (encrypt at rest)
```bash
# Encrypted harvest bundle (writes /tmp/enroll-harvest/harvest.tar.gz.sops)
enroll harvest --out /tmp/enroll-harvest --dangerous --sops <FINGERPRINT(s)>
```
---
## Manifest
### Single-site (default: no --fqdn)
```bash
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
```
### Multi-site (--fqdn)
```bash
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
```
### Manifest with `--sops`
```bash
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
enroll manifest --harvest /tmp/enroll-harvest/harvest.tar.gz.sops --out /tmp/enroll-ansible --sops <FINGERPRINT(s)>
# Decrypt/extract the manifest bundle, then run Ansible from inside ./manifest/
cd /tmp/enroll-ansible
sops -d manifest.tar.gz.sops | tar -xzvf -
cd manifest
```
---
## Single-shot
```bash
enroll single-shot --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
```
Remote single-shot (run harvest over SSH, then manifest locally):
```bash
enroll single-shot --remote-host myhost.example.com --remote-user myuser --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "myhost.example.com"
```
---
## Diff
### Compare two harvest directories, output in json
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --format json
```
### Diff + webhook notify
```bash
enroll diff --old /path/to/golden/harvest --new /path/to/new/harvest --webhook https://nr.mig5.net/forms/webhooks/xxxx --webhook-format json --webhook-header 'X-Enroll-Secret: xxxx'
```
`diff` mode also supports email sending and text or markdown format, as well as `--exit-code` mode to trigger a return code of 2 (useful for crons or CI)
### Ignore a specific directory or file from the diff
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/anacron
```
### Ignore package version drift (routine upgrades) but still alert on add/remove
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --ignore-package-versions
```
### Enforce the old harvest state when drift is detected (requires Ansible)
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --enforce --ignore-package-versions --exclude-path /var/anacron
```
---
## Explain
### Explain a harvest
All of these do the same thing:
```bash
enroll explain /path/to/state.json
enroll explain /path/to/bundle_dir
enroll explain /path/to/harvest.tar.gz
```
### Explain a SOPS-encrypted harvest
```bash
enroll explain /path/to/harvest.tar.gz.sops --sops
```
### Explain with JSON output and more examples
```bash
enroll explain /path/to/state.json --format json --max-examples 25
```
### Example output
```
enroll explain /tmp/syrah.harvest
Enroll explain: /tmp/syrah.harvest
Host: syrah.mig5.net (os: debian, pkg: dpkg)
Enroll: 0.2.3
Inventory
- Packages: 254
- Why packages were included (observed_via):
- user_installed: 248 Package appears explicitly installed (as opposed to only pulled in as a dependency).
- package_role: 232 Package was referenced by an enroll packages snapshot/role. (e.g. acl, acpid, adduser)
- systemd_unit: 22 Package is associated with a systemd unit that was harvested. (e.g. postfix.service, tor.service, apparmor.service)
Roles collected
- users: 1 user(s), 1 file(s), 0 excluded
- services: 19 unit(s), 111 file(s), 6 excluded
- 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
Why files were included (managed_files.reason)
- custom_unowned (179): A file not owned by any package (often custom/operator-managed).. Examples: /etc/apparmor.d/local/lsb_release, /etc/apparmor.d/local/nvidia_modprobe, /etc/apparmor.d/local/sbin.dhclient
- usr_local_bin_script (35): Executable scripts under /usr/local/bin (often operator-installed).. Examples: /usr/local/bin/check_firewall, /usr/local/bin/awslogs
- apt_keyring (13): Repository signing key material used by APT.. Examples: /etc/apt/keyrings/openvpn-repo-public.asc, /etc/apt/trusted.gpg, /etc/apt/trusted.gpg.d/deb.torproject.org-keyring.gpg
- modified_conffile (10): A package-managed conffile differs from the packaged/default version.. Examples: /etc/dnsmasq.conf, /etc/ssh/moduli, /etc/tor/torrc
- logrotate_snippet (9): logrotate snippets/configs referenced in system configuration.. Examples: /etc/logrotate.d/rsyslog, /etc/logrotate.d/tor, /etc/logrotate.d/apt
- apt_config (7): APT configuration affecting package installation and repository behavior.. Examples: /etc/apt/apt.conf.d/01autoremove, /etc/apt/apt.conf.d/20listchanges, /etc/apt/apt.conf.d/70debconf
[...]
```
---
## Run Ansible
### Single-site
```bash
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml
```
### Multi-site (--fqdn)
```bash
ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml
```
### Run only specific roles (tags)
Generated playbooks tag each role as `role_<name>` (e.g. `role_users`, `role_services`), so you can speed up targeted runs:
```bash
ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_users
```
## Configuration file
As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.
Sometimes, it can be easier to store them in a config file so you don't have to remember them!
Enroll supports reading an ini-style file of all the arguments for each subcommand.
### Location of the config file
The path the config file can be specified with `-c` or `--config` on the command-line. Otherwise,
Enroll will look for `./enroll.ini`, `./.enroll.ini` (in the current working directory),
`~/.config/enroll/enroll.ini` (or `$XDG_CONFIG_HOME/enroll/enroll.ini`).
You may also pass `--no-config` if you deliberately want to ignore the config file even if it existed.
### Precedence
Highest wins:
* Explicit CLI flags
* INI config ([cmd], [enroll])
* argparse defaults
### Example config file
Here is an example.
Whenever an argument on the command-line has a 'hyphen' in it, just be sure to change it to an underscore in the ini file.
```ini
[enroll]
# (future global flags may live here)
[harvest]
dangerous = false
include_path =
/home/*/.bashrc
/home/*/.profile
exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
# remote_host = yourserver.example.com
# remote_user = you
# remote_port = 2222
[manifest]
# you can set defaults here too, e.g.
no_jinjaturtle = true
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
[diff]
# ignore noisy drift
exclude_path = /var/anacron
ignore_package_versions = true
# enforce = true # requires ansible-playbook on PATH
[single-shot]
# if you use single-shot, put its defaults here.
# It does not inherit those of the subsections above, so you
# may wish to repeat them here.
include_path = re:^/home/[^/]+/\.config/myapp/.*$
```
Thanks for all the love in 2026.

97
SECURITY.md Normal file
View file

@ -0,0 +1,97 @@
# Enroll Threat Model and Security Scope
Enroll is a command-line systems administration tool. It is designed to be executed intentionally by a system administrator, often with elevated privileges, in order to inspect a host, harvest selected system state, and optionally generate or apply configuration-management output.
Because of that design, Enrolls security model is different from that of a network service, web application, daemon, or setuid program. Enroll does not attempt to defend against arbitrary local compromise of the account executing it. If an attacker can control the command line, environment, configuration file, working directory, `PATH`, harvested input bundle, or configuration-management tools used by the administrator, they may be able to influence what Enroll does. That situation is considered a local trust-boundary failure outside Enrolls intended security model.
## Core assumptions
Enroll assumes that the person running the tool understands what they are asking it to do.
In particular:
* If Enroll is run as root, the root user is assumed to control and understand the command line, environment, configuration file, and output location being used.
* If an `enroll.ini` configuration file is loaded, its location and contents are assumed to be owned, selected, and understood by the operator.
* The operator is expected to understand the implications of options such as `--dangerous`, `--assume-safe-path`, `--sops`, `--enforce`, `--remote-host`, and `--remote-ssh-config`.
* Harvest bundles used for `manifest`, `diff`, or `diff --enforce` are assumed to come from a trusted source unless the operator is deliberately inspecting untrusted input without applying it.
* Configuration-management tools invoked by Enroll, such as Ansible, Puppet, Salt, SOPS, SSH, `sudo`, Docker, Podman, Flatpak, Snap, package managers, and system utilities, are assumed to be the trusted tools the operator intended to use.
## What is in scope
Enroll tries to protect careful administrators from common and serious mistakes that can occur when a privileged CLI tool reads and writes host state.
In-scope security concerns include:
* Avoiding accidental capture of obvious secrets in default safe mode.
* Refusing known sensitive paths such as shadow files, SSH host keys, private key material, and common certificate/private-key locations unless the operator explicitly opts into dangerous collection.
* Warning when `--dangerous` is used, especially without encrypted output.
* Supporting encrypted harvest bundles via `--sops`.
* Avoiding symlink traversal and time-of-check/time-of-use mistakes when copying harvested files.
* Refusing unsafe artifact paths, symlinks, hardlinks, device nodes, and tar path traversal in harvest bundles.
* Writing plaintext harvest outputs into private directories by default.
* Hardening root-run output path handling so Enroll does not accidentally write through attacker-prepared symlinks or unsafe parent directories.
* Refusing to continue non-interactively when run as root with an unsafe `PATH`, unless the operator explicitly confirms with `--assume-safe-path`.
* Avoiding shell injection in generated manifests where harvested values are embedded into Ansible, Puppet, or Salt output.
* Rejecting unknown SSH host keys by default during remote harvests.
These measures are defense-in-depth. They are intended to reduce the chance of accidental exposure, unsafe filesystem writes, path traversal, command injection, or dangerous behavior when Enroll is used normally by an administrator.
## What is out of scope
The following are generally out of scope and should not be reported as Enroll vulnerabilities unless they also bypass one of Enrolls explicit hardening mechanisms:
* A malicious local user who can already control the root users command line, shell environment, config file, `PATH`, SSH config, working directory, or invoked binaries.
* A root user loading an `enroll.ini` file whose contents intentionally request dangerous behavior.
* A root user passing `--dangerous` and then observing that Enroll may collect sensitive information.
* A root user passing `--assume-safe-path` and then observing that Enroll does not prompt about `PATH` safety.
* A root user enforcing a malicious or manually edited harvest bundle with `diff --enforce`.
* A user applying generated Ansible, Puppet, or Salt manifests from an untrusted harvest.
* A user configuring a webhook, email target, SSH proxy command, SOPS binary, package manager, or configuration-management tool that they do not trust.
* A compromised system where an attacker already controls root-owned files, roots shell, roots configuration, or the privileged tools Enroll invokes.
* Reports that amount to “if root runs this tool with malicious options, root can make the system do dangerous things.”
* Enroll harvesting a file that has a *commented out* secret even with `--dangerous` disabled (it ignores comments so as to not be totally useless when it comes to harvesting config files). It is still the responsibility of the user to use `--sops` or appropriate at-rest encryption if in the slightest doubt about what might get harvested.
Enroll is a tool for administrators, not a sandbox for hostile local users. It cannot make unsafe local trust decisions safe if the operators own execution environment is already attacker-controlled.
## Trusted harvests and enforcement
Harvest bundles should be treated as sensitive and trusted administrative artifacts.
A harvest may contain hostnames, usernames, package lists, service state, filesystem metadata, configuration files, firewall snapshots, container image references, Flatpak/Snap state, and other operational details. In `--dangerous` mode it may contain substantially more sensitive material.
Before running `manifest`, `diff`, or especially `diff --enforce`, the operator should be confident that the harvest bundle came from a trusted source and has not been tampered with.
Enroll validates harvest structure and artifact safety. Validation can detect many unsafe filesystem constructs, such as path traversal, missing artifacts, symlinks, hardlinks, and schema mismatches. Validation does not and cannot prove that the desired state represented by a harvest is safe to apply.
## Local compromise
Enroll includes hardening against some local filesystem attack patterns because it is often run with high privileges. For example, it tries to avoid symlink races, unsafe output directories, path traversal, and accidental secret capture.
However, local compromise cannot be ruled out completely for a privileged CLI tool. If an attacker can influence the administrators shell, environment, config file, binaries, SSH configuration, SOPS binary, configuration-management tools, or harvest inputs, they may be able to influence Enrolls behavior.
Such scenarios are treated as local compromise or operator trust failures, not as vulnerabilities in Enroll by themselves.
## Security report guidance
Useful vulnerability reports include issues where Enroll behaves unsafely despite the documented trust model. Examples include:
* Enroll captures a clearly sensitive default-denied file without `--dangerous`.
* Enroll follows a symlink or hardlink in a way that causes privileged file disclosure or overwrite.
* Enroll extracts a tar member outside the intended harvest directory.
* Enroll accepts a malicious harvest artifact that escapes the artifact root.
* Enroll generates an Ansible, Puppet, or Salt manifest where ordinary harvested data can cause command injection.
* Enroll writes root-run output into an unsafe attacker-controlled path despite its safety checks.
* Enroll silently ignores a failed safety check and proceeds anyway.
* Enroll accepts an unknown SSH host key unexpectedly.
* Enroll exposes secrets in logs, errors, reports, or generated output when not explicitly requested by the operator.
Less useful reports, and normally out of scope, include:
* “Root can configure Enroll to collect sensitive files.”
* “Root can pass `--dangerous` and collect dangerous data.”
* “Root can pass `--assume-safe-path` and bypass the root `PATH` warning.”
* “Root can point Enroll at a malicious config file.”
* “Root can enforce a malicious harvest bundle.”
* “A malicious local user can compromise Enroll after already controlling roots environment or binaries.”
Reports about concrete bypasses of Enroll's hardening are welcomed (see https://enroll.sh/security.html), but the project does not treat intentional administrator-controlled execution as a vulnerability.

2
debian/changelog vendored
View file

@ -3,7 +3,7 @@ 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
-- Miguel Jacq <mig@mig5.net> Thu, 14 May 2026 15:00:00 +1000
enroll (0.5.0) unstable; urgency=medium

View file

@ -1,8 +1,48 @@
from __future__ import annotations
import configparser
import os
from dataclasses import dataclass
from typing import Dict, List, Set, Tuple
import re
import shutil
import subprocess # nosec
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple
@dataclass
class FlatpakInstall:
name: str
method: str
remote: Optional[str] = None
branch: Optional[str] = None
arch: Optional[str] = None
kind: Optional[str] = None
ref: Optional[str] = None
user: Optional[str] = None
home: Optional[str] = None
source: str = "filesystem"
@dataclass
class FlatpakRemote:
name: str
method: str
url: str
user: Optional[str] = None
home: Optional[str] = None
source: str = "filesystem"
@dataclass
class SnapInstall:
name: str
channel: Optional[str] = None
revision: Optional[int] = None
classic: bool = False
devmode: bool = False
dangerous: bool = False
notes: List[str] = field(default_factory=list)
source: str = "snap-list"
@dataclass
@ -16,6 +56,7 @@ class UserRecord:
primary_group: str
supplementary_groups: List[str]
ssh_files: List[str]
flatpaks: List[FlatpakInstall] = field(default_factory=list)
def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]:
@ -105,7 +146,12 @@ def is_human_user(uid: int, shell: str, uid_min: int) -> bool:
def find_user_ssh_files(home: str) -> List[str]:
sshdir = os.path.join(home, ".ssh")
out: List[str] = []
if not os.path.isdir(sshdir):
# ``os.path.isdir`` follows symlinks, so a user who replaces ``~/.ssh``
# with a link to a sensitive directory (e.g. /etc/ssl/private) could
# otherwise have a regular file inside it harvested through the symlinked
# parent. Refuse a symlinked .ssh outright; capture_file() applies the
# same parent-symlink protection at copy time as defense in depth.
if os.path.islink(sshdir) or not os.path.isdir(sshdir):
return out
ak = os.path.join(sshdir, "authorized_keys")
@ -115,6 +161,612 @@ def find_user_ssh_files(home: str) -> List[str]:
return sorted(set(out))
def _read_first_existing_text(paths: List[str]) -> Optional[str]:
for path in paths:
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
value = f.read().strip()
if value:
return value
except OSError:
continue
return None
def _parse_flatpak_ref(
ref: str,
) -> Tuple[Optional[str], str, Optional[str], Optional[str]]:
"""Return (kind, name, arch, branch) for a Flatpak ref.
refs look like app/org.example.App/x86_64/stable or
runtime/org.example.Platform/x86_64/23.08. If the value is already just an
application/runtime ID, keep it as the name and leave the other fields empty.
"""
parts = [p for p in (ref or "").strip().split("/") if p]
if len(parts) >= 4 and parts[0] in {"app", "runtime"}:
return parts[0], parts[1], parts[2], parts[3]
return None, (ref or "").strip(), None, None
def _parse_plain_flatpak_list_output(
output: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
"""Parse default `flatpak list` table output.
Example:
Name Application ID Version Branch Installation
OnionShare org.onionshare.OnionShare 2.6.4 stable system
"""
out: List[FlatpakInstall] = []
seen: Set[
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
] = set()
id_re = re.compile(r"\b(?:[A-Za-z0-9_-]+\.)+[A-Za-z0-9_-]+\b")
for line in output.splitlines():
line = line.rstrip()
if not line.strip():
continue
if "Application ID" in line and "Installation" in line:
continue
match = id_re.search(line)
if not match:
continue
name = match.group(0)
tail = line[match.end() :].split()
installation = tail[-1] if tail else ""
if installation in {"system", "user"} and installation != method:
continue
branch = None
if len(tail) >= 2 and tail[-1] in {"system", "user"}:
branch = tail[-2]
elif tail:
branch = tail[-1]
key = (name, None, branch, None, None)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=name,
method=method,
remote=None,
branch=branch,
arch=None,
kind=None,
ref=None,
user=user,
home=home,
source="flatpak-list",
)
)
return sorted(out, key=lambda f: (f.name, f.branch or ""))
def _parse_flatpak_list_output(
output: str,
*,
method: str,
columns: Optional[Tuple[str, ...]] = None,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
"""Parse Flatpak list output.
If columns is None, parse the default table. Otherwise columns names must
match the order passed to `flatpak list --columns=...`.
"""
if columns is None:
return _parse_plain_flatpak_list_output(
output, method=method, user=user, home=home
)
out: List[FlatpakInstall] = []
seen: Set[
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
] = set()
for line in output.splitlines():
line = line.strip()
if not line:
continue
lower = line.lower()
if lower.startswith("ref") or lower.startswith("application id"):
continue
parts = line.split("\t")
if len(parts) < len(columns):
parts = line.split()
if not parts:
continue
fields = {
name: parts[idx].strip()
for idx, name in enumerate(columns)
if idx < len(parts)
}
ref = fields.get("ref") or fields.get("application") or ""
kind, name, ref_arch, ref_branch = _parse_flatpak_ref(ref)
if not name:
continue
remote = fields.get("origin") or None
branch = fields.get("branch") or ref_branch
arch = fields.get("arch") or ref_arch
if remote in {"", "-"}:
remote = None
if branch in {"", "-"}:
branch = None
if arch in {"", "-"}:
arch = None
key = (name, remote, branch, arch, kind)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=name,
method=method,
remote=remote,
branch=branch,
arch=arch,
kind=kind,
ref=ref if "/" in ref else None,
user=user,
home=home,
source="flatpak-list",
)
)
return sorted(
out,
key=lambda f: (
f.kind or "",
f.name,
f.remote or "",
f.branch or "",
f.arch or "",
),
)
_KNOWN_FLATPAK_LIST_COLUMNS = {
"name",
"description",
"application",
"version",
"branch",
"arch",
"origin",
"installation",
"ref",
"active",
"latest",
"size",
"options",
}
def _parse_flatpak_columns_help(output: str) -> Set[str]:
"""Parse `flatpak list --columns=help` output into supported fields."""
supported: Set[str] = set()
for line in output.splitlines():
# Help output varies a bit between Flatpak versions. Treat any known
# token as a supported field, whether it appears alone or in a
# description table.
for token in re.findall(r"[A-Za-z_][A-Za-z0-9_-]*", line.lower()):
if token in _KNOWN_FLATPAK_LIST_COLUMNS:
supported.add(token)
return supported
def _run_flatpak_columns_help() -> Optional[Set[str]]:
if shutil.which("flatpak") is None:
return None
try:
proc = subprocess.run( # nosec
["flatpak", "list", "--columns=help"],
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception:
return None
if proc.returncode != 0:
return None
supported = _parse_flatpak_columns_help(proc.stdout or "")
return supported or None
def _flatpak_list_attempts(
scope: str, supported: Optional[Set[str]]
) -> List[Tuple[List[str], Optional[Tuple[str, ...]]]]:
def supported_columns(*wanted: str) -> Optional[Tuple[str, ...]]:
if supported is not None and not set(wanted).issubset(supported):
return None
return tuple(wanted)
column_sets: List[Tuple[str, ...]] = []
for wanted in (
("application", "origin", "branch", "arch"),
("application", "branch", "arch"),
("application", "branch"),
("application",),
("ref", "origin", "branch", "arch"),
("ref", "branch", "arch"),
("ref", "branch"),
("ref",),
):
cols = supported_columns(*wanted)
if cols is not None and cols not in column_sets:
column_sets.append(cols)
attempts: List[Tuple[List[str], Optional[Tuple[str, ...]]]] = [
(
["flatpak", "list", scope, "--columns=" + ",".join(cols)],
cols,
)
for cols in column_sets
]
attempts.append((["flatpak", "list", scope], None))
return attempts
def _run_flatpak_list(method: str) -> Optional[Tuple[str, Optional[Tuple[str, ...]]]]:
if shutil.which("flatpak") is None:
return None
scope = "--system" if method == "system" else "--user"
supported = _run_flatpak_columns_help()
for args, columns in _flatpak_list_attempts(scope, supported):
try:
proc = subprocess.run( # nosec
args,
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception: # nosec B112
continue
if proc.returncode == 0:
return proc.stdout or "", columns
return None
def _flatpak_remote_from_ref(
flatpak_root: str, app_id: str, arch: str, branch: str, remote_names: List[str]
) -> Optional[str]:
for remote_name in remote_names:
ref = os.path.join(
flatpak_root,
"repo",
"refs",
"remotes",
remote_name,
"app",
app_id,
arch,
branch,
)
if os.path.exists(ref):
return remote_name
return None
def _parse_flatpak_deploy_origin(branch_dir: str) -> Optional[str]:
active_dir = os.path.join(branch_dir, "active")
candidates = [
os.path.join(active_dir, "origin"),
os.path.join(active_dir, "metadata"),
]
origin = _read_first_existing_text([candidates[0]])
if origin:
return origin
metadata = candidates[1]
if os.path.isfile(metadata):
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(metadata, encoding="utf-8")
except Exception:
return None
for section in ("Application", "Runtime"):
if parser.has_option(section, "origin"):
value = parser.get(section, "origin", fallback="").strip()
if value:
return value
return None
def _find_flatpaks_in_root(
flatpak_root: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakInstall]:
apps_dir = os.path.join(flatpak_root, "app")
if not os.path.isdir(apps_dir):
return []
remote_names = [
r.name
for r in find_flatpak_remotes(flatpak_root, method=method, user=user, home=home)
]
out: List[FlatpakInstall] = []
try:
app_ids = sorted(os.listdir(apps_dir))
except OSError:
return []
seen: Set[Tuple[str, Optional[str], Optional[str], Optional[str]]] = set()
for app_id in app_ids:
app_path = os.path.join(apps_dir, app_id)
if not os.path.isdir(app_path):
continue
try:
arches = sorted(os.listdir(app_path))
except OSError:
continue
for arch in arches:
arch_path = os.path.join(app_path, arch)
if not os.path.isdir(arch_path):
continue
try:
branches = sorted(os.listdir(arch_path))
except OSError:
continue
for branch in branches:
branch_path = os.path.join(arch_path, branch)
if not os.path.isdir(branch_path):
continue
active_dir = os.path.join(branch_path, "active")
if not os.path.exists(active_dir):
continue
remote = _parse_flatpak_deploy_origin(branch_path)
if not remote:
remote = _flatpak_remote_from_ref(
flatpak_root, app_id, arch, branch, remote_names
)
key = (app_id, remote, branch, arch)
if key in seen:
continue
seen.add(key)
out.append(
FlatpakInstall(
name=app_id,
method=method,
remote=remote,
branch=branch or None,
arch=arch or None,
kind="app",
ref=f"app/{app_id}/{arch}/{branch}",
user=user,
home=home,
)
)
return sorted(
out, key=lambda f: (f.name, f.remote or "", f.branch or "", f.arch or "")
)
def find_flatpak_remotes(
flatpak_root: str,
*,
method: str,
user: Optional[str] = None,
home: Optional[str] = None,
) -> List[FlatpakRemote]:
"""Return configured Flatpak remotes for a Flatpak installation root.
Flatpak stores remotes in the OSTree repo config. This gives us the remote
names and repository URLs. It does not reliably preserve the original
.flatpakref/.flatpakrepo URL that was used during installation.
"""
config_path = os.path.join(flatpak_root, "repo", "config")
if not os.path.isfile(config_path):
return []
parser = configparser.ConfigParser(interpolation=None, strict=False)
try:
parser.read(config_path, encoding="utf-8")
except Exception:
return []
out: List[FlatpakRemote] = []
for section in parser.sections():
match = re.fullmatch(r'remote\s+"(.+)"', section)
if not match:
continue
name = match.group(1).strip()
url = parser.get(section, "url", fallback="").strip()
if not name or not url:
continue
out.append(
FlatpakRemote(
name=name,
method=method,
url=url,
user=user,
home=home,
)
)
return sorted(out, key=lambda r: (r.method, r.user or "", r.name))
def find_user_flatpaks(home: str, user: Optional[str] = None) -> List[FlatpakInstall]:
"""Return per-user Flatpak applications installed under a home directory."""
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
return _find_flatpaks_in_root(flatpak_root, method="user", user=user, home=home)
def find_user_flatpak_remotes(
home: str, user: Optional[str] = None
) -> List[FlatpakRemote]:
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
return find_flatpak_remotes(flatpak_root, method="user", user=user, home=home)
def find_system_flatpaks() -> List[FlatpakInstall]:
"""Return Flatpak refs installed system-wide.
Prefer `flatpak list --system` because it is Flatpak's own view of
installed refs and includes layouts the filesystem scanner might miss.
Fall back to the on-disk app deployment tree when the command is
unavailable or produces unparsable output.
"""
listing = _run_flatpak_list("system")
if listing is not None:
output, columns = listing
parsed = _parse_flatpak_list_output(output, method="system", columns=columns)
if parsed or not output.strip():
return parsed
return _find_flatpaks_in_root("/var/lib/flatpak", method="system")
def find_system_flatpak_remotes() -> List[FlatpakRemote]:
return find_flatpak_remotes("/var/lib/flatpak", method="system")
def _parse_snap_notes(notes: str) -> List[str]:
if not notes or notes == "-":
return []
cleaned = notes.replace(",", " ").replace(";", " ")
return sorted(
{n.strip().lower() for n in cleaned.split() if n.strip() and n.strip() != "-"}
)
def _parse_snap_list_output(output: str) -> List[SnapInstall]:
out: List[SnapInstall] = []
for idx, line in enumerate(output.splitlines()):
line = line.strip()
if not line:
continue
if idx == 0 and line.lower().startswith("name"):
continue
parts = line.split(maxsplit=5)
if len(parts) < 5:
continue
name = parts[0]
revision: Optional[int]
try:
revision = int(parts[2])
except ValueError:
revision = None
tracking = parts[3]
channel = None if tracking in {"-", ""} else tracking
notes = _parse_snap_notes(parts[5] if len(parts) > 5 else "")
out.append(
SnapInstall(
name=name,
channel=channel,
revision=revision,
classic="classic" in notes,
devmode="devmode" in notes,
dangerous="dangerous" in notes,
notes=notes,
source="snap-list",
)
)
return sorted(out, key=lambda s: s.name)
def _run_snap_list() -> Optional[str]:
if shutil.which("snap") is None:
return None
try:
proc = subprocess.run( # nosec
["snap", "list"],
shell=False,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10,
)
except Exception:
return None
if proc.returncode != 0:
return None
return proc.stdout or ""
def _find_system_snaps_from_filesystem() -> List[SnapInstall]:
snapd_snaps = "/var/lib/snapd/snaps"
if not os.path.isdir(snapd_snaps):
return []
current_revisions: Dict[str, int] = {}
snap_mounts = "/snap"
if os.path.isdir(snap_mounts):
try:
mount_names = os.listdir(snap_mounts)
except OSError:
mount_names = []
for name in mount_names:
current = os.path.join(snap_mounts, name, "current")
try:
target = os.readlink(current)
except OSError:
continue
try:
current_revisions[name] = int(os.path.basename(target.rstrip("/")))
except ValueError:
continue
candidates: Dict[str, List[int]] = {}
try:
entries = os.listdir(snapd_snaps)
except OSError:
return []
for entry in entries:
if not entry.endswith(".snap") or "_" not in entry:
continue
name, rev_text = entry[:-5].rsplit("_", 1)
try:
revision = int(rev_text)
except ValueError:
continue
candidates.setdefault(name, []).append(revision)
out: List[SnapInstall] = []
for name, revisions in candidates.items():
revision = current_revisions.get(name)
if revision is None:
revision = max(revisions)
out.append(SnapInstall(name=name, revision=revision, source="filesystem"))
return sorted(out, key=lambda s: s.name)
def find_system_snaps() -> List[SnapInstall]:
"""Return system-wide snap packages.
Prefer `snap list` because it exposes channel tracking and confinement notes.
Fall back to snapd's on-disk snap filenames when the command is unavailable.
"""
output = _run_snap_list()
if output is not None:
parsed = _parse_snap_list_output(output)
if parsed:
return parsed
return _find_system_snaps_from_filesystem()
def collect_non_system_users() -> List[UserRecord]:
defs = parse_login_defs()
uid_min = defs.get("UID_MIN", 1000)
@ -139,6 +791,10 @@ def collect_non_system_users() -> List[UserRecord]:
ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else []
flatpaks: List[FlatpakInstall] = []
if home and home.startswith("/"):
flatpaks = find_user_flatpaks(home, user=name)
users.append(
UserRecord(
name=name,
@ -150,6 +806,7 @@ def collect_non_system_users() -> List[UserRecord]:
primary_group=primary_group,
supplementary_groups=supp,
ssh_files=ssh_files,
flatpaks=flatpaks,
)
)

2442
enroll/ansible.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from .harvest_safety import OutputSafetyError, ensure_private_dir
def _safe_component(s: str) -> str:
s = s.strip()
@ -44,16 +46,17 @@ class HarvestCache:
def _ensure_dir_secure(path: Path) -> None:
"""Create a directory with restrictive permissions; refuse symlinks."""
# Refuse a symlink at the leaf.
if path.exists() and path.is_symlink():
raise RuntimeError(f"Refusing to use symlink path: {path}")
path.mkdir(parents=True, exist_ok=True, mode=0o700)
"""Create a private cache directory with output-path safety checks.
Cache roots are persistent, so existing directories are allowed, but they
still need the same symlink-component and root-parent trust checks as
plaintext harvest/manifest output paths.
"""
try:
os.chmod(path, 0o700)
except OSError:
# Best-effort; on some FS types chmod may fail.
pass
ensure_private_dir(path, label="cache directory")
except OutputSafetyError as e:
raise RuntimeError(str(e)) from e
def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache:

343
enroll/capture.py Normal file
View file

@ -0,0 +1,343 @@
from __future__ import annotations
import os
import errno
import stat
from typing import List, Optional, Set
from .fsutil import open_no_follow_path, stat_triplet, stat_triplet_from_stat
from .harvest_types import ExcludedFile, ManagedFile, ManagedLink
from .ignore import IgnorePolicy
from .pathfilter import PathFilter
def files_differ(a: str, b: str, *, max_bytes: int = 2_000_000) -> bool:
"""Return True if file ``a`` differs from file ``b``.
Best-effort and conservative: unreadable/missing baselines, non-regular
files, and unexpectedly large files are treated as different so callers err
on the side of preserving user state.
"""
try:
st_a = os.stat(a, follow_symlinks=True)
except OSError:
return True
if not stat.S_ISREG(st_a.st_mode):
return True
try:
st_b = os.stat(b, follow_symlinks=True)
except OSError:
return True
if not stat.S_ISREG(st_b.st_mode):
return True
if st_a.st_size != st_b.st_size:
return True
if st_a.st_size > max_bytes:
return True
try:
with open(a, "rb") as fa, open(b, "rb") as fb:
while True:
ca = fa.read(1024 * 64)
cb = fb.read(1024 * 64)
if ca != cb:
return True
if not ca:
return False
except OSError:
return True
def _open_no_follow_write(path: str, mode: int = 0o600) -> int:
return open_no_follow_path(path, write=True, mode=mode)
def write_bytes_into_bundle(
bundle_dir: str, role_name: str, src_rel: str, data: bytes
) -> None:
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
fd = -1
try:
fd = _open_no_follow_write(dst, 0o600)
with os.fdopen(fd, "wb") as f:
fd = -1
f.write(data)
try:
os.chmod(dst, 0o600)
except OSError:
pass
finally:
if fd >= 0:
os.close(fd)
def copy_into_bundle(
bundle_dir: str, role_name: str, abs_path: str, src_rel: str
) -> None:
"""Legacy safe copy helper used by tests and non-IgnorePolicy callers.
Real harvests using IgnorePolicy copy the exact bytes read from the safely
opened source file in capture_file(). This helper still refuses source
symlinks at copy time and refuses destination symlink overwrites.
"""
fd = -1
try:
try:
fd = open_no_follow_path(abs_path)
except OSError as e:
if e.errno in {errno.ELOOP, errno.ENOTDIR}:
raise OSError("refusing to copy symlink source") from e
raise
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
raise OSError("refusing to copy non-regular source")
chunks: list[bytes] = []
while True:
chunk = os.read(fd, 1024 * 1024)
if not chunk:
break
chunks.append(chunk)
write_bytes_into_bundle(bundle_dir, role_name, src_rel, b"".join(chunks))
finally:
if fd >= 0:
os.close(fd)
def capture_file(
*,
bundle_dir: str,
role_name: str,
abs_path: str,
reason: str,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedFile],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]] = None,
seen_global: Optional[Set[str]] = None,
metadata: Optional[tuple[str, str, str]] = None,
) -> bool:
"""Try to capture a single file into the bundle.
Returns True if the file was copied and appended to ``managed_out``.
``seen_role`` de-duplicates within a role; ``seen_global`` de-duplicates
across harvest stages so multiple generated roles do not manage one path.
"""
if seen_global is not None and abs_path in seen_global:
return False
if seen_role is not None and abs_path in seen_role:
return False
def _mark_seen() -> None:
if seen_role is not None:
seen_role.add(abs_path)
if seen_global is not None:
seen_global.add(abs_path)
if path_filter.is_excluded(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
_mark_seen()
return False
inspection = None
inspect_file = getattr(policy, "inspect_file", None)
if callable(inspect_file):
inspected = inspect_file(abs_path)
if isinstance(inspected, tuple) and len(inspected) == 2:
deny, inspection = inspected
else:
# Some tests and third-party callers use MagicMock/spec policies that
# expose inspect_file but have not configured it. Fall back to the
# legacy deny_reason/copy path for those non-real policies.
deny = policy.deny_reason(abs_path)
else:
deny = policy.deny_reason(abs_path)
if deny:
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
_mark_seen()
return False
try:
if metadata is not None:
owner, group, mode = metadata
elif inspection is not None:
owner, group, mode = stat_triplet_from_stat(inspection.stat_result)
else:
owner, group, mode = stat_triplet(abs_path)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
src_rel = abs_path.lstrip("/")
try:
if inspection is not None:
write_bytes_into_bundle(bundle_dir, role_name, src_rel, inspection.data)
else:
copy_into_bundle(bundle_dir, role_name, abs_path, src_rel)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
managed_out.append(
ManagedFile(
path=abs_path,
src_rel=src_rel,
owner=owner,
group=group,
mode=mode,
reason=reason,
)
)
_mark_seen()
return True
USER_SHELL_DOTFILES_WITH_SKEL_BASELINE = [
(".bashrc", "user_shell_rc"),
(".profile", "user_profile"),
(".bash_logout", "user_shell_logout"),
]
USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE = [
(".bash_aliases", "user_shell_aliases"),
]
def capture_user_shell_dotfiles(
*,
bundle_dir: str,
role_name: str,
home: str,
skel_dir: str,
enabled: bool,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedFile],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]],
seen_global: Optional[Set[str]],
) -> int:
"""Capture selected per-user shell dotfiles when explicitly enabled."""
if not enabled:
return 0
home = (home or "").rstrip("/")
if not home or not home.startswith("/"):
return 0
captured = 0
max_compare_bytes = int(getattr(policy, "max_file_bytes", 256_000))
for rel, reason in USER_SHELL_DOTFILES_WITH_SKEL_BASELINE:
upath = os.path.join(home, rel)
if not os.path.isfile(upath) or os.path.islink(upath):
continue
skel_path = os.path.join(skel_dir, rel)
if not files_differ(upath, skel_path, max_bytes=max_compare_bytes):
continue
if capture_file(
bundle_dir=bundle_dir,
role_name=role_name,
abs_path=upath,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=managed_out,
excluded_out=excluded_out,
seen_role=seen_role,
seen_global=seen_global,
):
captured += 1
for rel, reason in USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE:
upath = os.path.join(home, rel)
if not os.path.isfile(upath) or os.path.islink(upath):
continue
if capture_file(
bundle_dir=bundle_dir,
role_name=role_name,
abs_path=upath,
reason=reason,
policy=policy,
path_filter=path_filter,
managed_out=managed_out,
excluded_out=excluded_out,
seen_role=seen_role,
seen_global=seen_global,
):
captured += 1
return captured
def capture_link(
*,
role_name: str,
abs_path: str,
reason: str,
policy: IgnorePolicy,
path_filter: PathFilter,
managed_out: List[ManagedLink],
excluded_out: List[ExcludedFile],
seen_role: Optional[Set[str]] = None,
seen_global: Optional[Set[str]] = None,
) -> bool:
"""Record a symlink for later materialisation by the manifest renderer."""
if seen_global is not None and abs_path in seen_global:
return False
if seen_role is not None and abs_path in seen_role:
return False
def _mark_seen() -> None:
if seen_role is not None:
seen_role.add(abs_path)
if seen_global is not None:
seen_global.add(abs_path)
if path_filter.is_excluded(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
_mark_seen()
return False
deny_link = getattr(policy, "deny_reason_link", None)
if callable(deny_link):
deny = deny_link(abs_path)
else:
deny = policy.deny_reason(abs_path)
if deny in ("not_regular_file", "not_file", "not_regular"):
deny = None
if deny:
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
_mark_seen()
return False
if not os.path.islink(abs_path):
excluded_out.append(ExcludedFile(path=abs_path, reason="not_symlink"))
_mark_seen()
return False
try:
target = os.readlink(abs_path)
except OSError:
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
_mark_seen()
return False
managed_out.append(ManagedLink(path=abs_path, target=target, reason=reason))
_mark_seen()
return True

View file

@ -4,6 +4,7 @@ import argparse
import configparser
import json
import os
import stat
import sys
import tarfile
import tempfile
@ -21,6 +22,7 @@ from .diff import (
)
from .explain import explain_state
from .harvest import harvest
from .harvest_safety import ensure_safe_output_parent, write_text_output_file
from .manifest import manifest
from .remote import (
remote_harvest,
@ -39,8 +41,10 @@ def _discover_config_path(argv: list[str]) -> Optional[Path]:
1) --no-config disables loading.
2) --config PATH (or -c PATH)
3) $ENROLL_CONFIG
4) ./enroll.ini, ./.enroll.ini
5) $XDG_CONFIG_HOME/enroll/enroll.ini (or ~/.config/enroll/enroll.ini)
4) $XDG_CONFIG_HOME/enroll/enroll.ini (or ~/.config/enroll/enroll.ini)
Current-directory config files are deliberately not auto-loaded; use
--config ./enroll.ini if that behaviour is desired.
The config file is optional; if no file is found, returns None.
"""
@ -66,12 +70,6 @@ def _discover_config_path(argv: list[str]) -> Optional[Path]:
if envp:
return Path(envp).expanduser()
cwd = Path.cwd()
for name in ("enroll.ini", ".enroll.ini"):
cp = cwd / name
if cp.exists() and cp.is_file():
return cp
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
base = Path(xdg).expanduser()
@ -115,6 +113,15 @@ def _action_lookup(p: argparse.ArgumentParser) -> dict[str, argparse.Action]:
return m
def _warn_dangerous_harvest(*, sops_enabled: bool) -> None:
if not sops_enabled:
print(
"warning: --dangerous is enabled. The harvest may contain sensitive "
"files, credentials, private keys, tokens, or application secrets. "
"Consider using --sops to encrypt the harvest at rest."
)
def _choose_flag(a: argparse.Action) -> Optional[str]:
# Prefer a long flag if available (e.g. --dangerous over -d)
for s in getattr(a, "option_strings", []) or []:
@ -138,6 +145,149 @@ def _split_list_value(v: str) -> list[str]:
return [raw] if raw else []
def _root_trust_reason(path: Path, *, final: bool) -> Optional[str]:
"""Return why a PATH directory/ancestor is unsafe for root execution."""
running_as_root = _is_effective_root()
if not final and not running_as_root:
return None
try:
st = os.stat(path)
except OSError:
return None
if not stat.S_ISDIR(st.st_mode):
return None
subject = "directory" if final else "parent directory"
if running_as_root and st.st_uid != 0:
return f"{subject} is not owned by root"
writable_by_group = bool(st.st_mode & stat.S_IWGRP)
writable_by_other = bool(st.st_mode & stat.S_IWOTH)
sticky = bool(st.st_mode & stat.S_ISVTX)
# A sticky shared ancestor such as /tmp may contain a root-owned PATH
# directory safely enough for this check, but the PATH entry itself must
# never be writable by group/other because that permits command planting.
if final or not sticky:
if writable_by_other:
return f"{subject} is world-writable"
if writable_by_group:
return f"{subject} is group-writable"
return None
def _root_parent_trust_reason(path: Path) -> Optional[str]:
"""Check original and resolved PATH ancestors for root trust."""
if not _is_effective_root():
return None
candidates: list[Path] = []
candidates.extend(reversed(path.parents))
try:
resolved = path.resolve(strict=True)
except OSError:
resolved = None
if resolved is not None and resolved != path:
candidates.extend(reversed(resolved.parents))
seen: set[str] = set()
for parent in candidates:
key = str(parent)
if key in seen:
continue
seen.add(key)
reason = _root_trust_reason(parent, final=False)
if reason:
return f"{reason}: {parent}"
return None
def _path_entry_is_unsafe(entry: str) -> Optional[str]:
"""Return a human-readable reason if a PATH entry is unsafe for root.
Empty PATH entries and relative entries resolve via the current working
directory, which is equivalent to trusting whatever directory the operator
happens to be in. Existing group/world-writable directories are also risky
when Enroll is run as root because Enroll deliberately invokes host tools
from PATH while harvesting and enforcing state. When running as root, an
existing PATH directory must also be root-owned; a non-root-owned 0755
directory is still attacker-controlled by its owner.
"""
if entry == "":
return "empty PATH entry resolves to the current directory"
if entry == ".":
return "'.' resolves to the current directory"
if not os.path.isabs(entry):
return "relative PATH entry resolves from the current directory"
p = Path(entry)
parent_reason = _root_parent_trust_reason(p)
if parent_reason:
return parent_reason
try:
st = os.stat(entry)
except OSError:
return None
if not stat.S_ISDIR(st.st_mode):
return None
final_reason = _root_trust_reason(p, final=True)
if final_reason:
return final_reason
return None
def _unsafe_root_path_reasons(path_value: Optional[str] = None) -> list[str]:
"""Return unsafe PATH entries that should make root execution interactive."""
raw = os.environ.get("PATH", "") if path_value is None else str(path_value)
out: list[str] = []
for entry in raw.split(os.pathsep):
reason = _path_entry_is_unsafe(entry)
if reason:
label = entry if entry else "<empty>"
out.append(f"{label}: {reason}")
return out
def _is_effective_root() -> bool:
geteuid = getattr(os, "geteuid", None)
return bool(geteuid is not None and geteuid() == 0)
def _confirm_root_path_safety(*, force: bool = False) -> None:
"""Prompt before running as root with a PATH that trusts writable entries."""
if force or not _is_effective_root():
return
reasons = _unsafe_root_path_reasons()
if not reasons:
return
details = "\n".join(f" - {r}" for r in reasons)
msg = (
"warning: enroll is running as root and PATH contains entries that "
"could allow an untrusted binary to be executed:\n"
f"{details}\n"
)
if not sys.stdin.isatty():
raise SystemExit(
msg + "error: refusing to continue non-interactively. Re-run with "
"--assume-safe-path if you intentionally trust this PATH."
)
print(msg, file=sys.stderr, end="")
answer = input("Are you sure you want to continue? [y/N] ")
if answer.strip().lower() not in {"y", "yes"}:
raise SystemExit("aborted: unsafe root PATH was not confirmed")
def _section_to_argv(
p: argparse.ArgumentParser, cfg: configparser.ConfigParser, section: str
) -> list[str]:
@ -279,7 +429,7 @@ def _resolve_sops_out_file(out: Optional[str], *, hint: str) -> Path:
def _tar_dir_to(path_dir: Path, tar_path: Path) -> None:
tar_path.parent.mkdir(parents=True, exist_ok=True)
ensure_safe_output_parent(tar_path, label="harvest tar output")
with tarfile.open(tar_path, mode="w:gz") as tf:
# Keep a stable on-disk layout when extracted: state.json + artifacts/
tf.add(str(path_dir), arcname=".")
@ -289,7 +439,7 @@ def _encrypt_harvest_dir_to_sops(
bundle_dir: Path, out_file: Path, fps: list[str]
) -> Path:
out_file = Path(out_file)
out_file.parent.mkdir(parents=True, exist_ok=True)
ensure_safe_output_parent(out_file, label="encrypted harvest output")
# Create the tarball alongside the output file (keeps filesystem permissions/locality sane).
fd, tmp_tgz = tempfile.mkstemp(
@ -308,9 +458,23 @@ def _encrypt_harvest_dir_to_sops(
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--target",
choices=["ansible", "puppet", "salt"],
default="ansible",
help="Manifest target to generate (default: ansible).",
)
p.add_argument(
"--fqdn",
help="Host FQDN/name for site-mode output (creates inventory/, inventory/host_vars/, playbooks/).",
help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars, Puppet Hiera, or Salt pillar).",
)
p.add_argument(
"--no-common-roles",
action="store_true",
help=(
"Do not group package and systemd-unit roles into common section/group roles. "
"This preserves one generated role per package/unit. --fqdn implies this."
),
)
g = p.add_mutually_exclusive_group()
g.add_argument(
@ -338,8 +502,8 @@ def _add_config_args(p: argparse.ArgumentParser) -> None:
"-c",
"--config",
help=(
"Path to an INI config file for default options. If omitted, enroll will look for "
"./enroll.ini, ./.enroll.ini, or ~/.config/enroll/enroll.ini (or $XDG_CONFIG_HOME/enroll/enroll.ini)."
"Path to an INI config file for default options. If omitted, enroll will look for a path defined by the "
"ENROLL_CONFIG environment variable , ~/.config/enroll/enroll.ini (or $XDG_CONFIG_HOME/enroll/enroll.ini)."
),
)
p.add_argument(
@ -349,6 +513,21 @@ def _add_config_args(p: argparse.ArgumentParser) -> None:
)
def _add_path_safety_args(
p: argparse.ArgumentParser, *, default: object = False
) -> None:
p.add_argument(
"--assume-safe-path",
action="store_true",
default=default,
help=(
"When running as root, continue without confirmation even if PATH "
"contains '.', an empty/relative entry, or a group/world-writable "
"directory. Intended for trusted non-interactive automation."
),
)
def _add_remote_args(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--remote-host",
@ -422,10 +601,12 @@ def main() -> None:
version=f"{get_enroll_version()}",
)
_add_config_args(ap)
_add_path_safety_args(ap)
sub = ap.add_subparsers(dest="cmd", required=True)
h = sub.add_parser("harvest", help="Harvest service/package/config state")
_add_config_args(h)
_add_path_safety_args(h, default=argparse.SUPPRESS)
_add_remote_args(h)
h.add_argument(
"--out",
@ -459,7 +640,6 @@ def main() -> None:
"Excludes apply to all harvesting, including defaults."
),
)
h.add_argument(
"--sops",
nargs="+",
@ -475,8 +655,11 @@ def main() -> None:
help="Don't use sudo on the remote host (when using --remote options). This may result in a limited harvest due to permission restrictions.",
)
m = sub.add_parser("manifest", help="Render Ansible roles from a harvest")
m = sub.add_parser(
"manifest", help="Render configuration-management code from a harvest"
)
_add_config_args(m)
_add_path_safety_args(m, default=argparse.SUPPRESS)
m.add_argument(
"--harvest",
required=True,
@ -507,9 +690,11 @@ def main() -> None:
_add_common_manifest_args(m)
s = sub.add_parser(
"single-shot", help="Harvest state, then manifest Ansible code, in one shot"
"single-shot",
help="Harvest state, then manifest configuration-management code, in one shot",
)
_add_config_args(s)
_add_path_safety_args(s, default=argparse.SUPPRESS)
_add_remote_args(s)
s.add_argument(
"--harvest",
@ -543,7 +728,6 @@ def main() -> None:
"Excludes apply to all harvesting, including defaults."
),
)
s.add_argument(
"--sops",
nargs="+",
@ -571,6 +755,7 @@ def main() -> None:
d = sub.add_parser("diff", help="Compare two harvests and report differences")
_add_config_args(d)
_add_path_safety_args(d, default=argparse.SUPPRESS)
d.add_argument(
"--old",
required=True,
@ -619,10 +804,19 @@ def main() -> None:
action="store_true",
help=(
"If differences are detected, attempt to enforce the old harvest state locally by generating a manifest and "
"running ansible-playbook. Requires ansible-playbook on PATH. "
"running the selected local apply tool. "
"Enroll does not attempt to downgrade packages; if the only drift is package version upgrades (or newly installed packages), enforcement is skipped."
),
)
d.add_argument(
"--target",
choices=["ansible", "puppet", "salt"],
default="ansible",
help=(
"Configuration-management target to use with --enforce (default: ansible). "
"Requires ansible-playbook, puppet, or salt-call on PATH as appropriate."
),
)
d.add_argument(
"--out",
help="Write the report to this file instead of stdout.",
@ -683,6 +877,7 @@ def main() -> None:
e = sub.add_parser("explain", help="Explain a harvest state.json")
_add_config_args(e)
_add_path_safety_args(e, default=argparse.SUPPRESS)
e.add_argument(
"harvest",
help=(
@ -711,6 +906,7 @@ def main() -> None:
"validate", help="Validate a harvest bundle (state.json + artifacts)"
)
_add_config_args(v)
_add_path_safety_args(v, default=argparse.SUPPRESS)
v.add_argument(
"harvest",
help=(
@ -767,6 +963,13 @@ def main() -> None:
)
args = ap.parse_args(argv)
if args.cmd in {"harvest", "single-shot"} and bool(
getattr(args, "dangerous", False)
):
_warn_dangerous_harvest(sops_enabled=bool(getattr(args, "sops", None)))
_confirm_root_path_safety(force=bool(getattr(args, "assume_safe_path", False)))
# Preserve historical defaults for remote harvesting unless ssh_config lookup is enabled.
# This lets ssh_config values take effect when the user did not explicitly set
# --remote-user / --remote-port.
@ -806,6 +1009,7 @@ def main() -> None:
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=True,
)
_encrypt_harvest_dir_to_sops(
tmp_bundle, out_file, list(sops_fps)
@ -832,6 +1036,7 @@ def main() -> None:
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=not bool(args.out),
)
print(str(state))
else:
@ -849,6 +1054,7 @@ def main() -> None:
dangerous=bool(args.dangerous),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=True,
)
_encrypt_harvest_dir_to_sops(
tmp_bundle, out_file, list(sops_fps)
@ -868,6 +1074,7 @@ def main() -> None:
dangerous=bool(args.dangerous),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=not bool(args.out),
)
print(path)
elif args.cmd == "explain":
@ -895,9 +1102,7 @@ def main() -> None:
out_path = getattr(args, "out", None)
if out_path:
p = Path(out_path).expanduser()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(txt, encoding="utf-8")
write_text_output_file(out_path, txt, label="validation report")
else:
sys.stdout.write(txt)
@ -913,6 +1118,8 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
sops_fingerprints=getattr(args, "sops", None),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
target=getattr(args, "target", "ansible"),
)
if getattr(args, "sops", None) and out_enc:
print(str(out_enc))
@ -928,7 +1135,7 @@ def main() -> None:
)
# Optional enforcement: if drift is detected, attempt to restore the
# system to the *old* (baseline) state using ansible-playbook.
# system to the *old* (baseline) state using the selected target.
if bool(getattr(args, "enforce", False)):
if has_changes:
if not has_enforceable_drift(report):
@ -946,6 +1153,7 @@ def main() -> None:
args.old,
sops_mode=bool(getattr(args, "sops", False)),
report=report,
target=getattr(args, "target", "ansible"),
)
except Exception as e:
raise SystemExit(
@ -965,9 +1173,7 @@ def main() -> None:
txt = format_report(report, fmt=str(getattr(args, "format", "text")))
out_path = getattr(args, "out", None)
if out_path:
p = Path(out_path).expanduser()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(txt, encoding="utf-8")
write_text_output_file(out_path, txt, label="diff report")
else:
print(txt, end="" if txt.endswith("\n") else "\n")
@ -1039,6 +1245,7 @@ def main() -> None:
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=True,
)
_encrypt_harvest_dir_to_sops(
tmp_bundle, out_file, list(sops_fps)
@ -1050,6 +1257,8 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
sops_fingerprints=list(sops_fps),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
target=getattr(args, "target", "ansible"),
)
if not args.harvest:
print(str(out_file))
@ -1074,12 +1283,15 @@ def main() -> None:
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=not bool(args.harvest),
)
manifest(
str(harvest_dir),
args.out,
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
target=getattr(args, "target", "ansible"),
)
# For usability (when --harvest wasn't provided), print the harvest path.
if not args.harvest:
@ -1099,6 +1311,7 @@ def main() -> None:
dangerous=bool(args.dangerous),
include_paths=list(getattr(args, "include_path", []) or []),
exclude_paths=list(getattr(args, "exclude_path", []) or []),
allow_existing_output=True,
)
_encrypt_harvest_dir_to_sops(
tmp_bundle, out_file, list(sops_fps)
@ -1110,6 +1323,8 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
sops_fingerprints=list(sops_fps),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
target=getattr(args, "target", "ansible"),
)
if not args.harvest:
print(str(out_file))
@ -1129,6 +1344,8 @@ def main() -> None:
args.out,
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
target=getattr(args, "target", "ansible"),
)
except RemoteSudoPasswordRequired:
raise SystemExit(

859
enroll/cm.py Normal file
View file

@ -0,0 +1,859 @@
from __future__ import annotations
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from typing import (
Any,
Callable,
ClassVar,
Dict,
Iterable,
Iterator,
List,
Mapping,
Set,
)
from .state import load_state, state_path, write_state
@dataclass
class CMModule:
"""Renderer-neutral configuration-management resource group.
A CMModule is intentionally small: it captures the resources that a target
renderer can turn into Ansible tasks, Puppet resources, Salt states, etc.
The renderer may still decide how to name/include/order the group.
"""
role_name: str
module_name: str
packages: Set[str] = field(default_factory=set)
groups: Set[str] = field(default_factory=set)
users: Dict[str, Dict[str, Any]] = field(default_factory=dict)
dirs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
files: Dict[str, Dict[str, Any]] = field(default_factory=dict)
links: Dict[str, Dict[str, Any]] = field(default_factory=dict)
services: Dict[str, Dict[str, Any]] = field(default_factory=dict)
firewall_runtime: Dict[str, Any] = field(default_factory=dict)
notes: List[str] = field(default_factory=list)
managed_owner_attr: ClassVar[str] = "owner"
firewall_runtime_dir: ClassVar[str] = "/etc/enroll/firewall"
firewall_runtime_artifacts: ClassVar[tuple[tuple[str, str, str], ...]] = (
("ipset_save", "ipset.save", "0600"),
("iptables_v4_save", "iptables.v4", "0600"),
("iptables_v6_save", "iptables.v6", "0600"),
)
def has_core_resources(self) -> bool:
return bool(
self.packages
or self.groups
or self.users
or self.dirs
or self.files
or self.links
or self.services
or self.firewall_runtime
or self.notes
)
def has_resources(self) -> bool:
return self.has_core_resources()
def has_resources_or_attrs(self, *attrs: str) -> bool:
"""Return true if core resources or named renderer extras are present."""
return self.has_core_resources() or any(
bool(getattr(self, attr, None)) for attr in attrs
)
@staticmethod
def state_path(bundle_dir: str | Path) -> Path:
"""Return the canonical state.json path for a harvest bundle."""
return state_path(bundle_dir)
@classmethod
def load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
"""Load state.json for a renderer using the shared bundle state loader."""
return load_state(bundle_dir)
@classmethod
def _load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
"""Backward-compatible alias for renderer subclasses."""
return cls.load_state(bundle_dir)
@classmethod
def write_state(
cls,
bundle_dir: str | Path,
state: Mapping[str, Any],
*,
indent: int = 2,
sort_keys: bool = True,
) -> Path:
"""Write state.json using the shared bundle state writer."""
return write_state(bundle_dir, state, indent=indent, sort_keys=sort_keys)
@staticmethod
def _snapshot_items(snap: Dict[str, Any], key: str) -> Iterator[Dict[str, Any]]:
values = snap.get(key) or []
if not isinstance(values, list):
return
for item in values:
if isinstance(item, dict):
yield item
@classmethod
def managed_dirs_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_dirs")
@classmethod
def managed_files_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_files")
@classmethod
def managed_links_from_snapshot(
cls, snap: Dict[str, Any]
) -> Iterator[Dict[str, Any]]:
return cls._snapshot_items(snap, "managed_links")
def add_managed_dir(
self,
path: str,
*,
owner: Any = "root",
group: Any = "root",
mode: Any = "0755",
**attrs: Any,
) -> None:
if not path:
return
data: Dict[str, Any] = {
"owner": owner or "root",
"group": group or "root",
"mode": mode or "0755",
}
data.update(attrs)
self.dirs.setdefault(path, data)
def add_managed_file(
self,
path: str,
*,
owner: Any = "root",
group: Any = "root",
mode: Any = "0644",
**attrs: Any,
) -> None:
if not path:
return
data: Dict[str, Any] = {
"owner": owner or "root",
"group": group or "root",
"mode": mode or "0644",
}
data.update(attrs)
self.files.setdefault(path, data)
def add_managed_link(self, path: str, **attrs: Any) -> None:
if path:
self.links.setdefault(path, attrs)
def add_snapshot_notes(self, snap: Dict[str, Any]) -> None:
self.notes.extend(str(n) for n in (snap.get("notes", []) or []))
@staticmethod
def package_name_from_snapshot(snap: Dict[str, Any]) -> str:
return str(snap.get("package") or "").strip()
@staticmethod
def package_names_from_snapshot(snap: Dict[str, Any]) -> Iterator[str]:
for pkg in snap.get("packages", []) or []:
pkg_s = str(pkg or "").strip()
if pkg_s:
yield pkg_s
def add_package_snapshot(self, snap: Dict[str, Any]) -> None:
pkg = self.package_name_from_snapshot(snap)
if pkg:
self.packages.add(pkg)
def add_service_packages_from_snapshot(self, snap: Dict[str, Any]) -> None:
self.packages.update(self.package_names_from_snapshot(snap))
def service_unit_from_snapshot(self, snap: Dict[str, Any]) -> str:
return str(snap.get("unit") or "").strip()
def service_enabled_from_snapshot(self, snap: Dict[str, Any]) -> bool:
unit_file_state = str(snap.get("unit_file_state") or "")
return unit_file_state in ("enabled", "enabled-runtime")
def service_state_from_snapshot(
self,
snap: Dict[str, Any],
*,
running: str,
stopped: str,
) -> str:
return running if snap.get("active_state") == "active" else stopped
def add_service_snapshot_state(
self,
snap: Dict[str, Any],
*,
state_key: str,
running: str,
stopped: str,
include_manage: bool = False,
) -> None:
"""Add the common systemd service parts, parameterised per renderer."""
self.add_service_packages_from_snapshot(snap)
unit = self.service_unit_from_snapshot(snap)
if not unit:
return
data: Dict[str, Any] = {
"name": unit,
state_key: self.service_state_from_snapshot(
snap, running=running, stopped=stopped
),
"enable": self.service_enabled_from_snapshot(snap),
}
if include_manage:
data["manage"] = True
self.services[unit] = data
@staticmethod
def normalise_flatpak_item(
item: Any,
*,
method: str,
user: str | None = None,
home: str | None = None,
) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
elif isinstance(item, str):
out = {"name": item}
else:
out = {"name": str(item)}
out["method"] = str(out.get("method") or method or "system").strip() or "system"
if user and not out.get("user"):
out["user"] = user
if home and not out.get("home"):
out["home"] = home
ref = str(out.get("ref") or "").strip()
if ref and not out.get("name"):
out["name"] = ref.rsplit("/", 1)[-1]
name = str(out.get("name") or out.get("app_id") or "").strip()
if name:
out["name"] = name
remote = str(out.get("remote") or "").strip()
if remote:
out["remote"] = remote
branch = str(out.get("branch") or out.get("origin") or "").strip()
if branch:
out["branch"] = branch
if ref:
out["ref"] = ref
return out
@staticmethod
def normalise_flatpak_remote(item: Any) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
else:
out = {"name": str(item)}
name = str(out.get("name") or out.get("remote") or "").strip()
url = str(out.get("url") or out.get("from_url") or "").strip()
method = (
str(out.get("method") or out.get("scope") or "system").strip() or "system"
)
if name:
out["name"] = name
if url:
out["url"] = url
out["method"] = "user" if method == "user" else "system"
return out
@staticmethod
def normalise_snap_item(item: Any) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
elif isinstance(item, str):
out = {"name": item}
else:
out = {"name": str(item)}
name = str(out.get("name") or "").strip()
if name:
out["name"] = name
channel = str(out.get("tracking") or out.get("channel") or "").strip()
if channel:
out["channel"] = channel
raw_notes = out.get("notes") or []
if isinstance(raw_notes, str):
raw_notes = [raw_notes]
notes = [str(note).lower() for note in raw_notes]
confinement = str(out.get("confinement") or "").strip().lower()
out["classic"] = bool(
out.get("classic")
or confinement == "classic"
or any("classic" in note for note in notes)
)
out["devmode"] = bool(
out.get("devmode")
or any("devmode" in note or "dev mode" in note for note in notes)
)
out["dangerous"] = bool(
out.get("dangerous") or any("dangerous" in note for note in notes)
)
revision = str(out.get("revision") or "").strip()
if revision and not channel:
out["revision"] = revision
return out
def prepare_flatpak_remote(self, item: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
def prepare_flatpak_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
def prepare_snap_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError
@staticmethod
def user_records_from_snapshot(snap: Dict[str, Any]) -> List[Dict[str, Any]]:
records: List[Dict[str, Any]] = []
for raw in snap.get("users", []) or []:
if not isinstance(raw, dict):
continue
name = str(raw.get("name") or "").strip()
if not name:
continue
primary_group = str(raw.get("primary_group") or name).strip()
supplementary = sorted(
{
str(group).strip()
for group in (raw.get("supplementary_groups") or [])
if str(group).strip()
}
)
records.append(
{
"name": name,
"uid": raw.get("uid"),
"gid": raw.get("gid"),
"primary_group": primary_group,
"home": raw.get("home") or f"/home/{name}",
"shell": raw.get("shell"),
"gecos": raw.get("gecos"),
"supplementary_groups": supplementary,
}
)
return records
@staticmethod
def user_group_names_from_records(records: Iterable[Mapping[str, Any]]) -> Set[str]:
groups: Set[str] = set()
for record in records:
primary_group = str(record.get("primary_group") or "").strip()
if primary_group:
groups.add(primary_group)
groups.update(
str(group).strip()
for group in (record.get("supplementary_groups") or [])
if str(group).strip()
)
return groups
@staticmethod
def package_service_entries(
roles: Mapping[str, Any],
inventory_packages: Mapping[str, Any],
*,
use_common_roles: bool,
) -> Iterator[Dict[str, Any]]:
for svc in roles.get("services", []) or []:
if not isinstance(svc, dict):
continue
own_label = str(svc.get("role_name") or svc.get("unit") or "service")
role_label = (
section_label_for_packages(
svc.get("packages", []) or [], inventory_packages
)
if use_common_roles
else own_label
)
yield {"kind": "service", "snapshot": svc, "role_label": role_label}
for pkg in roles.get("packages", []) or []:
if not isinstance(pkg, dict):
continue
own_label = str(pkg.get("role_name") or pkg.get("package") or "package")
role_label = (
package_section_label(pkg, inventory_packages)
if use_common_roles
else own_label
)
yield {"kind": "package", "snapshot": pkg, "role_label": role_label}
@staticmethod
def active_service_units_by_package(
entries: Iterable[Mapping[str, Any]],
) -> Dict[str, List[Dict[str, str]]]:
"""Return active service units keyed by the packages that produced them.
Renderers use this when a package-owned managed file should refresh the
service that package provides. The helper is deliberately conservative:
stopped/inactive services are not included, and ambiguous package->many
service mappings are left to the renderer/caller to resolve.
"""
by_package: Dict[str, List[Dict[str, str]]] = {}
for entry in entries:
if str(entry.get("kind") or "package") != "service":
continue
snap = entry.get("snapshot") or {}
if not isinstance(snap, Mapping):
continue
unit = str(snap.get("unit") or "").strip()
if not unit or str(snap.get("active_state") or "") != "active":
continue
role_name = str(snap.get("role_name") or unit).strip()
for pkg in snap.get("packages", []) or []:
package = str(pkg or "").strip()
if package:
by_package.setdefault(package, []).append(
{"unit": unit, "role_name": role_name}
)
for package, services in list(by_package.items()):
seen: Set[str] = set()
unique: List[Dict[str, str]] = []
for svc in services:
unit = svc.get("unit") or ""
if unit and unit not in seen:
seen.add(unit)
unique.append(svc)
by_package[package] = sorted(unique, key=lambda svc: svc.get("unit", ""))
return by_package
@staticmethod
def active_service_units_for_package_snapshot(
package_snapshot: Mapping[str, Any],
service_units_by_package: Mapping[str, List[Dict[str, str]]],
) -> List[str]:
"""Return active service units that a package snapshot can safely refresh.
If one active service is associated with the package, return it. If
several are associated, only return a role-name match; otherwise avoid
guessing and return no services. This prevents package-level config from
recreating the old broad-restart problem.
"""
package = str(package_snapshot.get("package") or "").strip()
if not package:
return []
services = list(service_units_by_package.get(package) or [])
if len(services) == 1:
unit = services[0].get("unit") or ""
return [unit] if unit else []
role_name = str(package_snapshot.get("role_name") or "").strip()
if role_name:
matched = [
svc.get("unit") or ""
for svc in services
if svc.get("role_name") == role_name and svc.get("unit")
]
if matched:
return sorted(set(matched))
return []
def add_user_flatpaks_snapshot(self, snap: Dict[str, Any]) -> None:
home_by_user = {
str(u.get("name")): str(u.get("home") or "")
for u in (snap.get("users", []) or [])
if isinstance(u, dict) and u.get("name")
}
for remote in snap.get("user_flatpak_remotes", []) or []:
item = self.normalise_flatpak_remote(remote)
user = str(item.get("user") or "").strip()
if user and not item.get("home"):
item["home"] = home_by_user.get(user) or f"/home/{user}"
if item.get("method") == "user" and item.get("name") and item.get("url"):
self.flatpak_remotes.append( # type: ignore[attr-defined]
self.prepare_flatpak_remote(item)
)
for uname, flatpaks in (snap.get("user_flatpaks", {}) or {}).items():
user = str(uname)
for fp in flatpaks or []:
item = self.normalise_flatpak_item(
fp, method="user", user=user, home=home_by_user.get(user) or None
)
if item.get("name"):
self.flatpaks.append( # type: ignore[attr-defined]
self.prepare_flatpak_item(item)
)
def add_flatpak_snapshot(self, snap: Dict[str, Any]) -> None:
for remote in snap.get("remotes", []) or []:
item = self.normalise_flatpak_remote(remote)
if item.get("name") and item.get("url"):
self.flatpak_remotes.append( # type: ignore[attr-defined]
self.prepare_flatpak_remote(item)
)
for fp in snap.get("system_flatpaks", []) or []:
item = self.normalise_flatpak_item(fp, method="system")
if item.get("name"):
self.flatpaks.append( # type: ignore[attr-defined]
self.prepare_flatpak_item(item)
)
self.add_snapshot_notes(snap)
def add_snap_snapshot(self, snap: Dict[str, Any]) -> None:
for raw in snap.get("system_snaps", []) or []:
item = self.normalise_snap_item(raw)
if item.get("name"):
self.snaps.append( # type: ignore[attr-defined]
self.prepare_snap_item(item)
)
self.add_snapshot_notes(snap)
def firewall_runtime_snapshot_has_artifacts(self, snap: Mapping[str, Any]) -> bool:
return any(
str(snap.get(key) or "").strip()
for key, _dest, _mode in self.firewall_runtime_artifacts
)
def firewall_runtime_source_refs(self, snap: Mapping[str, Any]) -> Dict[str, str]:
return {
key: str(snap.get(key) or "").strip()
for key, _dest, _mode in self.firewall_runtime_artifacts
if str(snap.get(key) or "").strip()
}
def firewall_runtime_dest_path(self, dest_name: str) -> str:
return f"{self.firewall_runtime_dir}/{dest_name}"
def firewall_runtime_ipset_sets(self, snap: Mapping[str, Any]) -> List[str]:
return [
str(x).strip() for x in (snap.get("ipset_sets") or []) if str(x).strip()
]
@staticmethod
def shell_quote(value: Any) -> str:
return shlex.quote(str(value or ""))
def firewall_ipset_restore_cmd(self, path: str, sets: List[str]) -> str:
flush_parts = [f"ipset flush {self.shell_quote(name)} || true" for name in sets]
flush = "; ".join(flush_parts)
restore = f"ipset restore -exist < {self.shell_quote(path)}"
if flush:
return f"/bin/sh -c {self.shell_quote(flush + '; ' + restore)}"
return f"/bin/sh -c {self.shell_quote(restore)}"
def firewall_runtime_commands(self, runtime: Mapping[str, Any]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
ipset_path = str(runtime.get("ipset_save") or "")
if ipset_path:
sets = [str(x) for x in (runtime.get("ipset_sets") or []) if str(x)]
out["ipset_restore_cmd"] = self.firewall_ipset_restore_cmd(ipset_path, sets)
ipt4_path = str(runtime.get("iptables_v4_save") or "")
if ipt4_path:
out["iptables_v4_restore_cmd"] = (
f"iptables-restore {self.shell_quote(ipt4_path)}"
)
ipt6_path = str(runtime.get("iptables_v6_save") or "")
if ipt6_path:
out["iptables_v6_restore_cmd"] = (
f"ip6tables-restore {self.shell_quote(ipt6_path)}"
)
return out
def _managed_owner_attrs(self, owner: Any) -> Dict[str, Any]:
return {self.managed_owner_attr: owner or "root"}
def add_firewall_runtime_snapshot(
self,
snap: Dict[str, Any],
*,
bundle_dir: str,
artifact_role: str,
files_dir: Path,
copy_artifact: Callable[..., str | None],
source_uri: Callable[[str, str], str],
file_prefix: str | None = None,
dir_attrs: Mapping[str, Any] | None = None,
file_attrs: Mapping[str, Any] | None = None,
) -> None:
"""Add captured live firewall state using renderer-supplied file hooks."""
self.add_service_packages_from_snapshot(snap)
attrs: Dict[str, Any] = {
**self._managed_owner_attrs("root"),
"group": "root",
"mode": "0750",
"reason": "firewall_runtime",
}
if dir_attrs:
attrs.update(dir_attrs)
self.add_managed_dir(self.firewall_runtime_dir, **attrs)
runtime: Dict[str, Any] = {}
for key, dest_name, mode in self.firewall_runtime_artifacts:
src_rel = str(snap.get(key) or "").strip()
if not src_rel:
continue
role_rel = copy_artifact(
bundle_dir,
artifact_role,
src_rel,
files_dir,
dst_prefix=file_prefix,
)
if not role_rel:
self.notes.append(
f"Firewall runtime artifact {src_rel!r} was referenced but not found."
)
continue
file_data: Dict[str, Any] = {
**self._managed_owner_attrs("root"),
"group": "root",
"mode": mode,
"source": source_uri(self.module_name, role_rel),
"reason": "firewall_runtime",
}
if file_attrs:
file_data.update(file_attrs)
dest = self.firewall_runtime_dest_path(dest_name)
self.add_managed_file(dest, **file_data)
runtime[key] = dest
ipset_sets = self.firewall_runtime_ipset_sets(snap)
if ipset_sets:
runtime["ipset_sets"] = ipset_sets
if runtime:
runtime.update(self.firewall_runtime_commands(runtime))
self.firewall_runtime.update(runtime)
self.add_snapshot_notes(snap)
def remove_directory_resource_conflicts(self) -> None:
for path in set(self.files) | set(self.links):
self.dirs.pop(path, None)
def package_section_label(
package_role: Dict[str, Any], inventory_packages: Dict[str, Any]
) -> str:
"""Return the Debian Section/RPM Group label for a package role."""
pkg = str(package_role.get("package") or "").strip()
inv = inventory_packages.get(pkg) or {}
candidates: List[str] = []
for value in (package_role.get("section"), inv.get("section"), inv.get("group")):
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
for inst in inv.get("installations", []) or []:
if not isinstance(inst, dict):
continue
for key in ("section", "group"):
value = inst.get(key)
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
for value in candidates:
if value.lower() not in {"(none)", "none", "unspecified"}:
return value
return "misc"
def section_label_for_packages(
packages: List[str], inventory_packages: Dict[str, Any]
) -> str:
"""Return a stable section/group label for a set of packages."""
for pkg in packages or []:
label = package_section_label({"package": pkg}, inventory_packages)
if label and label.lower() != "misc":
return label
return "misc"
def role_order_key(role: str) -> tuple[int, str]:
# Keep broadly similar ordering to generated Ansible playbooks: package/config
# scaffolding first, then services/users, then host-specific runtime state.
priority = {
"apt_config": 10,
"dnf_config": 11,
"etc_custom": 80,
"usr_local_custom": 81,
"extra_paths": 82,
"container_images": 88,
"users": 90,
"enroll_runtime": 94,
"sysctl": 95,
"firewall_runtime": 99,
}
return (priority.get(role, 50), role)
def markdown_list(items: Iterable[Any], *, empty: str = "None.") -> str:
values = [str(item) for item in items if str(item)]
return "\n".join(f"- {item}" for item in values) or f"- {empty}"
def path_reason_lines(
items: Iterable[Mapping[str, Any]], *, source_key: str = "path"
) -> List[str]:
lines: List[str] = []
for item in items or []:
path = str(item.get(source_key) or "")
if not path:
continue
reason = str(item.get("reason") or "")
lines.append(f"{path} ({reason})" if reason else path)
return lines
def iter_role_snapshots(roles: Mapping[str, Any]) -> Iterator[Mapping[str, Any]]:
for value in roles.values():
if isinstance(value, list):
for item in value:
if isinstance(item, Mapping):
yield item
elif isinstance(value, Mapping):
yield value
def snapshot_note_lines(roles: Mapping[str, Any]) -> List[str]:
notes: List[str] = []
for snap in iter_role_snapshots(roles):
source = str(
snap.get("role_name") or snap.get("unit") or snap.get("package") or "role"
)
notes.extend(f"`{source}`: {note}" for note in snap.get("notes", []) or [])
return notes
def snapshot_excluded_lines(roles: Mapping[str, Any]) -> List[str]:
excluded: List[str] = []
for snap in iter_role_snapshots(roles):
source = str(
snap.get("role_name") or snap.get("unit") or snap.get("package") or "role"
)
for line in path_reason_lines(snap.get("excluded", []) or []):
excluded.append(f"`{source}`: {line}")
return excluded
def _drop_duplicate_set_items(
module: CMModule,
values: Set[str],
seen: Set[str],
resource_type: str,
) -> Set[str]:
kept: Set[str] = set()
for value in sorted(values):
if value in seen:
module.notes.append(
f"Skipped duplicate {resource_type}[{value}] already emitted earlier in this catalog."
)
continue
kept.add(value)
seen.add(value)
return kept
def _drop_duplicate_mapping_items(
module: CMModule,
values: Dict[str, Dict[str, Any]],
seen: Set[str],
resource_type: str,
*,
excluded_titles: Set[str] | None = None,
excluded_reason: str = "conflicts with another resource",
) -> Dict[str, Dict[str, Any]]:
kept: Dict[str, Dict[str, Any]] = {}
excluded_titles = excluded_titles or set()
for title, attrs in values.items():
if title in excluded_titles:
module.notes.append(f"Skipped {resource_type}[{title}]: {excluded_reason}.")
continue
if title in seen:
module.notes.append(
f"Skipped duplicate {resource_type}[{title}] already emitted earlier in this catalog."
)
continue
kept[title] = attrs
seen.add(title)
return kept
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
"""Resolve global catalog conflicts before renderer output.
Puppet and Salt compile a single resource catalog. Ansible can tolerate the
same package, service, or parent directory appearing in more than one role;
catalog targets cannot. Resolve those conflicts in the shared model rather
than deleting renderer output after the fact.
"""
ordered = list(modules)
concrete_file_paths: Set[str] = set()
for module in ordered:
concrete_file_paths.update(module.files)
concrete_file_paths.update(module.links)
seen_packages: Set[str] = set()
seen_groups: Set[str] = set()
seen_users: Set[str] = set()
seen_dirs: Set[str] = set()
seen_files: Set[str] = set()
seen_links: Set[str] = set()
seen_services: Set[str] = set()
for module in ordered:
module.packages = _drop_duplicate_set_items(
module, module.packages, seen_packages, "Package"
)
module.groups = _drop_duplicate_set_items(
module, module.groups, seen_groups, "Group"
)
module.users = _drop_duplicate_mapping_items(
module, module.users, seen_users, "User"
)
module.dirs = _drop_duplicate_mapping_items(
module,
module.dirs,
seen_dirs,
"File",
excluded_titles=concrete_file_paths,
excluded_reason="a file or link with the same path is emitted in this catalog",
)
module.files = _drop_duplicate_mapping_items(
module, module.files, seen_files | seen_links, "File"
)
seen_files.update(module.files)
module.links = _drop_duplicate_mapping_items(
module, module.links, seen_links | seen_files, "File"
)
seen_links.update(module.links)
module.services = _drop_duplicate_mapping_items(
module, module.services, seen_services, "Service"
)

View file

@ -69,7 +69,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
Uses dpkg-query and is expected to work on Debian/Ubuntu-like systems.
Output format:
{"pkg": [{"version": "...", "arch": "..."}, ...], ...}
{"pkg": [{"version": "...", "arch": "...", "section": "..."}, ...], ...}
"""
try:
@ -77,7 +77,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
[
"dpkg-query",
"-W",
"-f=${Package}\t${Version}\t${Architecture}\n",
"-f=${Package}\t${Version}\t${Architecture}\t${Section}\n",
],
text=True,
capture_output=True,
@ -97,7 +97,10 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
name, ver, arch = parts[0].strip(), parts[1].strip(), parts[2].strip()
if not name:
continue
out.setdefault(name, []).append({"version": ver, "arch": arch})
instance = {"version": ver, "arch": arch}
if len(parts) >= 4 and parts[3].strip():
instance["section"] = parts[3].strip()
out.setdefault(name, []).append(instance)
# Stable ordering for deterministic JSON dumps.
for k in list(out.keys()):
@ -183,7 +186,12 @@ def parse_status_conffiles(
if m:
out[pkg] = m
with open(status_path, "r", encoding="utf-8", errors="replace") as f:
try:
f = open(status_path, "r", encoding="utf-8", errors="replace")
except OSError:
return out
with f:
for line in f:
if line.strip() == "":
if cur:

View file

@ -21,10 +21,37 @@ from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from .remote import _safe_extract_tar
from .state import (
inventory_packages_from_state as _packages_inventory,
load_state as _load_state,
roles_from_state as _roles,
state_path,
)
from .pathfilter import PathFilter
from .sopsutil import decrypt_file_binary_to, require_sops_cmd
def _validate_diff_bundle(label: str, bundle_dir: Path) -> None:
"""Validate a resolved harvest bundle before diff reads artifacts.
`diff` intentionally compares older harvests, so keep schema validation out
of this internal safety pass. The important security property here is that
the bundle's artifact tree has the same path/symlink/hardlink/special-file
checks that `manifest` relies on before copying artifacts.
"""
# Import lazily to avoid a module-level cycle: enroll.validate imports
# BundleRef/_bundle_from_input from this module.
from .validate import validate_harvest
validation = validate_harvest(str(bundle_dir), no_schema=True)
if not validation.ok:
raise RuntimeError(
f"{label} harvest failed validation; refusing to diff unsafe bundle.\n"
+ validation.to_text().strip()
)
def _progress_enabled() -> bool:
"""Return True if we should display interactive progress UI on the CLI.
@ -116,7 +143,7 @@ class BundleRef:
@property
def state_path(self) -> Path:
return self.dir / "state.json"
return state_path(self.dir)
def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
@ -189,24 +216,10 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
)
def _load_state(bundle_dir: Path) -> Dict[str, Any]:
sp = bundle_dir / "state.json"
with open(sp, "r", encoding="utf-8") as f:
return json.load(f)
def _packages_inventory(state: Dict[str, Any]) -> Dict[str, Any]:
return (state.get("inventory") or {}).get("packages") or {}
def _all_packages(state: Dict[str, Any]) -> List[str]:
return sorted(_packages_inventory(state).keys())
def _roles(state: Dict[str, Any]) -> Dict[str, Any]:
return state.get("roles") or {}
def _pkg_version_key(entry: Dict[str, Any]) -> Optional[str]:
"""Return a stable string used for version comparison."""
installs = entry.get("installations") or []
@ -303,6 +316,12 @@ def _iter_managed_files(state: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str,
for mf in ac.get("managed_files", []) or []:
yield str(ac_role), mf
# sysctl
sc = _roles(state).get("sysctl") or {}
sc_role = sc.get("role_name") or "sysctl"
for mf in sc.get("managed_files", []) or []:
yield str(sc_role), mf
# etc_custom
ec = _roles(state).get("etc_custom") or {}
ec_role = ec.get("role_name") or "etc_custom"
@ -373,6 +392,9 @@ def compare_harvests(
if new_b.tempdir:
stack.callback(new_b.tempdir.cleanup)
_validate_diff_bundle("old", old_b.dir)
_validate_diff_bundle("new", new_b.dir)
old_state = _load_state(old_b.dir)
new_state = _load_state(new_b.dir)
@ -660,6 +682,113 @@ def _role_tag(role: str) -> str:
return f"role_{safe}"
def _normalise_enforcement_target(target: str) -> str:
t = str(target or "ansible").strip().lower()
if t not in {"ansible", "puppet", "salt"}:
raise ValueError(f"unsupported enforcement target: {target!r}")
return t
def _enforcement_tool(target: str) -> Tuple[str, str]:
"""Return (binary-name, human-label) for a local enforcement target."""
if target == "puppet":
return "puppet", "puppet apply"
if target == "salt":
return "salt-call", "salt-call"
return "ansible-playbook", "ansible-playbook"
def _require_enforcement_tool(target: str) -> Tuple[str, str]:
binary, label = _enforcement_tool(target)
exe = shutil.which(binary)
if not exe:
install_hint = {
"ansible": "Ansible",
"puppet": "Puppet",
"salt": "Salt",
}.get(target, target)
raise RuntimeError(
f"{binary} not found on PATH "
f"(cannot enforce with target {target}; install {install_hint})"
)
return exe, label
def _enforcement_command(
target: str,
exe: str,
manifest_dir: Path,
*,
tags: Optional[List[str]] = None,
) -> Tuple[List[str], Dict[str, str]]:
"""Return the local apply command and environment for a rendered manifest."""
env = dict(os.environ)
if target == "ansible":
playbook = manifest_dir / "playbook.yml"
if not playbook.exists():
raise RuntimeError(
f"manifest did not produce expected playbook.yml at {playbook}"
)
cfg = manifest_dir / "ansible.cfg"
if cfg.exists():
env["ANSIBLE_CONFIG"] = str(cfg)
cmd = [
exe,
"-i",
"localhost,",
"-c",
"local",
str(playbook),
]
if tags:
cmd.extend(["--tags", ",".join(tags)])
return cmd, env
if target == "puppet":
site_pp = manifest_dir / "manifests" / "site.pp"
if not site_pp.exists():
raise RuntimeError(
f"manifest did not produce expected Puppet site.pp at {site_pp}"
)
cmd = [
exe,
"apply",
"--modulepath",
str(manifest_dir / "modules"),
]
hiera_config = manifest_dir / "hiera.yaml"
if hiera_config.exists():
cmd.extend(["--hiera_config", str(hiera_config)])
cmd.append(str(site_pp))
return cmd, env
if target == "salt":
states_dir = manifest_dir / "states"
top_sls = states_dir / "top.sls"
if not top_sls.exists():
raise RuntimeError(
f"manifest did not produce expected Salt top.sls at {top_sls}"
)
cmd = [
exe,
"--local",
"--file-root",
str(states_dir),
]
pillar_dir = manifest_dir / "pillar"
if pillar_dir.exists():
cmd.extend(["--pillar-root", str(pillar_dir)])
cmd.extend(["state.apply"])
return cmd, env
raise ValueError(f"unsupported enforcement target: {target!r}")
def _enforcement_plan(
report: Dict[str, Any],
old_state: Dict[str, Any],
@ -769,22 +898,22 @@ def enforce_old_harvest(
*,
sops_mode: bool = False,
report: Optional[Dict[str, Any]] = None,
target: str = "ansible",
) -> Dict[str, Any]:
"""Enforce the *old* (baseline) harvest state on the current machine.
When Ansible is available, this:
1) renders a temporary manifest from the old harvest, and
2) runs ansible-playbook locally to apply it.
This renders a temporary manifest from the old harvest using the requested
target, then runs the target's local apply command:
- ansible: ansible-playbook -i localhost, -c local playbook.yml
- puppet: puppet apply --modulepath ./modules manifests/site.pp
- salt: salt-call --local --file-root ./states state.apply
Returns a dict suitable for attaching to the diff report under
report['enforcement'].
"""
ansible_playbook = shutil.which("ansible-playbook")
if not ansible_playbook:
raise RuntimeError(
"ansible-playbook not found on PATH (cannot enforce; install Ansible)"
)
target = _normalise_enforcement_target(target)
tool_exe, tool_label = _require_enforcement_tool(target)
# Import lazily to avoid heavy import cost and potential CLI cycles.
from .manifest import manifest
@ -804,6 +933,10 @@ def enforce_old_harvest(
if report is not None:
plan = _enforcement_plan(report, old_state, old_b.dir)
roles = list(plan.get("roles") or [])
# Only Ansible has generated per-role tags that can safely narrow
# the apply scope. Puppet and Salt enforcement deliberately run the
# full generated local manifest/catalog for now.
if target == "ansible":
t = list(plan.get("tags") or [])
tags = t if t else None
@ -814,31 +947,19 @@ def enforce_old_harvest(
except OSError:
pass
# 1) Generate a manifest in a temp directory.
manifest(str(old_b.dir), str(td_path))
playbook = td_path / "playbook.yml"
if not playbook.exists():
raise RuntimeError(
f"manifest did not produce expected playbook.yml at {playbook}"
)
# 1) Generate a manifest in a temp directory. The renderer now
# refuses to write into an existing destination, so use a fresh
# child path under the secure temporary directory.
manifest_dir = td_path / "manifest"
manifest(str(old_b.dir), str(manifest_dir), target=target)
# 2) Apply it locally.
env = dict(os.environ)
cfg = td_path / "ansible.cfg"
if cfg.exists():
env["ANSIBLE_CONFIG"] = str(cfg)
cmd = [
ansible_playbook,
"-i",
"localhost,",
"-c",
"local",
str(playbook),
]
if tags:
cmd.extend(["--tags", ",".join(tags)])
cmd, env = _enforcement_command(
target,
tool_exe,
manifest_dir,
tags=tags,
)
spinner: Optional[_Spinner] = None
p: Optional[subprocess.CompletedProcess[str]] = None
@ -846,12 +967,12 @@ def enforce_old_harvest(
if _progress_enabled():
if tags:
sys.stderr.write(
f"Enforce: running ansible-playbook (tags: {','.join(tags)})\n",
f"Enforce: running {tool_label} (tags: {','.join(tags)})\n",
)
else:
sys.stderr.write("Enforce: running ansible-playbook\n")
sys.stderr.write(f"Enforce: running {tool_label}\n")
sys.stderr.flush()
spinner = _Spinner(" ansible-playbook")
spinner = _Spinner(f" {tool_label}")
spinner.start()
try:
@ -869,8 +990,8 @@ def enforce_old_harvest(
rc = p.returncode if p is not None else None
spinner.stop(
final_line=(
f"Enforce: ansible-playbook finished in {elapsed:0.1f}s"
+ (f" (rc={rc})" if rc is not None else ""),
f"Enforce: {tool_label} finished in {elapsed:0.1f}s"
+ (f" (rc={rc})" if rc is not None else "")
),
)
@ -878,23 +999,32 @@ def enforce_old_harvest(
info: Dict[str, Any] = {
"status": "applied" if p.returncode == 0 else "failed",
"target": target,
"tool": tool_label,
"executable": tool_exe,
"started_at": started_at,
"finished_at": finished_at,
"ansible_playbook": ansible_playbook,
"command": cmd,
"returncode": int(p.returncode),
}
# Keep the original Ansible-specific field for compatibility with
# existing consumers of the JSON report.
if target == "ansible":
info["ansible_playbook"] = tool_exe
elif target == "puppet":
info["puppet"] = tool_exe
elif target == "salt":
info["salt_call"] = tool_exe
# Record tag selection (if we could attribute drift to specific roles).
info["roles"] = roles
info["tags"] = list(tags or [])
if not tags:
info["scope"] = "full_playbook"
info["scope"] = "full_manifest"
if p.returncode != 0:
err = (p.stderr or p.stdout or "").strip()
raise RuntimeError(
"ansible-playbook failed"
f"{tool_label} failed"
+ (f" (rc={p.returncode})" if p.returncode is not None else "")
+ (f": {err}" if err else "")
)
@ -939,6 +1069,9 @@ def _report_text(report: Dict[str, Any]) -> str:
if enf:
lines.append("\nEnforcement")
status = str(enf.get("status") or "").strip().lower()
tool = str(enf.get("tool") or "ansible-playbook")
target = str(enf.get("target") or "ansible")
via = f"{tool} ({target})" if target and target not in tool else tool
if status == "applied":
extra = ""
tags = enf.get("tags") or []
@ -948,7 +1081,7 @@ def _report_text(report: Dict[str, Any]) -> str:
elif scope:
extra = f" ({scope})"
lines.append(
f" applied old harvest via ansible-playbook (rc={enf.get('returncode')})"
f" applied old harvest via {via} (rc={enf.get('returncode')})"
+ extra
+ (
f" (finished {enf.get('finished_at')})"
@ -958,7 +1091,7 @@ def _report_text(report: Dict[str, Any]) -> str:
)
elif status == "failed":
lines.append(
f" attempted enforcement but ansible-playbook failed (rc={enf.get('returncode')})"
f" attempted enforcement but {via} failed (rc={enf.get('returncode')})"
)
elif status == "skipped":
r = enf.get("reason")
@ -1098,6 +1231,9 @@ def _report_markdown(report: Dict[str, Any]) -> str:
if enf:
out.append("\n## Enforcement\n")
status = str(enf.get("status") or "").strip().lower()
tool = str(enf.get("tool") or "ansible-playbook")
target = str(enf.get("target") or "ansible")
via = f"{tool} ({target})" if target and target not in tool else tool
if status == "applied":
extra = ""
tags = enf.get("tags") or []
@ -1107,7 +1243,7 @@ def _report_markdown(report: Dict[str, Any]) -> str:
elif scope:
extra = f" ({scope})"
out.append(
"- ✅ Applied old harvest via ansible-playbook"
f"- ✅ Applied old harvest via {via}"
+ extra
+ (
f" (rc={enf.get('returncode')})"
@ -1123,7 +1259,7 @@ def _report_markdown(report: Dict[str, Any]) -> str:
)
elif status == "failed":
out.append(
"- ⚠️ Attempted enforcement but ansible-playbook failed"
f"- ⚠️ Attempted enforcement but {via} failed"
+ (
f" (rc={enf.get('returncode')})"
if enf.get("returncode") is not None
@ -1345,8 +1481,14 @@ def send_email(
try:
s.starttls()
s.ehlo()
except Exception:
# STARTTLS is optional; ignore if unsupported.
except Exception as e:
if smtp_user or smtp_password:
raise RuntimeError(
"email: SMTP STARTTLS failed; refusing to send credentials "
"without TLS"
) from e
# Without credentials, keep STARTTLS opportunistic so localhost or
# unauthenticated relay setups continue to work.
pass # nosec
if smtp_user:
s.login(smtp_user, smtp_password or "")

View file

@ -5,7 +5,8 @@ from collections import Counter, defaultdict
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Tuple
from .diff import _bundle_from_input, _load_state # reuse existing bundle handling
from .diff import _bundle_from_input # reuse existing bundle handling
from .state import load_state
@dataclass(frozen=True)
@ -188,6 +189,12 @@ _EXCLUDED_REASONS: Dict[str, ReasonInfo] = {
"Not a regular file",
"Excluded because it was not a regular file (device, socket, etc.).",
),
"symlink_component": ReasonInfo(
"Unsafe symlinked path",
"Excluded because a directory in the path was a symlink, which could "
"redirect capture into a sensitive location; Enroll refuses to follow "
"symlinked parents when harvesting files.",
),
"binary_like": ReasonInfo(
"Binary-like",
"Excluded because it looked like binary content (not useful for config management).",
@ -289,7 +296,7 @@ def explain_state(
- a SOPS-encrypted bundle (.sops)
"""
bundle = _bundle_from_input(harvest, sops_mode=sops_mode)
state = _load_state(bundle.dir)
state = load_state(bundle.dir)
host = state.get("host") or {}
enroll = state.get("enroll") or {}
@ -383,6 +390,7 @@ def explain_state(
for rname in [
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
@ -435,6 +443,7 @@ def explain_state(
for rname in [
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",

View file

@ -1,10 +1,150 @@
from __future__ import annotations
import errno
import hashlib
import os
import stat
from typing import Tuple
def open_no_follow_path(path: str, *, write: bool = False, mode: int = 0o600) -> int:
"""Open ``path`` without following a symlink in *any* path component.
``O_NOFOLLOW`` only protects the final component of a path. A regular
file reached through a symlinked *parent* directory (for example a user
replacing ``~/.ssh`` with a link to a sensitive directory) would still be
opened by a plain ``os.open(path, O_NOFOLLOW)``.
This helper resolves the path one component at a time with ``openat``
semantics:
- each intermediate component is opened relative to its parent's
descriptor without following symlinks;
- the final component is opened with ``O_NOFOLLOW`` (read, or
``O_WRONLY | O_CREAT | O_EXCL`` when ``write`` is True).
The important detail is that intermediate components are opened with
``O_PATH | O_NOFOLLOW`` when ``O_PATH`` is available, and then verified
with ``fstat()``. On Linux, ``O_RDONLY | O_DIRECTORY | O_NOFOLLOW`` is not
sufficient for this job: a symlink whose target is a directory can still be
opened as the target directory on some kernels. Opening with ``O_PATH`` and
checking the resulting descriptor reliably exposes such a component as a
symlink instead.
A symlink (or a ``..`` component) anywhere in the path raises
``OSError(ELOOP)``. On platforms without ``openat``/``O_DIRECTORY``
support, this falls back to a single ``O_NOFOLLOW`` open of the whole path,
which is no worse than the historical behaviour.
"""
cloexec = getattr(os, "O_CLOEXEC", 0)
nofollow = getattr(os, "O_NOFOLLOW", 0)
o_directory = getattr(os, "O_DIRECTORY", 0)
o_path = getattr(os, "O_PATH", 0)
if write:
final_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | cloexec | nofollow
else:
final_flags = os.O_RDONLY | cloexec | nofollow
supports_openat = bool(
o_directory and nofollow and os.open in getattr(os, "supports_dir_fd", set())
)
if not supports_openat:
return os.open(path, final_flags, mode)
absolute = path.startswith("/")
parts = [p for p in path.split("/") if p not in ("", ".")]
if not parts:
return os.open(path, final_flags, mode)
*parent_parts, leaf = parts
# Use O_PATH for directory descriptors when available. O_PATH descriptors
# can be used as dir_fd anchors for later openat-style calls, and with
# O_NOFOLLOW they let us fstat() a symlink component instead of silently
# following it. If O_PATH is unavailable, use O_RDONLY and an lstat()
# pre-check for intermediate components as a best-effort fallback.
dir_base_flags = (o_path if o_path else os.O_RDONLY) | cloexec | o_directory
component_flags = (
(o_path if o_path else os.O_RDONLY) | cloexec | o_directory | nofollow
)
dir_fd = os.open("/" if absolute else ".", dir_base_flags)
try:
for component in parent_parts:
if component == "..":
raise OSError(errno.ELOOP, "unsafe '..' path component", path)
if not o_path:
# Best-effort fallback for platforms without O_PATH. This is not
# as race-resistant as the descriptor-only path, but it avoids
# known symlink parents where we cannot open the component itself
# as a non-followed O_PATH descriptor.
try:
st = os.lstat(component, dir_fd=dir_fd)
except OSError:
raise
if stat.S_ISLNK(st.st_mode):
raise OSError(errno.ELOOP, "symlinked path component", path)
if not stat.S_ISDIR(st.st_mode):
raise OSError(errno.ENOTDIR, "non-directory path component", path)
try:
next_fd = os.open(component, component_flags, dir_fd=dir_fd)
except OSError as e:
if e.errno in {errno.ELOOP, errno.ENOTDIR}:
try:
st = os.lstat(component, dir_fd=dir_fd)
except OSError:
raise
if stat.S_ISLNK(st.st_mode):
raise OSError(
errno.ELOOP,
"symlinked path component",
path,
) from e
raise
try:
st = os.fstat(next_fd)
if stat.S_ISLNK(st.st_mode):
raise OSError(errno.ELOOP, "symlinked path component", path)
if not stat.S_ISDIR(st.st_mode):
raise OSError(errno.ENOTDIR, "non-directory path component", path)
except Exception:
os.close(next_fd)
raise
os.close(dir_fd)
dir_fd = next_fd
if leaf == "..":
raise OSError(errno.ELOOP, "unsafe '..' path component", path)
return os.open(leaf, final_flags, mode, dir_fd=dir_fd)
finally:
os.close(dir_fd)
def stat_triplet_from_stat(st: os.stat_result) -> Tuple[str, str, str]:
"""Return (owner, group, mode) for an existing stat result."""
mode = oct(st.st_mode & 0o7777)[2:].zfill(4)
import grp
import pwd
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError:
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except KeyError:
group = str(st.st_gid)
return owner, group, mode
def file_md5(path: str) -> str:
"""Return hex MD5 of a file.
@ -23,18 +163,4 @@ def stat_triplet(path: str) -> Tuple[str, str, str]:
owner/group are usernames/group names when resolvable, otherwise numeric ids.
mode is a zero-padded octal string (e.g. "0644").
"""
st = os.stat(path, follow_symlinks=True)
mode = oct(st.st_mode & 0o7777)[2:].zfill(4)
import grp
import pwd
try:
owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError:
owner = str(st.st_uid)
try:
group = grp.getgrgid(st.st_gid).gr_name
except KeyError:
group = str(st.st_gid)
return owner, group, mode
return stat_triplet_from_stat(os.stat(path, follow_symlinks=True))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
"""Harvest collector package exports"""
from __future__ import annotations
from importlib import import_module
from .context import HarvestCollector, HarvestContext
_COLLECTOR_EXPORTS = {
"CronLogrotateCollection": ".cron_logrotate",
"CronLogrotateCollector": ".cron_logrotate",
"ExtraPathsCollector": ".paths",
"PackageManagerConfigCollection": ".package_manager",
"PackageManagerConfigCollector": ".package_manager",
"RuntimeStateCollection": ".runtime",
"RuntimeStateCollector": ".runtime",
"ServicePackageCollection": ".services",
"ServicePackageCollector": ".services",
"UsersCollection": ".users",
"UsersCollector": ".users",
"UsrLocalCustomCollector": ".paths",
}
__all__ = [
"HarvestCollector",
"HarvestContext",
*_COLLECTOR_EXPORTS,
]
def __getattr__(name: str):
module_name = _COLLECTOR_EXPORTS.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module = import_module(module_name, __name__)
value = getattr(module, name)
globals()[name] = value
return value

View file

@ -0,0 +1,251 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess # nosec B404
from collections.abc import (
Iterable,
) # nosec - executes fixed docker/podman command arguments only
from typing import Any, Dict, List, Optional, Sequence, Tuple
from ..harvest_types import ContainerImagesSnapshot
from .context import HarvestCollector
_DIGEST_RE = re.compile(r"@sha256:[0-9A-Fa-f]{32,}")
_SHA_ID_RE = re.compile(r"^(?:sha256:)?[0-9A-Fa-f]{64}$")
def _normalise_image_id(value: Any) -> Optional[str]:
s = str(value or "").strip()
if not s:
return None
if s.startswith("sha256:"):
return s
if _SHA_ID_RE.match(s):
return "sha256:" + s
return s
def _as_string_list(value: Any) -> List[str]:
if not value:
return []
if isinstance(value, str):
values = [value]
elif isinstance(value, Iterable):
values = list(value)
else:
values = [value]
out: List[str] = []
for item in values:
s = str(item or "").strip()
if not s or s in {"<none>", "<none>:<none>"}:
continue
if s not in out:
out.append(s)
return out
def _pullable_digests(value: Any) -> List[str]:
return [s for s in _as_string_list(value) if _DIGEST_RE.search(s)]
def _split_tag_ref(ref: str) -> Optional[Dict[str, str]]:
"""Split an image tag into repository/tag, preserving registry ports."""
s = str(ref or "").strip()
if not s or "@" in s or s == "<none>:<none>":
return None
last_slash = s.rfind("/")
last_colon = s.rfind(":")
if last_colon > last_slash:
repository = s[:last_colon]
tag = s[last_colon + 1 :]
else:
repository = s
tag = "latest"
if not repository or not tag:
return None
return {"ref": s, "repository": repository, "tag": tag}
def _tag_aliases(value: Any) -> List[Dict[str, str]]:
out: List[Dict[str, str]] = []
seen = set()
for ref in _as_string_list(value):
item = _split_tag_ref(ref)
if not item:
continue
key = (item["repository"], item["tag"])
if key in seen:
continue
seen.add(key)
out.append(item)
return out
def _platform_from_inspect(
item: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
os_name = item.get("Os") or item.get("OS")
arch = item.get("Architecture") or item.get("Arch")
variant = item.get("Variant")
os_s = str(os_name).strip() if os_name not in (None, "") else None
arch_s = str(arch).strip() if arch not in (None, "") else None
variant_s = str(variant).strip() if variant not in (None, "") else None
platform = None
if os_s and arch_s:
platform = f"{os_s}/{arch_s}"
if variant_s:
platform = f"{platform}/{variant_s}"
return os_s, arch_s, variant_s, platform
def _run_command(
argv: Sequence[str], *, timeout: int = 20
) -> subprocess.CompletedProcess[str]:
return subprocess.run( # nosec - argv is constructed from fixed binary names and image ids
list(argv),
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
)
def _chunks(items: Sequence[str], size: int) -> Iterable[List[str]]:
for i in range(0, len(items), size):
yield list(items[i : i + size])
class ContainerImagesCollector(HarvestCollector):
"""Collect local Docker and Podman image metadata.
The harvest records pullable registry digests where present. Local image IDs
are kept as evidence but are not treated as pull references.
"""
def collect(self) -> ContainerImagesSnapshot:
images: List[Dict[str, Any]] = []
notes: List[str] = []
images.extend(self._collect_engine("docker", notes=notes))
images.extend(self._collect_engine("podman", notes=notes))
if images:
digest_count = len([img for img in images if img.get("pull_ref")])
notes.append(
f"Detected {len(images)} container image(s); {digest_count} have registry digests usable for exact pulls."
)
return ContainerImagesSnapshot(
role_name="container_images",
images=images,
notes=notes,
)
def _collect_engine(self, engine: str, *, notes: List[str]) -> List[Dict[str, Any]]:
exe = shutil.which(engine)
if not exe:
return []
try:
listed = _run_command([exe, "image", "ls", "-q", "--no-trunc"])
except Exception as exc:
notes.append(f"Failed to list {engine} images: {exc!r}")
return []
if listed.returncode != 0:
detail = (listed.stderr or listed.stdout or "").strip()
if detail:
notes.append(f"Failed to list {engine} images: {detail}")
else:
notes.append(
f"Failed to list {engine} images: exit {listed.returncode}"
)
return []
image_ids = []
seen_ids = set()
for line in listed.stdout.splitlines():
image_id = _normalise_image_id(line)
if not image_id or image_id in seen_ids:
continue
seen_ids.add(image_id)
image_ids.append(image_id)
if not image_ids:
return []
out: List[Dict[str, Any]] = []
for chunk in _chunks(image_ids, 40):
try:
inspected = _run_command([exe, "image", "inspect", *chunk])
except Exception as exc:
notes.append(f"Failed to inspect {engine} images: {exc!r}")
continue
if inspected.returncode != 0:
detail = (inspected.stderr or inspected.stdout or "").strip()
notes.append(
f"Failed to inspect {engine} images {', '.join(chunk[:3])}: {detail or inspected.returncode}"
)
continue
try:
data = json.loads(inspected.stdout or "[]")
except json.JSONDecodeError as exc:
notes.append(f"Failed to parse {engine} image inspect JSON: {exc}")
continue
if not isinstance(data, list):
notes.append(f"Unexpected {engine} image inspect JSON shape")
continue
for item in data:
if isinstance(item, dict):
normalised = self._normalise_inspect(engine, item)
if normalised is not None:
out.append(normalised)
return out
def _normalise_inspect(
self, engine: str, item: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
image_id = _normalise_image_id(item.get("Id") or item.get("ID"))
repo_tags = _as_string_list(item.get("RepoTags"))
repo_digests = _pullable_digests(item.get("RepoDigests"))
pull_ref = sorted(repo_digests)[0] if repo_digests else None
os_name, arch, variant, platform = _platform_from_inspect(item)
if not image_id and not repo_tags and not repo_digests:
return None
notes: List[str] = []
if not pull_ref:
if repo_tags:
notes.append(
"Image has tag(s) but no RepoDigest; exact digest-pinned pull cannot be rendered."
)
else:
notes.append(
"Image has no tag or RepoDigest; local-only/dangling images cannot be pulled from a registry."
)
out: Dict[str, Any] = {
"engine": engine,
"scope": "system",
"user": None,
"home": None,
"image_id": image_id,
"repo_tags": repo_tags,
"repo_digests": repo_digests,
"pull_ref": pull_ref,
"tag_aliases": _tag_aliases(repo_tags),
"os": os_name,
"architecture": arch,
"variant": variant,
"platform": platform,
"size": item.get("Size"),
"created": item.get("Created"),
"source": f"{engine} image inspect",
"notes": notes,
}
return out

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Set
from ..ignore import IgnorePolicy
from ..pathfilter import PathFilter
@dataclass
class HarvestContext:
"""Shared context passed to feature collectors."""
bundle_dir: str
policy: IgnorePolicy
path_filter: PathFilter
platform: Dict[str, Any]
backend: Any
installed_pkgs: Dict[str, Any]
installed_names: Set[str]
owned_etc: Set[str]
etc_owner_map: Dict[str, str]
topdir_to_pkgs: Dict[str, Set[str]]
pkg_to_etc_paths: Dict[str, List[str]]
captured_global: Set[str]
class HarvestCollector:
"""Base class for harvest feature collectors."""
def __init__(self, context: HarvestContext) -> None:
self.context = context

View file

@ -0,0 +1,161 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import List, Optional, Set
from ..capture import capture_file
from ..harvest_types import ExcludedFile, ManagedFile, PackageSnapshot
from ..package_hints import package_section_from_installations
from ..system_paths import iter_matching_files
from .context import HarvestCollector
def _pick_installed(installed_names: Set[str], candidates: List[str]) -> Optional[str]:
for candidate in candidates:
if candidate in installed_names:
return candidate
return None
def _is_cron_path(path: str) -> bool:
return (
path == "/etc/crontab"
or path == "/etc/anacrontab"
or path in ("/etc/cron.allow", "/etc/cron.deny")
or path.startswith("/etc/cron.")
or path.startswith("/etc/cron.d/")
or path.startswith("/etc/anacron/")
or path.startswith("/var/spool/cron/")
or path.startswith("/var/spool/crontabs/")
or path.startswith("/var/spool/anacron/")
)
def _is_logrotate_path(path: str) -> bool:
return path == "/etc/logrotate.conf" or path.startswith("/etc/logrotate.d/")
_CRON_CAPTURE_GLOBS = [
"/etc/crontab",
"/etc/cron.d/*",
"/etc/cron.hourly/*",
"/etc/cron.daily/*",
"/etc/cron.weekly/*",
"/etc/cron.monthly/*",
"/etc/cron.allow",
"/etc/cron.deny",
"/etc/anacrontab",
"/etc/anacron/*",
# user crontabs / spool state
"/var/spool/cron/*",
"/var/spool/cron/crontabs/*",
"/var/spool/crontabs/*",
"/var/spool/anacron/*",
]
_LOGROTATE_CAPTURE_GLOBS = [
"/etc/logrotate.conf",
"/etc/logrotate.d/*",
]
@dataclass
class CronLogrotateCollection:
cron_pkg: Optional[str]
logrotate_pkg: Optional[str]
cron_snapshot: Optional[PackageSnapshot]
logrotate_snapshot: Optional[PackageSnapshot]
class CronLogrotateCollector(HarvestCollector):
"""Collect dedicated cron/logrotate package roles before general packages."""
cron_role_name = "cron"
logrotate_role_name = "logrotate"
def collect(self) -> CronLogrotateCollection:
cron_pkg = _pick_installed(
self.context.installed_names,
["cron", "cronie", "cronie-anacron", "vixie-cron", "fcron"],
)
logrotate_pkg = _pick_installed(self.context.installed_names, ["logrotate"])
cron_snapshot = self._collect_cron_snapshot(cron_pkg) if cron_pkg else None
logrotate_snapshot = (
self._collect_logrotate_snapshot(logrotate_pkg) if logrotate_pkg else None
)
return CronLogrotateCollection(
cron_pkg=cron_pkg,
logrotate_pkg=logrotate_pkg,
cron_snapshot=cron_snapshot,
logrotate_snapshot=logrotate_snapshot,
)
def _collect_cron_snapshot(self, cron_pkg: str) -> PackageSnapshot:
managed: List[ManagedFile] = []
excluded: List[ExcludedFile] = []
notes: List[str] = []
seen: Set[str] = set()
for spec in _CRON_CAPTURE_GLOBS:
for path in iter_matching_files(spec):
if not os.path.isfile(path) or os.path.islink(path):
continue
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=self.cron_role_name,
abs_path=path,
reason="system_cron",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=managed,
excluded_out=excluded,
seen_role=seen,
seen_global=self.context.captured_global,
)
return PackageSnapshot(
package=cron_pkg,
role_name=self.cron_role_name,
section=package_section_from_installations(
self.context.installed_pkgs.get(cron_pkg, [])
),
managed_files=managed,
excluded=excluded,
notes=notes,
)
def _collect_logrotate_snapshot(self, logrotate_pkg: str) -> PackageSnapshot:
managed: List[ManagedFile] = []
excluded: List[ExcludedFile] = []
notes: List[str] = []
seen: Set[str] = set()
for spec in _LOGROTATE_CAPTURE_GLOBS:
for path in iter_matching_files(spec):
if not os.path.isfile(path) or os.path.islink(path):
continue
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=self.logrotate_role_name,
abs_path=path,
reason="system_logrotate",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=managed,
excluded_out=excluded,
seen_role=seen,
seen_global=self.context.captured_global,
)
return PackageSnapshot(
package=logrotate_pkg,
role_name=self.logrotate_role_name,
section=package_section_from_installations(
self.context.installed_pkgs.get(logrotate_pkg, [])
),
managed_files=managed,
excluded=excluded,
notes=notes,
)

View file

@ -0,0 +1,87 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Set
from ..capture import capture_file
from ..harvest_types import (
AptConfigSnapshot,
DnfConfigSnapshot,
ExcludedFile,
ManagedFile,
)
from ..system_paths import iter_apt_capture_paths, iter_dnf_capture_paths
from .context import HarvestCollector, HarvestContext
@dataclass
class PackageManagerConfigCollection:
apt_config_snapshot: AptConfigSnapshot
dnf_config_snapshot: DnfConfigSnapshot
class PackageManagerConfigCollector(HarvestCollector):
"""Collect package-manager configuration into existing role snapshots."""
def __init__(
self, context: HarvestContext, seen_by_role: Dict[str, Set[str]]
) -> None:
super().__init__(context)
self.seen_by_role = seen_by_role
def collect(self) -> PackageManagerConfigCollection:
apt_notes: List[str] = []
apt_excluded: List[ExcludedFile] = []
apt_managed: List[ManagedFile] = []
dnf_notes: List[str] = []
dnf_excluded: List[ExcludedFile] = []
dnf_managed: List[ManagedFile] = []
apt_role_name = "apt_config"
dnf_role_name = "dnf_config"
if self.context.backend.name == "dpkg":
apt_role_seen = self.seen_by_role.setdefault(apt_role_name, set())
for path, reason in iter_apt_capture_paths():
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=apt_role_name,
abs_path=path,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=apt_managed,
excluded_out=apt_excluded,
seen_role=apt_role_seen,
seen_global=self.context.captured_global,
)
elif self.context.backend.name == "rpm":
dnf_role_seen = self.seen_by_role.setdefault(dnf_role_name, set())
for path, reason in iter_dnf_capture_paths():
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=dnf_role_name,
abs_path=path,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=dnf_managed,
excluded_out=dnf_excluded,
seen_role=dnf_role_seen,
seen_global=self.context.captured_global,
)
return PackageManagerConfigCollection(
apt_config_snapshot=AptConfigSnapshot(
role_name=apt_role_name,
managed_files=apt_managed,
excluded=apt_excluded,
notes=apt_notes,
),
dnf_config_snapshot=DnfConfigSnapshot(
role_name=dnf_role_name,
managed_files=dnf_managed,
excluded=dnf_excluded,
notes=dnf_notes,
),
)

View file

@ -0,0 +1,286 @@
from __future__ import annotations
import glob
import os
from typing import Dict, List, Optional, Set
from .. import harvest as h
from ..capture import capture_file, capture_link
from ..harvest_types import (
ExcludedFile,
ExtraPathsSnapshot,
ManagedDir,
ManagedFile,
ManagedLink,
UsrLocalCustomSnapshot,
)
from ..system_paths import MAX_FILES_CAP
from ..pathfilter import expand_includes
from .context import HarvestCollector, HarvestContext
class UsrLocalCustomCollector(HarvestCollector):
"""Collect selected /usr/local state into the usr_local_custom role."""
role_name = "usr_local_custom"
def __init__(
self,
context: HarvestContext,
seen_by_role: Dict[str, Set[str]],
already_all: Set[str],
) -> None:
super().__init__(context)
self.seen_by_role = seen_by_role
self.already_all = already_all
self.notes: List[str] = []
self.excluded: List[ExcludedFile] = []
self.managed: List[ManagedFile] = []
def collect(self) -> UsrLocalCustomSnapshot:
self._scan_tree(
"/usr/local/etc",
require_executable=False,
cap=MAX_FILES_CAP,
reason="usr_local_etc_custom",
)
self._scan_tree(
"/usr/local/bin",
require_executable=True,
cap=MAX_FILES_CAP,
reason="usr_local_bin_script",
)
return UsrLocalCustomSnapshot(
role_name=self.role_name,
managed_files=self.managed,
excluded=self.excluded,
notes=self.notes,
)
def _scan_tree(
self,
root: str,
*,
require_executable: bool,
cap: int,
reason: str,
) -> None:
scanned = 0
if not os.path.isdir(root):
return
role_seen = self.seen_by_role.setdefault(self.role_name, set())
for dirpath, _, filenames in os.walk(root):
for filename in filenames:
path = os.path.join(dirpath, filename)
if path in self.already_all:
continue
if not os.path.isfile(path) or os.path.islink(path):
continue
try:
owner, group, mode = h.stat_triplet(path)
except OSError:
self.excluded.append(ExcludedFile(path=path, reason="unreadable"))
continue
if require_executable:
try:
if (int(mode, 8) & 0o111) == 0:
continue
except ValueError:
continue
if capture_file(
bundle_dir=self.context.bundle_dir,
role_name=self.role_name,
abs_path=path,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=self.managed,
excluded_out=self.excluded,
seen_role=role_seen,
seen_global=self.context.captured_global,
metadata=(owner, group, mode),
):
self.already_all.add(path)
scanned += 1
if scanned >= cap:
self.notes.append(
f"Reached file cap ({cap}) while scanning {root}."
)
return
class ExtraPathsCollector(HarvestCollector):
"""Collect user-requested include/exclude paths into extra_paths."""
role_name = "extra_paths"
def __init__(
self,
context: HarvestContext,
seen_by_role: Dict[str, Set[str]],
already_all: Set[str],
*,
include_paths: Optional[List[str]] = None,
exclude_paths: Optional[List[str]] = None,
) -> None:
super().__init__(context)
self.seen_by_role = seen_by_role
self.already_all = already_all
self.include_specs = list(include_paths or [])
self.exclude_specs = list(exclude_paths or [])
self.notes: List[str] = []
self.excluded: List[ExcludedFile] = []
self.managed: List[ManagedFile] = []
self.managed_links: List[ManagedLink] = []
self.managed_dirs: List[ManagedDir] = []
self.dir_seen: Set[str] = set()
def collect(self) -> ExtraPathsSnapshot:
self._collect_included_dirs()
if self.include_specs:
self.notes.append("User include patterns:")
self.notes.extend([f"- {p}" for p in self.include_specs])
if self.exclude_specs:
self.notes.append("User exclude patterns:")
self.notes.extend([f"- {p}" for p in self.exclude_specs])
included_files: List[str] = []
if self.include_specs:
files, inc_notes = expand_includes(
self.context.path_filter.iter_include_patterns(),
exclude=self.context.path_filter,
max_files=MAX_FILES_CAP,
)
included_files = files
self.notes.extend(inc_notes)
role_seen = self.seen_by_role.setdefault(self.role_name, set())
for path in included_files:
if path in self.already_all:
continue
if capture_file(
bundle_dir=self.context.bundle_dir,
role_name=self.role_name,
abs_path=path,
reason="user_include",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=self.managed,
excluded_out=self.excluded,
seen_role=role_seen,
seen_global=self.context.captured_global,
):
self.already_all.add(path)
return ExtraPathsSnapshot(
role_name=self.role_name,
include_patterns=self.include_specs,
exclude_patterns=self.exclude_specs,
managed_dirs=self.managed_dirs,
managed_files=self.managed,
managed_links=self.managed_links,
excluded=self.excluded,
notes=self.notes,
)
def _collect_included_dirs(self) -> None:
role_seen = self.seen_by_role.setdefault(self.role_name, set())
for pat in self.context.path_filter.iter_include_patterns():
if pat.kind == "prefix":
path = pat.value
if os.path.islink(path):
self._capture_included_link(path, role_seen)
elif os.path.isdir(path):
self._walk_and_capture_dirs(path, role_seen)
elif pat.kind == "glob":
for hit in glob.glob(pat.value, recursive=True):
if os.path.islink(hit):
self._capture_included_link(hit, role_seen)
elif os.path.isdir(hit):
self._walk_and_capture_dirs(hit, role_seen)
def _capture_included_link(self, path: str, role_seen: Set[str]) -> None:
path = os.path.normpath(path)
if not path.startswith("/"):
path = "/" + path
if path in self.already_all:
return
if capture_link(
role_name=self.role_name,
abs_path=path,
reason="user_include_link",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=self.managed_links,
excluded_out=self.excluded,
seen_role=role_seen,
seen_global=self.context.captured_global,
):
self.already_all.add(path)
def _walk_and_capture_dirs(self, root: str, role_seen: Set[str]) -> None:
root = os.path.normpath(root)
if not root.startswith("/"):
root = "/" + root
if not os.path.isdir(root) or os.path.islink(root):
return
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
if len(self.managed_dirs) >= MAX_FILES_CAP:
self.notes.append(
f"Reached directory cap ({MAX_FILES_CAP}) while scanning {root}."
)
return
dirpath = os.path.normpath(dirpath)
if not dirpath.startswith("/"):
dirpath = "/" + dirpath
if self.context.path_filter.is_excluded(dirpath):
dirnames[:] = []
continue
if os.path.islink(dirpath) or not os.path.isdir(dirpath):
dirnames[:] = []
continue
if dirpath not in self.dir_seen:
deny = None
deny_dir = getattr(self.context.policy, "deny_reason_dir", None)
if callable(deny_dir):
deny = deny_dir(dirpath)
else:
deny = self.context.policy.deny_reason(dirpath)
if deny in ("not_regular_file", "not_file", "not_regular"):
deny = None
if not deny:
try:
owner, group, mode = h.stat_triplet(dirpath)
self.managed_dirs.append(
ManagedDir(
path=dirpath,
owner=owner,
group=group,
mode=mode,
reason="user_include_dir",
)
)
except OSError:
pass
self.dir_seen.add(dirpath)
pruned: List[str] = []
for dirname in dirnames:
path = os.path.join(dirpath, dirname)
if self.context.path_filter.is_excluded(path):
continue
if os.path.islink(path):
self._capture_included_link(path, role_seen)
continue
pruned.append(dirname)
dirnames[:] = pruned
for filename in filenames:
path = os.path.join(dirpath, filename)
if self.context.path_filter.is_excluded(path):
continue
if os.path.islink(path):
self._capture_included_link(path, role_seen)

View file

@ -0,0 +1,64 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import List, Optional
from .. import harvest as h
from ..harvest_types import FirewallRuntimeSnapshot, SysctlSnapshot
from .context import HarvestCollector, HarvestContext
@dataclass
class RuntimeStateCollection:
firewall_runtime_snapshot: FirewallRuntimeSnapshot
sysctl_snapshot: SysctlSnapshot
class RuntimeStateCollector(HarvestCollector):
"""Collect root-only live runtime state that has generated roles."""
def __init__(
self,
context: HarvestContext,
*,
persistent_ipset_files: Optional[List[str]] = None,
persistent_iptables_v4_files: Optional[List[str]] = None,
persistent_iptables_v6_files: Optional[List[str]] = None,
) -> None:
super().__init__(context)
self.persistent_ipset_files = persistent_ipset_files or []
self.persistent_iptables_v4_files = persistent_iptables_v4_files or []
self.persistent_iptables_v6_files = persistent_iptables_v6_files or []
def collect(self) -> RuntimeStateCollection:
running_as_root = not hasattr(os, "geteuid") or os.geteuid() == 0
if not running_as_root:
return RuntimeStateCollection(
firewall_runtime_snapshot=FirewallRuntimeSnapshot(
role_name="firewall_runtime",
notes=[
"Live ipset/iptables runtime capture skipped because harvest "
"is not running as root."
],
),
sysctl_snapshot=SysctlSnapshot(
role_name="sysctl",
notes=[
"Live sysctl runtime capture skipped because harvest is not "
"running as root."
],
),
)
firewall_runtime_snapshot = h._collect_firewall_runtime_snapshot(
self.context.bundle_dir,
persistent_ipset_files=self.persistent_ipset_files,
persistent_iptables_v4_files=self.persistent_iptables_v4_files,
persistent_iptables_v6_files=self.persistent_iptables_v6_files,
)
sysctl_snapshot = h._collect_sysctl_snapshot(self.context.bundle_dir)
return RuntimeStateCollection(
firewall_runtime_snapshot=firewall_runtime_snapshot,
sysctl_snapshot=sysctl_snapshot,
)

View file

@ -0,0 +1,541 @@
from __future__ import annotations
import glob
import os
from dataclasses import dataclass
from typing import Dict, List, Optional, Set
from .. import harvest as h
from ..capture import capture_file, capture_link
from ..harvest_types import ExcludedFile, ManagedFile, PackageSnapshot, ServiceSnapshot
from ..package_hints import (
SHARED_ETC_TOPDIRS,
add_pkgs_from_etc_topdirs,
hint_names,
maybe_add_specific_paths,
package_section_from_installations,
role_name_from_pkg,
role_name_from_unit,
)
from ..system_paths import (
MAX_UNOWNED_FILES_PER_ROLE,
is_confish,
scan_unowned_under_roots,
topdirs_for_package,
)
from ..systemd import UnitQueryError
from .context import HarvestCollector, HarvestContext
from .cron_logrotate import CronLogrotateCollector, _is_cron_path, _is_logrotate_path
@dataclass
class ServicePackageCollection:
service_snaps: List[ServiceSnapshot]
pkg_snaps: List[PackageSnapshot]
manual_pkgs: List[str]
simple_packages: List[str]
manual_pkgs_skipped: List[str]
service_role_aliases: Dict[str, Set[str]]
seen_by_role: Dict[str, Set[str]]
class ServicePackageCollector(HarvestCollector):
"""Collect service-attributed and manually-installed package snapshots."""
def __init__(
self,
context: HarvestContext,
*,
cron_snapshot: Optional[PackageSnapshot] = None,
logrotate_snapshot: Optional[PackageSnapshot] = None,
cron_pkg: Optional[str] = None,
logrotate_pkg: Optional[str] = None,
) -> None:
super().__init__(context)
self.cron_snapshot = cron_snapshot
self.logrotate_snapshot = logrotate_snapshot
self.cron_pkg = cron_pkg
self.logrotate_pkg = logrotate_pkg
self.service_role_aliases: Dict[str, Set[str]] = {}
self.seen_by_role: Dict[str, Set[str]] = {}
self.managed_by_role: Dict[str, List[ManagedFile]] = {}
self.excluded_by_role: Dict[str, List[ExcludedFile]] = {}
def collect(self) -> ServicePackageCollection:
service_snaps, timer_extra_by_pkg = self._collect_service_snapshots()
pkg_snaps, manual_pkgs, simple_packages, manual_pkgs_skipped = (
self._collect_package_snapshots(
service_snaps,
timer_extra_by_pkg,
)
)
self._capture_common_enabled_symlinks(service_snaps, pkg_snaps)
return ServicePackageCollection(
service_snaps=service_snaps,
pkg_snaps=pkg_snaps,
manual_pkgs=manual_pkgs,
simple_packages=simple_packages,
manual_pkgs_skipped=manual_pkgs_skipped,
service_role_aliases=self.service_role_aliases,
seen_by_role=self.seen_by_role,
)
def _collect_service_snapshots(
self,
) -> tuple[List[ServiceSnapshot], Dict[str, List[str]]]:
backend = self.context.backend
service_snaps: List[ServiceSnapshot] = []
enabled_services = h.list_enabled_services()
if self.cron_snapshot is not None or self.logrotate_snapshot is not None:
blocked_roles = set()
if self.cron_snapshot is not None:
blocked_roles.add(CronLogrotateCollector.cron_role_name)
if self.logrotate_snapshot is not None:
blocked_roles.add(CronLogrotateCollector.logrotate_role_name)
enabled_services = [
u
for u in enabled_services
if role_name_from_unit(u) not in blocked_roles
]
enabled_set = set(enabled_services)
def service_sort_key(unit: str) -> tuple[int, str, str]:
base = unit.removesuffix(".service")
base = base.split("@", 1)[0]
return (base.count("-"), base.lower(), unit.lower())
def parent_service_unit(unit: str) -> Optional[str]:
if not unit.endswith(".service"):
return None
base = unit.removesuffix(".service")
base = base.split("@", 1)[0]
parts = base.split("-")
for i in range(len(parts) - 1, 0, -1):
cand = "-".join(parts[:i]) + ".service"
if cand in enabled_set:
return cand
return None
parent_unit_for = {
u: pu for u in enabled_services if (pu := parent_service_unit(u))
}
for unit in sorted(enabled_services, key=service_sort_key):
role = role_name_from_unit(unit)
parent_unit = parent_unit_for.get(unit)
parent_role = role_name_from_unit(parent_unit) if parent_unit else None
try:
ui = h.get_unit_info(unit)
except UnitQueryError as e:
self.service_role_aliases.setdefault(
role, hint_names(unit, set()) | {role}
)
self.seen_by_role.setdefault(role, set())
managed = self.managed_by_role.setdefault(role, [])
excluded = self.excluded_by_role.setdefault(role, [])
service_snaps.append(
ServiceSnapshot(
unit=unit,
role_name=role,
packages=[],
active_state=None,
sub_state=None,
unit_file_state=None,
condition_result=None,
managed_files=managed,
excluded=excluded,
notes=[str(e)],
)
)
continue
pkgs: Set[str] = set()
notes: List[str] = []
excluded = self.excluded_by_role.setdefault(role, [])
managed = self.managed_by_role.setdefault(role, [])
candidates: Dict[str, str] = {}
if ui.fragment_path:
p = backend.owner_of_path(ui.fragment_path)
if p:
pkgs.add(p)
for exe in ui.exec_paths:
p = backend.owner_of_path(exe)
if p:
pkgs.add(p)
for pth in ui.dropin_paths:
if pth.startswith("/etc/"):
candidates[pth] = "systemd_dropin"
for env_file in ui.env_files:
env_file = env_file.lstrip("-")
if any(ch in env_file for ch in "*?["):
for g in glob.glob(env_file):
if g.startswith("/etc/") and os.path.isfile(g):
candidates[g] = "systemd_envfile"
elif env_file.startswith("/etc/") and os.path.isfile(env_file):
candidates[env_file] = "systemd_envfile"
hints = hint_names(unit, pkgs)
add_pkgs_from_etc_topdirs(hints, self.context.topdir_to_pkgs, pkgs)
self.service_role_aliases[role] = set(hints) | set(pkgs) | {role}
for sp in maybe_add_specific_paths(hints, backend):
if not os.path.exists(sp):
continue
if sp in self.context.etc_owner_map:
pkgs.add(self.context.etc_owner_map[sp])
else:
candidates.setdefault(sp, "custom_specific_path")
for pkg in sorted(pkgs):
etc_paths = self.context.pkg_to_etc_paths.get(pkg, [])
for path, reason in backend.modified_paths(pkg, etc_paths).items():
if not os.path.isfile(path) or os.path.islink(path):
continue
if self.cron_snapshot is not None and _is_cron_path(path):
continue
if self.logrotate_snapshot is not None and _is_logrotate_path(path):
continue
if backend.is_pkg_config_path(path):
continue
candidates.setdefault(path, reason)
any_roots: List[str] = []
confish_roots: List[str] = []
for hint in hints:
roots_for_hint = [f"/etc/{hint}", f"/etc/{hint}.d"]
if hint in SHARED_ETC_TOPDIRS:
confish_roots.extend(roots_for_hint)
else:
any_roots.extend(roots_for_hint)
found: List[str] = []
found.extend(
scan_unowned_under_roots(
any_roots,
self.context.owned_etc,
limit=MAX_UNOWNED_FILES_PER_ROLE,
confish_only=False,
)
)
if len(found) < MAX_UNOWNED_FILES_PER_ROLE:
found.extend(
scan_unowned_under_roots(
confish_roots,
self.context.owned_etc,
limit=MAX_UNOWNED_FILES_PER_ROLE - len(found),
confish_only=True,
)
)
for pth in found:
candidates.setdefault(pth, "custom_unowned")
if not pkgs and not candidates:
notes.append(
"No packages or /etc candidates detected (unexpected for enabled service)."
)
for path, reason in sorted(candidates.items()):
dest_role = role
if (
parent_role
and path.startswith("/etc/")
and reason not in ("systemd_dropin", "systemd_envfile")
):
dest_role = parent_role
dest_managed = self.managed_by_role.setdefault(dest_role, [])
dest_excluded = self.excluded_by_role.setdefault(dest_role, [])
dest_seen = self.seen_by_role.setdefault(dest_role, set())
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=dest_role,
abs_path=path,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=dest_managed,
excluded_out=dest_excluded,
seen_role=dest_seen,
seen_global=self.context.captured_global,
)
service_snaps.append(
ServiceSnapshot(
unit=unit,
role_name=role,
packages=sorted(pkgs),
active_state=ui.active_state,
sub_state=ui.sub_state,
unit_file_state=ui.unit_file_state,
condition_result=ui.condition_result,
managed_files=managed,
excluded=excluded,
notes=notes,
)
)
timer_extra_by_pkg = self._collect_timer_overrides(service_snaps)
return service_snaps, timer_extra_by_pkg
def _collect_timer_overrides(
self,
service_snaps: List[ServiceSnapshot],
) -> Dict[str, List[str]]:
backend = self.context.backend
timer_extra_by_pkg: Dict[str, List[str]] = {}
try:
enabled_timers = h.list_enabled_timers()
except Exception:
enabled_timers = []
service_snap_by_unit = {s.unit: s for s in service_snaps}
for timer in sorted(enabled_timers):
try:
ti = h.get_timer_info(timer)
except Exception: # nosec
continue
timer_paths: List[str] = []
for pth in [ti.fragment_path, *ti.dropin_paths, *ti.env_files]:
if not pth:
continue
if not pth.startswith("/etc/"):
continue
if os.path.islink(pth) or not os.path.isfile(pth):
continue
timer_paths.append(pth)
if not timer_paths:
continue
snap = (
service_snap_by_unit.get(ti.trigger_unit) if ti.trigger_unit else None
)
if snap is not None:
role_seen = self.seen_by_role.setdefault(snap.role_name, set())
for path in timer_paths:
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=snap.role_name,
abs_path=path,
reason="related_timer",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=snap.managed_files,
excluded_out=snap.excluded,
seen_role=role_seen,
seen_global=self.context.captured_global,
)
continue
pkgs: Set[str] = set()
if ti.fragment_path:
p = backend.owner_of_path(ti.fragment_path)
if p:
pkgs.add(p)
if ti.trigger_unit and ti.trigger_unit.endswith(".service"):
try:
ui = h.get_unit_info(ti.trigger_unit)
if ui.fragment_path:
p = backend.owner_of_path(ui.fragment_path)
if p:
pkgs.add(p)
for exe in ui.exec_paths:
p = backend.owner_of_path(exe)
if p:
pkgs.add(p)
except Exception: # nosec
pass
for pkg in pkgs:
timer_extra_by_pkg.setdefault(pkg, []).extend(timer_paths)
return timer_extra_by_pkg
def _collect_package_snapshots(
self,
service_snaps: List[ServiceSnapshot],
timer_extra_by_pkg: Dict[str, List[str]],
) -> tuple[List[PackageSnapshot], List[str], List[str], List[str]]:
backend = self.context.backend
manual_pkgs = backend.list_manual_packages()
covered_by_services: Set[str] = set()
for snap in service_snaps:
covered_by_services.update(snap.packages)
manual_pkgs_skipped: List[str] = []
pkg_snaps: List[PackageSnapshot] = []
simple_packages: List[str] = []
if self.cron_snapshot is not None:
pkg_snaps.append(self.cron_snapshot)
if self.logrotate_snapshot is not None:
pkg_snaps.append(self.logrotate_snapshot)
for pkg in sorted(manual_pkgs):
if pkg in covered_by_services:
manual_pkgs_skipped.append(pkg)
continue
if self.cron_snapshot is not None and pkg == self.cron_pkg:
manual_pkgs_skipped.append(pkg)
continue
if self.logrotate_snapshot is not None and pkg == self.logrotate_pkg:
manual_pkgs_skipped.append(pkg)
continue
role = role_name_from_pkg(pkg)
notes: List[str] = []
excluded: List[ExcludedFile] = []
managed: List[ManagedFile] = []
candidates: Dict[str, str] = {}
for tpath in timer_extra_by_pkg.get(pkg, []):
candidates.setdefault(tpath, "related_timer")
etc_paths = self.context.pkg_to_etc_paths.get(pkg, [])
for path, reason in backend.modified_paths(pkg, etc_paths).items():
if not os.path.isfile(path) or os.path.islink(path):
continue
if self.cron_snapshot is not None and _is_cron_path(path):
continue
if self.logrotate_snapshot is not None and _is_logrotate_path(path):
continue
if backend.is_pkg_config_path(path):
continue
candidates.setdefault(path, reason)
topdirs = topdirs_for_package(pkg, self.context.pkg_to_etc_paths)
roots: List[str] = []
for topdir in sorted(topdirs):
if topdir in SHARED_ETC_TOPDIRS:
continue
if backend.is_pkg_config_path(
f"/etc/{topdir}/"
) or backend.is_pkg_config_path(f"/etc/{topdir}"):
continue
roots.extend([f"/etc/{topdir}", f"/etc/{topdir}.d"])
roots.extend(maybe_add_specific_paths(set(topdirs), backend))
for pth in scan_unowned_under_roots(
[r for r in roots if os.path.isdir(r)],
self.context.owned_etc,
confish_only=False,
):
candidates.setdefault(pth, "custom_unowned")
for root in roots:
if os.path.isfile(root) and not os.path.islink(root):
if root not in self.context.owned_etc and is_confish(root):
candidates.setdefault(root, "custom_specific_path")
role_seen = self.seen_by_role.setdefault(role, set())
for path, reason in sorted(candidates.items()):
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=role,
abs_path=path,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=managed,
excluded_out=excluded,
seen_role=role_seen,
seen_global=self.context.captured_global,
)
has_config = bool(managed or excluded)
if not has_config:
notes.append(
"No changed or custom configuration detected for this package."
)
simple_packages.append(pkg)
pkg_snaps.append(
PackageSnapshot(
package=pkg,
role_name=role,
section=package_section_from_installations(
self.context.installed_pkgs.get(pkg, [])
),
managed_files=managed,
managed_links=[],
excluded=excluded,
notes=notes,
has_config=has_config,
)
)
return pkg_snaps, manual_pkgs, simple_packages, manual_pkgs_skipped
def _find_role_snapshot(
self,
role_name: str,
service_snaps: List[ServiceSnapshot],
pkg_snaps: List[PackageSnapshot],
):
for snap in service_snaps:
if snap.role_name == role_name:
return snap
for snap in pkg_snaps:
if snap.role_name == role_name:
return snap
return None
def _capture_enabled_symlinks_for_role(
self,
role_name: str,
dirs: List[str],
service_snaps: List[ServiceSnapshot],
pkg_snaps: List[PackageSnapshot],
) -> None:
snap = self._find_role_snapshot(role_name, service_snaps, pkg_snaps)
if snap is None:
return
role_seen = self.seen_by_role.setdefault(role_name, set())
for directory in dirs:
if not os.path.isdir(directory):
continue
for pth in sorted(glob.glob(os.path.join(directory, "*"))):
if not os.path.islink(pth):
continue
capture_link(
role_name=role_name,
abs_path=pth,
reason="enabled_symlink",
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=snap.managed_links,
excluded_out=snap.excluded,
seen_role=role_seen,
seen_global=self.context.captured_global,
)
def _capture_common_enabled_symlinks(
self,
service_snaps: List[ServiceSnapshot],
pkg_snaps: List[PackageSnapshot],
) -> None:
self._capture_enabled_symlinks_for_role(
"nginx",
["/etc/nginx/modules-enabled", "/etc/nginx/sites-enabled"],
service_snaps,
pkg_snaps,
)
self._capture_enabled_symlinks_for_role(
"apache2",
[
"/etc/apache2/conf-enabled",
"/etc/apache2/mods-enabled",
"/etc/apache2/sites-enabled",
],
service_snaps,
pkg_snaps,
)

View file

@ -0,0 +1,168 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any, Dict, List, Set
from .. import harvest as h
from ..capture import capture_file, capture_user_shell_dotfiles
from ..harvest_types import (
ExcludedFile,
FlatpakSnapshot,
ManagedFile,
SnapSnapshot,
UsersSnapshot,
)
from .context import HarvestCollector, HarvestContext
@dataclass
class UsersCollection:
users_snapshot: UsersSnapshot
flatpak_snapshot: FlatpakSnapshot
snap_snapshot: SnapSnapshot
class UsersCollector(HarvestCollector):
"""Collect non-system users plus system/user Flatpak and Snap facts."""
def __init__(
self, context: HarvestContext, seen_by_role: Dict[str, Set[str]]
) -> None:
super().__init__(context)
self.seen_by_role = seen_by_role
def collect(self) -> UsersCollection:
users_notes: List[str] = []
users_excluded: List[ExcludedFile] = []
users_managed: List[ManagedFile] = []
users_list: List[dict] = []
try:
user_records = h.collect_non_system_users()
except Exception as e:
user_records = []
users_notes.append(f"Failed to enumerate users: {e!r}")
# Detect system-wide Flatpaks/Snaps and configured Flatpak remotes.
from ..accounts import (
find_system_flatpak_remotes,
find_system_flatpaks,
find_system_snaps,
find_user_flatpak_remotes,
)
system_flatpaks = [asdict(f) for f in find_system_flatpaks()]
system_snaps = [asdict(s) for s in find_system_snaps()]
system_flatpak_remotes = [asdict(r) for r in find_system_flatpak_remotes()]
flatpak_notes: List[str] = []
snap_notes: List[str] = []
if system_flatpaks:
flatpak_notes.append(
"System-wide flatpaks detected: "
+ ", ".join(str(f.get("name")) for f in system_flatpaks)
)
if system_snaps:
snap_notes.append(
"System-wide snaps detected: "
+ ", ".join(str(s.get("name")) for s in system_snaps)
)
users_role_name = "users"
users_role_seen = self.seen_by_role.setdefault(users_role_name, set())
skel_dir = "/etc/skel"
auto_capture_user_dotfiles = bool(
getattr(self.context.policy, "dangerous", False)
)
if user_records and not auto_capture_user_dotfiles:
users_notes.append(
"User shell dotfiles were not auto-harvested because --dangerous was not set; "
"use --dangerous for automatic shell-dotfile capture, or targeted "
"--include-path patterns for safe-mode review."
)
user_flatpaks_map: Dict[str, List[Dict[str, Any]]] = {}
user_flatpak_remotes: List[Dict[str, Any]] = []
for user in user_records:
users_list.append(
{
"name": user.name,
"uid": user.uid,
"gid": user.gid,
"gecos": user.gecos,
"home": user.home,
"shell": user.shell,
"primary_group": user.primary_group,
"supplementary_groups": user.supplementary_groups,
}
)
# Copy only safe SSH public material: authorized_keys + *.pub
for ssh_file in user.ssh_files:
reason = (
"authorized_keys"
if ssh_file.endswith("/authorized_keys")
else "ssh_public_key"
)
capture_file(
bundle_dir=self.context.bundle_dir,
role_name=users_role_name,
abs_path=ssh_file,
reason=reason,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=users_managed,
excluded_out=users_excluded,
seen_role=users_role_seen,
seen_global=self.context.captured_global,
)
# Capture common per-user shell dotfiles only in dangerous mode. They
# often contain exported tokens or aliases/functions with embedded secrets.
home = (user.home or "").rstrip("/")
if home and home.startswith("/"):
capture_user_shell_dotfiles(
bundle_dir=self.context.bundle_dir,
role_name=users_role_name,
home=home,
skel_dir=skel_dir,
enabled=auto_capture_user_dotfiles,
policy=self.context.policy,
path_filter=self.context.path_filter,
managed_out=users_managed,
excluded_out=users_excluded,
seen_role=users_role_seen,
seen_global=self.context.captured_global,
)
# Collect per-user Flatpak applications and remotes. Snap packages are
# system-wide; ~/snap/* is user data, not an install source.
if user.flatpaks:
user_flatpaks_map[user.name] = [asdict(fp) for fp in user.flatpaks]
user_flatpak_remotes.extend(
asdict(r) for r in find_user_flatpak_remotes(home, user=user.name)
)
return UsersCollection(
users_snapshot=UsersSnapshot(
role_name="users",
users=users_list,
managed_files=users_managed,
excluded=users_excluded,
notes=users_notes,
user_flatpaks=user_flatpaks_map,
user_flatpak_remotes=user_flatpak_remotes,
),
flatpak_snapshot=FlatpakSnapshot(
role_name="flatpak",
system_flatpaks=system_flatpaks,
remotes=system_flatpak_remotes,
notes=flatpak_notes,
),
snap_snapshot=SnapSnapshot(
role_name="snap",
system_snaps=system_snaps,
notes=snap_notes,
),
)

277
enroll/harvest_safety.py Normal file
View file

@ -0,0 +1,277 @@
from __future__ import annotations
import os
import stat
import tempfile
from pathlib import Path
class OutputSafetyError(RuntimeError):
"""Raised when an output path is unsafe for root-run plaintext output."""
# Keep a reference to the real euid getter so tests that monkeypatch
# enroll.harvest.os.geteuid do not accidentally make output-safety code
# believe a non-root test process is running as root. Tests that need to
# exercise root behavior can still monkeypatch _effective_uid directly.
_OS_GETEUID = getattr(os, "geteuid", None)
def _chmod_private(path: Path) -> None:
try:
os.chmod(path, 0o700)
except OSError:
# Best-effort; callers still benefit from mkdir(mode=0o700) on normal FSes.
pass
def _effective_uid() -> int | None:
if _OS_GETEUID is None:
return None
try:
return int(_OS_GETEUID())
except OSError:
return None
def _assert_trusted_root_parent(path: Path, st: os.stat_result, *, label: str) -> None:
"""Reject parent directories that are unsafe when Enroll runs as root.
Enroll deliberately invokes host tools and writes host configuration state,
so root-run output should not pass through parent directories controlled by
an unprivileged user. Root-owned sticky shared directories such as /tmp are
allowed as a boundary, but any existing child below them must still be
root-owned and non-writable by group/other.
"""
if _effective_uid() != 0:
return
if not stat.S_ISDIR(st.st_mode):
raise OutputSafetyError(f"{label} parent is not a directory: {path}")
if st.st_uid != 0:
raise OutputSafetyError(
f"{label} parent is not owned by root; refusing root-run output: {path}"
)
writable_by_group_or_other = st.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
sticky = st.st_mode & stat.S_ISVTX
if writable_by_group_or_other and not sticky:
raise OutputSafetyError(
f"{label} parent is writable by group/other; refusing root-run output: {path}"
)
def _assert_existing_output_dir_component(path: Path, *, label: str) -> None:
try:
st = path.lstat()
except OSError as e:
raise OutputSafetyError(f"unable to inspect {label} parent: {path}") from e
if stat.S_ISLNK(st.st_mode):
raise OutputSafetyError(
f"{label} parent path contains a symlink; refusing: {path}"
)
if not stat.S_ISDIR(st.st_mode):
raise OutputSafetyError(f"{label} parent is not a directory: {path}")
_assert_trusted_root_parent(path, st, label=label)
def _mkdir_private_dir_tree(
path: Path, *, label: str, final_must_be_new: bool = False
) -> Path:
"""Create a directory tree one component at a time with safety checks.
pathlib.mkdir(parents=True) can traverse a symlink inserted after a parent
pre-check and create deeper components in the symlink target. Walking one
component at a time avoids that class of race for root-run output paths.
"""
out = Path(path).expanduser()
parts = out.parts
if not parts:
return out
if out.is_absolute():
cur = Path(parts[0])
rest = parts[1:]
_assert_existing_output_dir_component(cur, label=label)
else:
cur = Path.cwd()
rest = parts
_assert_existing_output_dir_component(cur, label=label)
for idx, part in enumerate(rest):
cur = cur / part
is_final = idx == len(rest) - 1
if os.path.lexists(cur):
if is_final and final_must_be_new:
raise OutputSafetyError(
f"{label} path already exists; refusing to overwrite or merge: {cur}"
)
_assert_existing_output_dir_component(cur, label=label)
continue
try:
os.mkdir(cur, 0o700)
except FileExistsError:
if is_final and final_must_be_new:
raise OutputSafetyError(
f"{label} path already exists; refusing to overwrite or merge: {cur}"
)
_assert_existing_output_dir_component(cur, label=label)
continue
_chmod_private(cur)
_assert_existing_output_dir_component(cur, label=label)
return out
def _assert_no_existing_symlink_components(
path: Path, *, label: str, require_trusted_root_parents: bool = True
) -> None:
"""Reject unsafe existing parent components of an output path.
This catches symlink parents for all users. When running as root, it also
rejects existing parents controlled by an unprivileged user so an attacker
cannot redirect root output by racing or replacing a parent directory.
"""
parts = path.parts
if not parts:
return
if path.is_absolute():
cur = Path(parts[0])
rest = parts[1:-1]
else:
cur = Path.cwd()
rest = parts[:-1]
if require_trusted_root_parents:
_assert_existing_output_dir_component(cur, label=label)
for part in rest:
cur = cur / part
if not os.path.lexists(cur):
return
if require_trusted_root_parents:
_assert_existing_output_dir_component(cur, label=label)
else:
try:
st = cur.lstat()
except OSError as e:
raise OutputSafetyError(
f"unable to inspect {label} parent: {cur}"
) from e
if stat.S_ISLNK(st.st_mode):
raise OutputSafetyError(
f"{label} parent path contains a symlink; refusing: {cur}"
)
def ensure_safe_output_parent(path: str | Path, *, label: str = "output") -> Path:
"""Create and validate the parent directory for a root-run output file.
The parent is checked with the same symlink/root-trust rules as plaintext
bundle directories. This is for output *files* such as reports and SOPS
bundles, where replacing an existing regular file is acceptable but
following attacker-controlled parent paths is not.
"""
out = Path(path).expanduser()
parent = out.parent if out.parent != Path("") else Path(".")
sentinel = parent / ".enroll-output-parent-check"
_assert_no_existing_symlink_components(sentinel, label=label)
_mkdir_private_dir_tree(parent, label=label, final_must_be_new=False)
_assert_no_existing_symlink_components(sentinel, label=label)
return parent
def write_text_output_file(
path: str | Path,
text: str,
*,
label: str = "output file",
mode: int = 0o600,
) -> Path:
"""Safely write a user-facing output text file.
The write is staged in the destination directory and atomically renamed into
place. A final-path symlink is replaced rather than followed, while parent
symlinks or root-unsafe parents are refused by ensure_safe_output_parent().
"""
out = Path(path).expanduser()
parent = ensure_safe_output_parent(out, label=label)
fd, tmp_name = tempfile.mkstemp(prefix=".enroll-output-", dir=str(parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
try:
os.chmod(tmp_name, mode)
except OSError:
pass
os.replace(tmp_name, out)
finally:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass
return out
def ensure_private_dir(path: str | Path, *, label: str = "output") -> Path:
"""Create or validate a private directory without requiring it to be empty.
This is for persistent internal directories such as Enroll's cache root,
where existing contents are expected across runs. It uses the same
component-by-component symlink and root-parent trust checks as user-facing
plaintext output directories, but permits an existing final directory.
"""
out = Path(path).expanduser()
sentinel = out / ".enroll-private-dir-check"
_assert_no_existing_symlink_components(sentinel, label=label)
out = _mkdir_private_dir_tree(out, label=label, final_must_be_new=False)
_assert_no_existing_symlink_components(sentinel, label=label)
_chmod_private(out)
return out
def prepare_new_private_dir(path: str | Path, *, label: str = "output") -> Path:
"""Create a brand-new private output directory.
Refuse existing paths, including symlinks. This prevents root-run harvests
from writing into attacker-precreated directories in shared locations such
as /tmp, and keeps plaintext bundles private by default.
"""
out = Path(path).expanduser()
_assert_no_existing_symlink_components(out, label=label)
return _mkdir_private_dir_tree(out, label=label, final_must_be_new=True)
def ensure_private_empty_dir(path: str | Path, *, label: str = "output") -> Path:
"""Create or validate a private empty directory.
This is for internally-generated random cache/temp directories. User-facing
--out paths should normally use prepare_new_private_dir() instead.
"""
out = Path(path).expanduser()
_assert_no_existing_symlink_components(out, label=label)
if os.path.lexists(out):
try:
st = out.lstat()
except OSError as e:
raise OutputSafetyError(f"unable to inspect {label} path: {out}") from e
if stat.S_ISLNK(st.st_mode):
raise OutputSafetyError(f"{label} path is a symlink; refusing: {out}")
if not stat.S_ISDIR(st.st_mode):
raise OutputSafetyError(
f"{label} path exists but is not a directory: {out}"
)
if any(out.iterdir()):
raise OutputSafetyError(
f"{label} path is not empty; refusing to merge: {out}"
)
_chmod_private(out)
return out
return _mkdir_private_dir_tree(out, label=label, final_must_be_new=True)

172
enroll/harvest_types.py Normal file
View file

@ -0,0 +1,172 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class ManagedFile:
path: str
src_rel: str
owner: str
group: str
mode: str
reason: str
@dataclass
class ManagedLink:
"""A symlink we want to materialise on the target host.
For configuration enablement patterns (e.g. sites-enabled), the symlink is
meaningful state even when the link target is captured elsewhere.
"""
path: str
target: str
reason: str
@dataclass
class ManagedDir:
path: str
owner: str
group: str
mode: str
reason: str
@dataclass
class ExcludedFile:
path: str
reason: str
@dataclass
class ServiceSnapshot:
unit: str
role_name: str
packages: List[str]
active_state: Optional[str]
sub_state: Optional[str]
unit_file_state: Optional[str]
condition_result: Optional[str]
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
managed_links: List[ManagedLink] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass
class PackageSnapshot:
package: str
role_name: str
section: Optional[str] = None
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
managed_links: List[ManagedLink] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
has_config: bool = True # False if package has no config/systemd/cron files
@dataclass
class UsersSnapshot:
role_name: str
users: List[dict]
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
user_flatpaks: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict)
user_flatpak_remotes: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class FlatpakSnapshot:
role_name: str
system_flatpaks: List[Dict[str, Any]] = field(default_factory=list)
remotes: List[Dict[str, Any]] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass
class SnapSnapshot:
role_name: str
system_snaps: List[Dict[str, Any]] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass
class ContainerImagesSnapshot:
role_name: str
images: List[Dict[str, Any]] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass
class AptConfigSnapshot:
role_name: str
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass
class DnfConfigSnapshot:
role_name: str
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass
class EtcCustomSnapshot:
role_name: str
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass
class UsrLocalCustomSnapshot:
role_name: str
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass
class ExtraPathsSnapshot:
role_name: str
include_patterns: List[str] = field(default_factory=list)
exclude_patterns: List[str] = field(default_factory=list)
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
managed_links: List[ManagedLink] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
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)
@dataclass
class SysctlSnapshot:
role_name: str
managed_files: List[ManagedFile] = field(default_factory=list)
parameters: Dict[str, str] = field(default_factory=dict)
notes: List[str] = field(default_factory=list)

View file

@ -1,11 +1,15 @@
from __future__ import annotations
import fnmatch
import errno
import os
import re
import stat
from dataclasses import dataclass
from typing import Optional
from .fsutil import open_no_follow_path
DEFAULT_DENY_GLOBS = [
# Common backup copies created by passwd tools (can contain sensitive data)
@ -46,9 +50,47 @@ DEFAULT_ALLOW_BINARY_GLOBS = [
"/etc/pki/rpm-gpg/*",
]
# Conservative secret patterns for default/safe harvesting. These are
# intentionally biased towards false positives: operators can opt in with
# --dangerous or targeted include/exclude review when a file is genuinely
# needed.
#
# The assignment pattern catches INI/YAML/JSON/TOML-ish keys such as:
# password: hunter2
# "client_secret": "..."
# aws_secret_access_key = ...
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json
SENSITIVE_CONTENT_PATTERNS = [
re.compile(rb"-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----"),
re.compile(rb"(?i)\bpassword\s*="),
re.compile(
rb"-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----"
),
re.compile(rb"(?i)-----BEGIN OPENSSH PRIVATE KEY-----"),
re.compile(rb"(?i)AGE-SECRET-KEY-[A-Z0-9]+"),
re.compile(rb"(?i)OPENSSH PRIVATE KEY"),
re.compile(rb"(?i)PGP PRIVATE KEY BLOCK"),
re.compile(
rb"""(?ix)
(^|[^A-Za-z0-9])
[\"']?
(
[A-Za-z0-9_.-]*
(
password|passwd|passphrase|
token|auth[_-]?token|access[_-]?token|refresh[_-]?token|
secret|client[_-]?secret|secret[_-]?key|
api[_-]?key|access[_-]?key|private[_-]?key|
credential|credentials|
aws[_-]?access[_-]?key[_-]?id|aws[_-]?secret[_-]?access[_-]?key|
azure[_-]?client[_-]?secret|azure[_-]?tenant[_-]?id|azure[_-]?client[_-]?id|
google[_-]?application[_-]?credentials|gcp[_-]?service[_-]?account|
service[_-]?account[_-]?key
)
[A-Za-z0-9_.-]*
)
[\"']?
\s*[:=]
"""
),
re.compile(rb"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"),
]
@ -57,6 +99,42 @@ BLOCK_START = b"/*"
BLOCK_END = b"*/"
def normalize_for_match(path: str) -> str:
"""Lexically normalize a path string for deny/allow glob matching.
This collapses redundant separators ("//"), resolves "." and ".."
segments, and strips trailing slashes using ``os.path.normpath`` -- a
pure string operation that never touches the filesystem.
It is deliberately NOT ``os.path.realpath``/``Path.resolve``: resolving
symlinks would stat the filesystem and reintroduce a time-of-check /
time-of-use window before the later ``O_NOFOLLOW`` open in
``inspect_file``. The goal here is only to stop a non-canonical *string*
(e.g. "/etc//shadow" or "/etc/foo/../shadow") from slipping past a deny
glob like "/etc/shadow". It is defense-in-depth on top of the no-follow
open, not a load-bearing control by itself.
``normpath`` preserves a leading "//" because POSIX treats it as
implementation-defined; for glob matching we collapse it to a single
leading slash so patterns anchored at "/" still match.
"""
if not path:
return path
normalized = os.path.normpath(path)
if normalized.startswith("//") and not normalized.startswith("///"):
normalized = normalized[1:]
return normalized
@dataclass(frozen=True)
class FileInspection:
"""Bytes and metadata captured from one safely-opened source file."""
data: bytes
stat_result: os.stat_result
@dataclass
class IgnorePolicy:
deny_globs: Optional[list[str]] = None
@ -96,42 +174,32 @@ class IgnorePolicy:
yield raw
def deny_reason(self, path: str) -> Optional[str]:
def _path_deny_reason(self, path: str) -> Optional[str]:
# Match against a lexically-normalized path so non-canonical spellings
# (e.g. "/etc//shadow", "/etc/foo/../shadow") cannot slip past a deny
# glob. The original path is still what gets opened/recorded.
match_path = normalize_for_match(path)
# Always ignore plain *.log files (rarely useful as config, often noisy).
if path.endswith(".log"):
if match_path.endswith(".log"):
return "log_file"
# Ignore editor/backup files that end with a trailing tilde.
if path.endswith("~"):
if match_path.endswith("~"):
return "backup_file"
# Ignore backup shadow files
if path.startswith("/etc/") and path.endswith("-"):
if match_path.startswith("/etc/") and match_path.endswith("-"):
return "backup_file"
if not self.dangerous:
for g in self.deny_globs or []:
if fnmatch.fnmatch(path, g):
if fnmatch.fnmatch(match_path, g):
return "denied_path"
return None
try:
st = os.stat(path, follow_symlinks=True)
except OSError:
return "unreadable"
if st.st_size > self.max_file_bytes:
return "too_large"
if not os.path.isfile(path) or os.path.islink(path):
return "not_regular_file"
try:
with open(path, "rb") as f:
data = f.read(min(self.sample_bytes, st.st_size))
except OSError:
return "unreadable"
def _content_deny_reason(self, path: str, data: bytes) -> Optional[str]:
if b"\x00" in data:
match_path = normalize_for_match(path)
for g in self.allow_binary_globs or []:
if fnmatch.fnmatch(path, g):
if fnmatch.fnmatch(match_path, g):
# Binary is acceptable for explicitly-allowed paths.
return None
return "binary_like"
@ -144,6 +212,74 @@ class IgnorePolicy:
return None
def inspect_file(self, path: str) -> tuple[Optional[str], Optional[FileInspection]]:
"""Safely inspect a regular file and return the exact bytes to copy.
The source is opened with O_NOFOLLOW on every path component (see
``fsutil.open_no_follow_path``), fstat() is taken from that file
descriptor, and the whole file is read only after the size cap passes.
With the default 256 KiB cap this avoids a memory DoS while ensuring
secret scanning covers every byte that may be copied.
Opening every component without following symlinks means a regular
file reached through a symlinked *parent* directory is refused with
``symlink_component`` rather than silently captured -- its logical
path would not have matched the deny globs.
"""
deny = self._path_deny_reason(path)
if deny:
return deny, None
fd: Optional[int] = None
try:
try:
fd = open_no_follow_path(path)
except OSError as e:
if e.errno == errno.ELOOP:
# A symlink (or unsafe '..') somewhere in the path. This is
# distinct from "not a regular file" so operators can see
# why a path under a symlinked parent was skipped.
return "symlink_component", None
if e.errno == errno.ENOTDIR:
return "not_regular_file", None
return "unreadable", None
try:
st = os.fstat(fd)
except OSError:
return "unreadable", None
if not stat.S_ISREG(st.st_mode):
return "not_regular_file", None
if st.st_size > self.max_file_bytes:
return "too_large", None
chunks: list[bytes] = []
remaining = int(st.st_size)
while remaining > 0:
chunk = os.read(fd, min(1024 * 1024, remaining))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
data = b"".join(chunks)
deny = self._content_deny_reason(path, data)
if deny:
return deny, None
return None, FileInspection(data=data, stat_result=st)
finally:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
def deny_reason(self, path: str) -> Optional[str]:
deny, _inspection = self.inspect_file(path)
return deny
def deny_reason_dir(self, path: str) -> Optional[str]:
"""Directory-specific deny logic.
@ -157,8 +293,9 @@ class IgnorePolicy:
No size checks or content scanning are performed for directories.
"""
if not self.dangerous:
match_path = normalize_for_match(path)
for g in self.deny_globs or []:
if fnmatch.fnmatch(path, g):
if fnmatch.fnmatch(match_path, g):
return "denied_path"
try:
@ -189,16 +326,17 @@ class IgnorePolicy:
"""
# Keep the same fast-path filename ignores as deny_reason().
if path.endswith(".log"):
match_path = normalize_for_match(path)
if match_path.endswith(".log"):
return "log_file"
if path.endswith("~"):
if match_path.endswith("~"):
return "backup_file"
if path.startswith("/etc/") and path.endswith("-"):
if match_path.startswith("/etc/") and match_path.endswith("-"):
return "backup_file"
if not self.dangerous:
for g in self.deny_globs or []:
if fnmatch.fnmatch(path, g):
if fnmatch.fnmatch(match_path, g):
return "denied_path"
try:

View file

@ -1,11 +1,16 @@
from __future__ import annotations
import hashlib
import re
import shutil
import subprocess # nosec
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional, Set, Tuple
from .manifest_safety import ArtifactSafetyError, safe_artifact_file
from .yamlutil import yaml_dump_mapping, yaml_load_mapping
SYSTEMD_SUFFIXES = {
@ -36,6 +41,258 @@ SUPPORTED_SUFFIXES = {
} | SYSTEMD_SUFFIXES
def resolve_jinjaturtle_mode(jinjaturtle: str) -> Tuple[Optional[str], bool]:
"""Resolve Enroll's common JinjaTurtle mode flag.
Renderers accept the same values:
- ``auto``: use JinjaTurtle when present on PATH
- ``on``: require it and fail if it is absent
- ``off``: never use it
"""
jt_exe = find_jinjaturtle_cmd()
if jinjaturtle not in {"auto", "on", "off"}:
raise ValueError("jinjaturtle must be one of: auto, on, off")
if jinjaturtle == "on":
if not jt_exe:
raise RuntimeError("jinjaturtle requested but not found on PATH")
return jt_exe, True
if jinjaturtle == "auto":
return jt_exe, jt_exe is not None
return jt_exe, False
def _merge_mappings_overwrite(
existing: Dict[str, Any], incoming: Dict[str, Any]
) -> Dict[str, Any]:
merged = dict(existing)
merged.update(incoming)
return merged
@dataclass(frozen=True)
class JinjifiedArtifact:
template_rel: str
template_text: str
vars_text: str
context: Dict[str, Any]
_JINJA_EXPR_VAR_RE = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_]*)\b")
_JINJA_FOR_RE = re.compile(
r"{%\s*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)\b"
)
_JINJA_SPECIAL_VARS = {"loop", "true", "false", "none", "True", "False", "None"}
_ERB_INSTANCE_VAR_RE = re.compile(r"<%=?[^%]*@([A-Za-z_][A-Za-z0-9_]*)", re.S)
def _find_undeclared_jinja_vars(template_text: str) -> Set[str]:
try:
from jinja2 import Environment, meta # type: ignore
env = Environment() # nosec B701 - parsing config templates, not rendering HTML
ast = env.parse(template_text)
return set(meta.find_undeclared_variables(ast))
except Exception:
locals_from_loops: Set[str] = set()
collection_vars: Set[str] = set()
for match in _JINJA_FOR_RE.finditer(template_text):
locals_from_loops.add(match.group(1))
collection_vars.add(match.group(2))
referenced = set(_JINJA_EXPR_VAR_RE.findall(template_text)) | collection_vars
referenced -= locals_from_loops
referenced -= _JINJA_SPECIAL_VARS
return referenced
def missing_jinja_template_vars(
template_text: str, context: Dict[str, Any]
) -> Set[str]:
"""Return variables referenced by a JinjaTurtle template but absent from vars.
This is a defensive check for Enroll's best-effort templating path. If
JinjaTurtle ever emits a placeholder without a matching default variable,
Enroll should fall back to copying the raw harvested file rather than
generating an Ansible role that fails at apply time.
"""
referenced = _find_undeclared_jinja_vars(template_text)
referenced -= _JINJA_SPECIAL_VARS
return {name for name in referenced if name not in context}
def missing_erb_template_vars(template_text: str, context: Dict[str, Any]) -> Set[str]:
"""Return ERB ``@param`` references absent from Puppet Hiera/class data."""
local_names: Set[str] = set()
for key in context:
text = str(key)
if "::" in text:
local_names.add(text.split("::", 1)[1])
else:
local_names.add(text)
referenced = set(_ERB_INSTANCE_VAR_RE.findall(template_text))
return {name for name in referenced if name not in local_names}
def jinjify_artifact(
bundle_dir: str | Path,
artifact_role: str,
src_rel: str,
dest_path: str,
template_root: str | Path,
*,
jt_exe: Optional[str],
jt_enabled: bool,
overwrite_templates: bool = True,
role_name: Optional[str] = None,
template_engine: str = "jinja2",
puppet_class: Optional[str] = None,
) -> Optional[JinjifiedArtifact]:
"""Best-effort conversion of one harvested artifact into a template.
Ansible/Salt use Jinja2 output. Puppet uses ERB output with Puppet Hiera
keys when a new enough JinjaTurtle is available.
"""
if not (jt_enabled and jt_exe and can_jinjify_path(dest_path)):
return None
try:
artifact_path = safe_artifact_file(bundle_dir, artifact_role, src_rel)
except (ArtifactSafetyError, FileNotFoundError):
return None
try:
run_kwargs: Dict[str, Any] = {
"role_name": role_name or artifact_role,
"force_format": infer_other_formats(dest_path),
}
# Keep the historical call shape for Ansible/Salt and for tests that
# monkeypatch run_jinjaturtle with the old signature. Puppet/ERB is
# the only path that needs the newer JinjaTurtle CLI switches.
if template_engine != "jinja2":
run_kwargs["template_engine"] = template_engine
if puppet_class:
run_kwargs["puppet_class"] = puppet_class
result = run_jinjaturtle(jt_exe, str(artifact_path), **run_kwargs)
except Exception:
return None # nosec - best-effort template generation
ext = "erb" if template_engine == "erb" else "j2"
template_rel = Path(src_rel).as_posix() + f".{ext}"
template_dst = Path(template_root) / template_rel
context = yaml_load_mapping(result.vars_text)
missing = (
missing_erb_template_vars(result.template_text, context)
if template_engine == "erb"
else missing_jinja_template_vars(result.template_text, context)
)
if missing:
# If this role was generated into an existing output directory, avoid
# leaving an obsolete template behind after falling back to a raw copy.
if overwrite_templates and template_dst.exists():
template_dst.unlink()
return None
if overwrite_templates or not template_dst.exists():
template_dst.parent.mkdir(parents=True, exist_ok=True)
template_dst.write_text(result.template_text, encoding="utf-8")
return JinjifiedArtifact(
template_rel=template_rel,
template_text=result.template_text,
vars_text=result.vars_text,
context=context,
)
def managed_file_var_prefix(role_name: str, src_rel: str) -> str:
"""Return a JinjaTurtle-safe variable prefix for one managed file.
JinjaTurtle's ``--role-name`` is a variable prefix. Enroll can place many
unrelated managed files in one generated role, so using only the role name
can collide for common keys such as ``enabled``, ``ignore``, or ``name``.
Include the relative artifact path when a role templates multiple files.
"""
raw = f"{role_name}_{src_rel}"
safe = re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_").lower()
safe = re.sub(r"_+", "_", safe)
if not safe:
safe = "managed_file"
if len(safe) > 96:
digest = hashlib.sha1( # nosec B324
raw.encode("utf-8", errors="replace")
).hexdigest()[:8]
safe = safe[:80].rstrip("_") + "_" + digest
return safe
def jinjify_managed_files(
bundle_dir: str | Path,
artifact_role: str,
template_root: str | Path,
managed_files: List[Dict[str, Any]],
*,
jt_exe: Optional[str],
jt_enabled: bool,
overwrite_templates: bool,
role_name: Optional[str] = None,
) -> Tuple[Set[str], str]:
"""Jinjify a list of managed files and return Ansible-style vars text.
The return shape intentionally matches the historical Ansible helper:
``(templated_src_rels, combined_vars_text)``. Salt uses
:func:`jinjify_artifact` directly because it stores variables as a context
map per managed file.
"""
templated: Set[str] = set()
vars_map: Dict[str, Any] = {}
base_role_name = role_name or artifact_role
candidates = [
mf
for mf in managed_files
if str(mf.get("path") or "")
and str(mf.get("src_rel") or "")
and can_jinjify_path(str(mf.get("path") or ""))
]
namespace_by_file = len(candidates) > 1
for mf in managed_files:
dest_path = str(mf.get("path") or "")
src_rel = str(mf.get("src_rel") or "")
if not dest_path or not src_rel:
continue
converted = jinjify_artifact(
bundle_dir,
artifact_role,
src_rel,
dest_path,
template_root,
jt_exe=jt_exe,
jt_enabled=jt_enabled,
overwrite_templates=overwrite_templates,
role_name=(
managed_file_var_prefix(base_role_name, src_rel)
if namespace_by_file
else base_role_name
),
)
if converted is None:
continue
templated.add(src_rel)
if converted.context:
vars_map = _merge_mappings_overwrite(vars_map, converted.context)
if vars_map:
return templated, yaml_dump_mapping(vars_map, sort_keys=True)
return templated, ""
def infer_other_formats(dest_path: str) -> Optional[str]:
p = Path(dest_path)
name = p.name.lower()
@ -83,6 +340,8 @@ def run_jinjaturtle(
*,
role_name: str,
force_format: Optional[str] = None,
template_engine: str = "jinja2",
puppet_class: Optional[str] = None,
) -> JinjifyResult:
"""
Run jinjaturtle against src_path and return (template, defaults-yaml).
@ -90,6 +349,9 @@ def run_jinjaturtle(
jinjaturtle CLI:
jinjaturtle <config> -r <role> [-f <format>] [-d <defaults-output>] [-t <template-output>]
Newer JinjaTurtle versions also support ``--template-engine erb`` and
``--puppet-class`` for Puppet/Hiera output.
"""
src = Path(src_path)
if not src.is_file():
@ -98,7 +360,9 @@ def run_jinjaturtle(
with tempfile.TemporaryDirectory(prefix="enroll-jt-") as td:
td_path = Path(td)
defaults_out = td_path / "defaults.yml"
template_out = td_path / "template.j2"
template_out = td_path / (
"template.erb" if template_engine == "erb" else "template.j2"
)
cmd = [
jt_exe,
@ -112,6 +376,10 @@ def run_jinjaturtle(
]
if force_format:
cmd.extend(["-f", force_format])
if template_engine != "jinja2":
cmd.extend(["--template-engine", template_engine])
if puppet_class:
cmd.extend(["--puppet-class", puppet_class])
p = subprocess.run(cmd, text=True, capture_output=True) # nosec
if p.returncode != 0:

File diff suppressed because it is too large Load diff

258
enroll/manifest_safety.py Normal file
View file

@ -0,0 +1,258 @@
from __future__ import annotations
import os
import re
import shutil
import stat
from pathlib import Path
from typing import Iterator, Tuple
from .harvest_safety import (
OutputSafetyError,
ensure_safe_output_parent,
prepare_new_private_dir,
)
class ArtifactSafetyError(RuntimeError):
"""Raised when a harvest artifact path is unsafe to consume."""
class ManifestOutputError(RuntimeError):
"""Raised when a manifest output path is unsafe to use."""
_SITE_FQDN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,252}$")
def validate_site_fqdn(value: str | None) -> str | None:
"""Validate the optional site-mode host name/FQDN.
Renderers use this value in inventory data and, for Ansible, in output
paths. Keep it deliberately conservative so it cannot become a path
separator, absolute path, YAML/INI newline injection, or shell-ish text in
generated documentation/commands.
"""
if value is None:
return None
text = str(value).strip()
if not text:
return None
if any(ch in text for ch in ("/", "\\", "\x00", "\n", "\r")):
raise ManifestOutputError(
"--fqdn contains unsafe path or newline characters; use a simple "
"host/inventory name"
)
if text in {".", ".."} or not _SITE_FQDN_RE.fullmatch(text):
raise ManifestOutputError(
"--fqdn must start with a letter or digit and contain only "
"letters, digits, dot, underscore, or hyphen"
)
return text
def _assert_no_output_symlinks(root: Path) -> None:
"""Reject pre-existing symlinks in an output tree we are about to merge into.
Non-site mode refuses existing output directories entirely. Site/FQDN modes
intentionally accumulate multiple nodes into one tree, so reject symlinks in
the tree before merging to avoid writes being redirected outside *root*.
Version-control metadata can contain implementation-specific entries and is
not part of Enroll's generated layout, so it is pruned from this check.
"""
skip_dirs = {".git", ".hg", ".svn"}
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
dirpath_p = Path(dirpath)
for dirname in list(dirnames):
if dirname in skip_dirs:
dirnames.remove(dirname)
continue
p = dirpath_p / dirname
try:
st = p.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
raise ManifestOutputError(
f"manifest output tree contains a symlink; refusing to merge: {p}"
)
for filename in filenames:
if filename in skip_dirs:
continue
p = dirpath_p / filename
try:
st = p.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
raise ManifestOutputError(
f"manifest output tree contains a symlink; refusing to merge: {p}"
)
def _safe_relative_path(value: str, *, field: str) -> Path:
text = str(value or "").strip()
if not text:
raise ArtifactSafetyError(f"empty {field}")
if "\x00" in text:
raise ArtifactSafetyError(f"{field} contains NUL byte: {text!r}")
p = Path(text)
if p.is_absolute():
raise ArtifactSafetyError(f"{field} must be relative: {text!r}")
if any(part in {"", ".", ".."} for part in p.parts):
raise ArtifactSafetyError(f"{field} contains unsafe path component: {text!r}")
return p
def prepare_manifest_output_dir(
out_dir: str | Path, *, allow_existing: bool = False
) -> Path:
"""Create a manifest output directory, refusing unsafe root output paths.
Rendering a manifest may be run by root and may target configuration-
management trees. Refuse an existing path rather than deleting or merging
with it by default; callers that intentionally support accumulation, such
as --fqdn site mode, may allow an existing directory but never a symlink,
non-directory path, symlinked parent, or root-unsafe parent.
"""
out = Path(out_dir).expanduser()
if os.path.lexists(out):
if not allow_existing:
raise ManifestOutputError(
"manifest output path already exists; refusing to overwrite: " f"{out}"
)
try:
ensure_safe_output_parent(
out / ".enroll-manifest-output-check", label="manifest output"
)
except OutputSafetyError as e:
raise ManifestOutputError(str(e)) from e
st = out.lstat()
if stat.S_ISLNK(st.st_mode):
raise ManifestOutputError(
f"manifest output path is a symlink; refusing to use: {out}"
)
if not out.is_dir():
raise ManifestOutputError(
f"manifest output path exists but is not a directory: {out}"
)
_assert_no_output_symlinks(out)
return out
try:
return prepare_new_private_dir(out, label="manifest output")
except OutputSafetyError as e:
raise ManifestOutputError(str(e)) from e
def _assert_no_symlink_components(path: Path, *, root: Path) -> None:
"""Reject symlinks in any existing path component between root and path."""
try:
rel = path.relative_to(root)
except ValueError as e:
raise ArtifactSafetyError(f"artifact path escapes artifact root: {path}") from e
cur = root
for part in rel.parts:
cur = cur / part
try:
st = cur.lstat()
except FileNotFoundError:
# Missing components are handled by the final caller where relevant.
return
if stat.S_ISLNK(st.st_mode):
raise ArtifactSafetyError(f"artifact path contains symlink: {cur}")
def safe_artifact_file(bundle_dir: str | Path, role: str, src_rel: str) -> Path:
"""Return a harvested artifact file path only if it is safe to copy.
The path must remain under artifacts/<role>, contain no absolute or '..'
components, contain no symlinks in any path component, and refer to a
regular, non-hardlinked file. This deliberately mirrors the tar extraction
hardening used for remote/SOPS/plain tarball bundles, but applies it to
directory bundles too.
"""
role_path = _safe_relative_path(role, field="artifact role")
src_path = _safe_relative_path(src_rel, field="artifact src_rel")
artifacts_root = Path(bundle_dir).expanduser() / "artifacts"
root = artifacts_root / role_path
candidate = root / src_path
if artifacts_root.exists():
st = artifacts_root.lstat()
if stat.S_ISLNK(st.st_mode):
raise ArtifactSafetyError(
f"artifacts directory is a symlink: {artifacts_root}"
)
if root.exists():
_assert_no_symlink_components(root, root=artifacts_root)
_assert_no_symlink_components(candidate, root=artifacts_root)
try:
st = candidate.lstat()
except FileNotFoundError:
raise
if stat.S_ISLNK(st.st_mode):
raise ArtifactSafetyError(f"artifact is a symlink: {candidate}")
if not stat.S_ISREG(st.st_mode):
raise ArtifactSafetyError(f"artifact is not a regular file: {candidate}")
if st.st_nlink > 1:
raise ArtifactSafetyError(f"artifact is hardlinked: {candidate}")
resolved_root = artifacts_root.resolve(strict=True)
resolved_candidate = candidate.resolve(strict=True)
try:
resolved_candidate.relative_to(resolved_root)
except ValueError as e:
raise ArtifactSafetyError(
f"artifact path escapes artifact root: {candidate}"
) from e
return candidate
def iter_safe_artifact_files(
bundle_dir: str | Path, role: str
) -> Iterator[Tuple[Path, str]]:
"""Yield safe artifact files for a role as (path, src_rel)."""
role_path = _safe_relative_path(role, field="artifact role")
artifacts_dir = Path(bundle_dir).expanduser() / "artifacts" / role_path
if not artifacts_dir.exists():
return
if not artifacts_dir.is_dir():
raise ArtifactSafetyError(
f"artifact role path is not a directory: {artifacts_dir}"
)
for root, dirs, files in os.walk(artifacts_dir, followlinks=False):
root_p = Path(root)
for dirname in list(dirs):
p = root_p / dirname
try:
st = p.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
raise ArtifactSafetyError(f"artifact directory is a symlink: {p}")
for filename in files:
p = root_p / filename
rel = p.relative_to(artifacts_dir).as_posix()
yield safe_artifact_file(bundle_dir, role, rel), rel
def copy_safe_artifact_file(src: str | Path, dst: str | Path) -> None:
"""Copy an already validated artifact file without following symlinks."""
shutil.copy2(src, dst, follow_symlinks=False)

126
enroll/package_hints.py Normal file
View file

@ -0,0 +1,126 @@
from __future__ import annotations
import re
from typing import Dict, List, Optional, Set
from .role_names import avoid_reserved_role_name
# Directories that are shared across many packages. Never attribute all unowned
# files in these trees to one single package.
SHARED_ETC_TOPDIRS = {
"apparmor.d",
"apt",
"cron.d",
"cron.daily",
"cron.weekly",
"cron.monthly",
"cron.hourly",
"default",
"init.d",
"logrotate.d",
"modprobe.d",
"network",
"pam.d",
"ssh",
"ssl",
"sudoers.d",
"sysctl.d",
"systemd",
# RPM-family shared trees
"dnf",
"yum",
"yum.repos.d",
"sysconfig",
"pki",
"firewalld",
}
def safe_name(s: str) -> str:
out: List[str] = []
for ch in s:
out.append(ch if ch.isalnum() or ch in ("_", "-") else "_")
return "".join(out).replace("-", "_")
def role_id(raw: str) -> str:
# normalise separators first
s = re.sub(r"[^A-Za-z0-9]+", "_", raw)
# split CamelCase -> snake_case
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
s = s.lower()
s = re.sub(r"_+", "_", s).strip("_")
if not re.match(r"^[a-z_]", s):
s = "r_" + s
return s
def role_name_from_unit(unit: str) -> str:
base = role_id(unit.removesuffix(".service"))
return avoid_reserved_role_name(safe_name(base), prefix="service")
def role_name_from_pkg(pkg: str) -> str:
return avoid_reserved_role_name(safe_name(pkg), prefix="package")
def package_section_from_installations(
installs: List[Dict[str, str]],
) -> Optional[str]:
"""Return a stable package grouping label from installed package metadata."""
values: Set[str] = set()
for inst in installs or []:
value = (inst.get("section") or inst.get("group") or "").strip()
if not value:
continue
if value.lower() in {"(none)", "none", "unspecified"}:
continue
values.add(value)
if not values:
return None
return sorted(values)[0]
def hint_names(unit: str, pkgs: Set[str]) -> Set[str]:
base = unit.removesuffix(".service")
hints = {base}
if "@" in base:
hints.add(base.split("@", 1)[0])
hints |= set(pkgs)
hints |= {h.split(".", 1)[0] for h in list(hints) if "." in h}
return {h for h in hints if h}
def add_pkgs_from_etc_topdirs(
hints: Set[str], topdir_to_pkgs: Dict[str, Set[str]], pkgs: Set[str]
) -> None:
"""Expand a service's package set using package-owned /etc top-level dirs."""
for h in hints:
for top in (h, f"{h}.d"):
if top in SHARED_ETC_TOPDIRS:
continue
for p in topdir_to_pkgs.get(top, set()):
pkgs.add(p)
def maybe_add_specific_paths(hints: Set[str], backend) -> List[str]:
# Delegate to backend-specific conventions (e.g. /etc/default on Debian,
# /etc/sysconfig on Fedora/RHEL). Always include sysctl.d.
try:
return backend.specific_paths_for_hints(hints)
except Exception:
# Best-effort fallback (Debian-ish).
paths: List[str] = []
for h in hints:
paths.extend(
[
f"/etc/default/{h}",
f"/etc/init.d/{h}",
f"/etc/sysctl.d/{h}.conf",
]
)
return paths

1840
enroll/puppet.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,8 @@ from pathlib import Path
from pathlib import PurePosixPath
from typing import Optional, Callable, TextIO
from .harvest_safety import ensure_private_empty_dir, prepare_new_private_dir
class RemoteSudoPasswordRequired(RuntimeError):
"""Raised when sudo requires a password but none was provided."""
@ -139,12 +141,16 @@ def remote_harvest(
getpass_fn=getpass_fn,
)
allow_existing_output = bool(kwargs.pop("allow_existing_output", False))
output_prepared = False
while True:
try:
return _remote_harvest(
sudo_password=sudo_password,
no_sudo=no_sudo,
ssh_key_passphrase=ssh_key_passphrase,
allow_existing_output=allow_existing_output or output_prepared,
**kwargs,
)
except RemoteSSHKeyPassphraseRequired:
@ -158,6 +164,7 @@ def remote_harvest(
# Fallback prompt if interactive.
if stdin is not None and getattr(stdin, "isatty", lambda: False)():
ssh_key_passphrase = getpass_fn(key_prompt)
output_prepared = True
continue
raise RemoteSSHKeyPassphraseRequired(
@ -173,6 +180,7 @@ def remote_harvest(
# Fallback prompt if interactive.
if stdin is not None and getattr(stdin, "isatty", lambda: False)():
sudo_password = getpass_fn(prompt)
output_prepared = True
continue
raise RemoteSudoPasswordRequired(
@ -210,10 +218,17 @@ def _safe_extract_tar(tar: tarfile.TarFile, dest: Path) -> None:
if member_path != dest and not str(member_path).startswith(str(dest) + os.sep):
raise RuntimeError(f"Unsafe tar member path: {name}")
# Extract members one-by-one after validation.
# Extract members one-by-one after validation. Pass an explicit tarfile
# extraction filter on Python versions that support it so Python 3.12/3.13
# do not warn about the Python 3.14 default changing. Keep the older call
# path for Python 3.10/3.11, where the filter argument is unavailable.
supports_filter = hasattr(tarfile, "data_filter")
for m in tar.getmembers():
if m.name in {".", "./"}:
continue
if supports_filter:
tar.extract(m, path=dest, filter="data")
else:
tar.extract(m, path=dest)
@ -406,6 +421,7 @@ def _remote_harvest(
ssh_key_passphrase: Optional[str] = None,
include_paths: Optional[list[str]] = None,
exclude_paths: Optional[list[str]] = None,
allow_existing_output: bool = False,
) -> Path:
"""Run enroll harvest on a remote host via SSH and pull the bundle locally.
@ -419,12 +435,11 @@ def _remote_harvest(
"Install it with: pip install paramiko"
) from e
local_out_dir = Path(local_out_dir)
local_out_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(local_out_dir, 0o700)
except OSError:
pass
local_out_dir = (
ensure_private_empty_dir(local_out_dir, label="remote harvest output")
if allow_existing_output
else prepare_new_private_dir(local_out_dir, label="remote harvest output")
)
# Build a zipapp locally and upload it to the remote.
with tempfile.TemporaryDirectory(prefix="enroll-remote-") as td:
@ -563,22 +578,50 @@ def _remote_harvest(
sftp = ssh.open_sftp()
rtmp: Optional[str] = None
remote_root_tmp: Optional[str] = None
try:
rc, out, err = _ssh_run(ssh, "mktemp -d")
if rc != 0:
raise RuntimeError(f"Remote mktemp failed: {err.strip()}")
rtmp = out.strip()
if not rtmp:
raise RuntimeError("Remote mktemp returned an empty path")
# Be explicit: restrict the remote staging area to the current user.
rc, out, err = _ssh_run(ssh, f"chmod 700 {rtmp}")
rc, out, err = _ssh_run(ssh, f"chmod 700 -- {shlex.quote(rtmp)}")
if rc != 0:
raise RuntimeError(f"Remote chmod failed: {err.strip()}")
rapp = f"{rtmp}/enroll.pyz"
rbundle = f"{rtmp}/bundle"
sftp.put(str(pyz), rapp)
if not no_sudo:
# The remote zipapp is staged as the SSH user, but the harvest
# itself runs as root. Root must not write its bundle under the
# SSH user's mktemp directory: the root-output safety checks
# deliberately reject user-owned parents to avoid symlink/race
# issues. Create a separate sudo-owned tempdir for the bundle.
rc, out, err = _ssh_run_sudo(
ssh, "mktemp -d", sudo_password=sudo_password, get_pty=True
)
if rc != 0:
raise RuntimeError(f"Remote sudo mktemp failed: {err.strip()}")
remote_root_tmp = out.strip()
if not remote_root_tmp:
raise RuntimeError("Remote sudo mktemp returned an empty path")
rc, out, err = _ssh_run_sudo(
ssh,
f"chmod 700 -- {shlex.quote(remote_root_tmp)}",
sudo_password=sudo_password,
get_pty=True,
)
if rc != 0:
raise RuntimeError(f"Remote sudo chmod failed: {err.strip()}")
rbundle = f"{remote_root_tmp}/bundle"
else:
rbundle = f"{rtmp}/bundle"
# Run remote harvest.
argv: list[str] = [
remote_python,
@ -620,7 +663,11 @@ def _remote_harvest(
"Unable to determine remote username for chown. "
"Pass --remote-user explicitly or use --no-sudo."
)
chown_cmd = f"chown -R {resolved_user} {rbundle}"
chown_target = remote_root_tmp or rbundle
chown_cmd = (
"chown -R -- "
f"{shlex.quote(resolved_user)} {shlex.quote(chown_target)}"
)
rc, out, err = _ssh_run_sudo(
ssh,
chown_cmd,
@ -637,7 +684,7 @@ def _remote_harvest(
)
# Stream a tarball back to the local machine (avoid creating a tar file on the remote).
cmd = f"tar -cz -C {rbundle} ."
cmd = f"tar -cz -C {shlex.quote(rbundle)} ."
_stdin, stdout, stderr = ssh.exec_command(cmd) # nosec
with open(local_tgz, "wb") as f:
while True:
@ -660,9 +707,21 @@ def _remote_harvest(
_safe_extract_tar(tf, local_out_dir)
finally:
# Cleanup remote tmpdir even on failure.
# Cleanup remote tmpdirs even on failure. The sudo-owned harvest
# tempdir may still be root-owned if harvest/chown failed, so remove
# it via sudo and avoid masking the original error if cleanup fails.
if remote_root_tmp:
try:
_ssh_run_sudo(
ssh,
f"rm -rf -- {shlex.quote(remote_root_tmp)}",
sudo_password=sudo_password,
get_pty=True,
)
except Exception:
pass # nosec - best-effort remote cleanup
if rtmp:
_ssh_run(ssh, f"rm -rf {rtmp}")
_ssh_run(ssh, f"rm -rf -- {shlex.quote(rtmp)}")
try:
sftp.close()
ssh.close()

232
enroll/render_safety.py Normal file
View file

@ -0,0 +1,232 @@
from __future__ import annotations
import json
import re
from collections.abc import Mapping, Set as AbstractSet
from typing import Any
ANSIBLE_JINJA_STARTS = ("{{", "{%", "{#")
class AnsibleUnsafeText(str):
"""String subclass dumped as Ansible's ``!unsafe`` YAML scalar.
Ansible templating can recursively evaluate Jinja delimiters that arrive
through variables/defaults. Harvested data is not authored playbook code;
values containing Jinja starts must be tagged as unsafe data before they are
written to Ansible variable files.
"""
def is_ansible_template_like(value: str) -> bool:
"""Return true if *value* contains a Jinja start delimiter."""
return any(marker in value for marker in ANSIBLE_JINJA_STARTS)
def ansible_unsafe_data(value: Any) -> Any:
"""Recursively mark template-looking harvested strings as Ansible data.
Keep ordinary strings untouched so generated output remains readable and so
existing tests/tools that use ``yaml.safe_load`` continue to work for normal
data. Mapping keys are also strings in Ansible data structures, so protect
keys as well as values.
"""
if isinstance(value, AnsibleUnsafeText):
return value
if isinstance(value, str):
return AnsibleUnsafeText(value) if is_ansible_template_like(value) else value
if isinstance(value, Mapping):
return {
ansible_unsafe_data(str(key)): ansible_unsafe_data(inner)
for key, inner in value.items()
}
if isinstance(value, list):
return [ansible_unsafe_data(item) for item in value]
if isinstance(value, tuple):
return [ansible_unsafe_data(item) for item in value]
if isinstance(value, AbstractSet):
return sorted(ansible_unsafe_data(item) for item in value)
return value
def escape_puppet_hiera_interpolation(value: str) -> str:
"""Preserve literal ``%{`` text in Puppet Hiera data sources.
Hiera treats ``%{...}`` in data values as interpolation. Enroll's Hiera
data is generated from harvested values, not authored Hiera expressions, so
any literal interpolation opener is escaped with Hiera's documented
``literal('%')`` helper.
"""
return str(value).replace("%{", "%{literal('%')}{")
def puppet_hiera_safe_data(value: Any) -> Any:
"""Recursively escape Hiera interpolation openers in harvested data."""
if isinstance(value, Mapping):
return {
escape_puppet_hiera_interpolation(str(key)): puppet_hiera_safe_data(inner)
for key, inner in value.items()
}
if isinstance(value, list):
return [puppet_hiera_safe_data(item) for item in value]
if isinstance(value, tuple):
return [puppet_hiera_safe_data(item) for item in value]
if isinstance(value, AbstractSet):
return sorted(puppet_hiera_safe_data(item) for item in value)
if isinstance(value, str):
return escape_puppet_hiera_interpolation(value)
return value
def _plain_json_data(value: Any) -> Any:
if isinstance(value, Mapping):
return {str(key): _plain_json_data(inner) for key, inner in value.items()}
if isinstance(value, list):
return [_plain_json_data(item) for item in value]
if isinstance(value, tuple):
return [_plain_json_data(item) for item in value]
if isinstance(value, AbstractSet):
return sorted(_plain_json_data(item) for item in value)
return value
def _escape_braces_inside_json_strings(text: str) -> str:
"""Replace literal braces only while scanning JSON string tokens."""
out: list[str] = []
in_string = False
escaped = False
for ch in text:
if not in_string:
out.append(ch)
if ch == '"':
in_string = True
continue
if escaped:
out.append(ch)
escaped = False
elif ch == "\\":
out.append(ch)
escaped = True
elif ch == '"':
out.append(ch)
in_string = False
elif ch == "{":
out.append("\\u007b")
elif ch == "}":
out.append("\\u007d")
else:
out.append(ch)
return "".join(out)
def salt_sls_json_quote(value: Any) -> str:
"""Return a double-quoted YAML/JSON scalar safe for Salt's Jinja pass.
Salt state and pillar SLS files normally use the ``jinja|yaml`` renderer
pipeline. YAML/JSON quoting alone does not stop ``{{ ... }}``, ``{% ... %}``
or ``{# ... #}`` inside harvested values from being evaluated before YAML is
parsed. JSON/YAML double-quoted scalars decode ``\u007b`` and ``\u007d``
after Jinja has run, so encode braces inside string tokens as Unicode escapes.
"""
dumped = json.dumps(str(value), ensure_ascii=False)
return _escape_braces_inside_json_strings(dumped)
_PLAIN_YAML_KEY_RE = re.compile(r"^[A-Za-z0-9_./:-]+$")
def _salt_yaml_key(value: Any) -> str:
text = str(value)
if text and _PLAIN_YAML_KEY_RE.match(text) and not text.startswith(("-", "?", ":")):
return text
return salt_sls_json_quote(text)
def _salt_yaml_scalar(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int) and not isinstance(value, bool):
return str(value)
if isinstance(value, float):
return json.dumps(value, allow_nan=False)
return salt_sls_json_quote(value)
def _salt_yaml_lines(
value: Any, indent: int = 0, *, sort_keys: bool = True
) -> list[str]:
prefix = " " * indent
if isinstance(value, Mapping):
if not value:
return [prefix + "{}"]
keys = sorted(value, key=lambda item: str(item)) if sort_keys else list(value)
lines: list[str] = []
for key in keys:
inner = value[key]
key_text = _salt_yaml_key(key)
if isinstance(inner, Mapping):
if not inner:
lines.append(f"{prefix}{key_text}: {{}}")
else:
lines.append(f"{prefix}{key_text}:")
lines.extend(
_salt_yaml_lines(inner, indent + 2, sort_keys=sort_keys)
)
elif isinstance(inner, (list, tuple, set)):
seq = list(inner) if not isinstance(inner, set) else sorted(inner)
if not seq:
lines.append(f"{prefix}{key_text}: []")
else:
lines.append(f"{prefix}{key_text}:")
lines.extend(_salt_yaml_lines(seq, indent + 2, sort_keys=sort_keys))
else:
lines.append(f"{prefix}{key_text}: {_salt_yaml_scalar(inner)}")
return lines
if isinstance(value, (list, tuple, set)):
seq = list(value) if not isinstance(value, set) else sorted(value)
if not seq:
return [prefix + "[]"]
lines = []
for item in seq:
if isinstance(item, Mapping):
if not item:
lines.append(prefix + "- {}")
else:
lines.append(prefix + "-")
lines.extend(
_salt_yaml_lines(item, indent + 2, sort_keys=sort_keys)
)
elif isinstance(item, (list, tuple, set)):
lines.append(prefix + "-")
lines.extend(_salt_yaml_lines(item, indent + 2, sort_keys=sort_keys))
else:
lines.append(f"{prefix}- {_salt_yaml_scalar(item)}")
return lines
return [prefix + _salt_yaml_scalar(value)]
def salt_sls_yaml_dump(
value: Any,
*,
sort_keys: bool = True,
explicit_start: bool = False,
) -> str:
"""Dump block YAML whose string braces cannot form Salt Jinja delimiters."""
lines = _salt_yaml_lines(_plain_json_data(value), sort_keys=sort_keys)
rendered = "\n".join(lines).rstrip() + "\n"
if explicit_start:
rendered = "---\n" + rendered
return rendered

30
enroll/role_names.py Normal file
View file

@ -0,0 +1,30 @@
from __future__ import annotations
RESERVED_SINGLETON_ROLE_NAMES = {
"users",
"flatpak",
"snap",
"container_images",
"apt_config",
"dnf_config",
"firewall_runtime",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
"common_packages",
}
def avoid_reserved_role_name(role_name: str, *, prefix: str) -> str:
"""Return a role name that cannot collide with singleton roles.
Singleton roles are generated once per manifest from dedicated top-level
state sections. Package and service roles can naturally have the same names
as those singletons, e.g. the OS package named ``flatpak``. Prefix those
generated package/service roles so they cannot overwrite singleton role
directories during manifestation.
"""
if role_name in RESERVED_SINGLETON_ROLE_NAMES:
return f"{prefix}_{role_name}"
return role_name

View file

@ -148,7 +148,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
Uses `rpm -qa` and is expected to work on RHEL/Fedora-like systems.
Output format:
{"pkg": [{"version": "...", "arch": "..."}, ...], ...}
{"pkg": [{"version": "...", "arch": "...", "group": "..."}, ...], ...}
The version string is formatted as:
- "<version>-<release>" for typical packages
@ -161,7 +161,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
"rpm",
"-qa",
"--qf",
"%{NAME}\t%{EPOCHNUM}\t%{VERSION}\t%{RELEASE}\t%{ARCH}\n",
"%{NAME}\t%{EPOCHNUM}\t%{VERSION}\t%{RELEASE}\t%{ARCH}\t%{GROUP}\n",
],
allow_fail=False,
merge_err=True,
@ -190,7 +190,11 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
if epoch and epoch.isdigit() and epoch != "0":
v = f"{epoch}:{v}"
pkgs.setdefault(name, []).append({"version": v, "arch": arch})
instance = {"version": v, "arch": arch}
if len(parts) >= 6 and parts[5].strip():
instance["group"] = parts[5].strip()
pkgs.setdefault(name, []).append(instance)
for k in list(pkgs.keys()):
pkgs[k] = sorted(

1759
enroll/salt.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,181 @@
],
"unevaluatedProperties": false
},
"ContainerImageTagAlias": {
"additionalProperties": false,
"properties": {
"ref": {
"minLength": 1,
"type": "string"
},
"repository": {
"minLength": 1,
"type": "string"
},
"tag": {
"minLength": 1,
"type": "string"
}
},
"required": [
"ref",
"repository",
"tag"
],
"type": "object"
},
"ContainerImage": {
"additionalProperties": false,
"properties": {
"architecture": {
"type": [
"string",
"null"
]
},
"created": {
"type": [
"string",
"null"
]
},
"engine": {
"enum": [
"docker",
"podman"
],
"type": "string"
},
"home": {
"type": [
"string",
"null"
]
},
"image_id": {
"type": [
"string",
"null"
]
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
},
"os": {
"type": [
"string",
"null"
]
},
"platform": {
"type": [
"string",
"null"
]
},
"pull_ref": {
"type": [
"string",
"null"
]
},
"repo_digests": {
"items": {
"type": "string"
},
"type": "array"
},
"repo_tags": {
"items": {
"type": "string"
},
"type": "array"
},
"scope": {
"enum": [
"system",
"user"
],
"type": "string"
},
"size": {
"type": [
"integer",
"null"
]
},
"source": {
"type": "string"
},
"tag_aliases": {
"items": {
"$ref": "#/$defs/ContainerImageTagAlias"
},
"type": "array"
},
"user": {
"type": [
"string",
"null"
]
},
"variant": {
"type": [
"string",
"null"
]
}
},
"required": [
"engine",
"scope",
"user",
"home",
"image_id",
"repo_tags",
"repo_digests",
"pull_ref",
"tag_aliases",
"os",
"architecture",
"variant",
"platform",
"size",
"created",
"source",
"notes"
],
"type": "object"
},
"ContainerImagesSnapshot": {
"additionalProperties": false,
"properties": {
"images": {
"items": {
"$ref": "#/$defs/ContainerImage"
},
"type": "array"
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
},
"role_name": {
"const": "container_images"
}
},
"required": [
"role_name",
"images",
"notes"
],
"type": "object"
},
"DnfConfigSnapshot": {
"allOf": [
{
@ -117,6 +292,14 @@
"minLength": 1,
"type": "string"
},
"group": {
"minLength": 1,
"type": "string"
},
"section": {
"minLength": 1,
"type": "string"
},
"version": {
"minLength": 1,
"type": "string"
@ -364,6 +547,12 @@
},
"type": "array"
},
"section": {
"type": [
"string",
"null"
]
},
"version": {
"type": [
"string",
@ -390,6 +579,16 @@
"package": {
"minLength": 1,
"type": "string"
},
"has_config": {
"type": "boolean",
"default": true
},
"section": {
"type": [
"string",
"null"
]
}
},
"required": [
@ -571,6 +770,21 @@
"$ref": "#/$defs/UserEntry"
},
"type": "array"
},
"user_flatpaks": {
"additionalProperties": {
"type": "array",
"items": {
"$ref": "#/$defs/FlatpakInstall"
}
},
"type": "object"
},
"user_flatpak_remotes": {
"type": "array",
"items": {
"$ref": "#/$defs/FlatpakRemote"
}
}
},
"required": [
@ -652,6 +866,256 @@
"notes"
],
"type": "object"
},
"SysctlSnapshot": {
"additionalProperties": false,
"properties": {
"role_name": {
"const": "sysctl"
},
"managed_files": {
"items": {
"$ref": "#/$defs/ManagedFile"
},
"type": "array"
},
"parameters": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"role_name",
"managed_files",
"parameters",
"notes"
],
"type": "object"
},
"FlatpakInstall": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"method": {
"type": "string",
"enum": [
"system",
"user"
]
},
"remote": {
"type": [
"string",
"null"
]
},
"branch": {
"type": [
"string",
"null"
]
},
"arch": {
"type": [
"string",
"null"
]
},
"kind": {
"type": [
"string",
"null"
],
"enum": [
"app",
"runtime",
null
]
},
"ref": {
"type": [
"string",
"null"
]
},
"user": {
"type": [
"string",
"null"
]
},
"home": {
"type": [
"string",
"null"
]
},
"source": {
"type": "string"
},
"from_url": {
"type": "string",
"minLength": 1
}
},
"required": [
"name",
"method"
],
"type": "object"
},
"FlatpakRemote": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"method": {
"type": "string",
"enum": [
"system",
"user"
]
},
"url": {
"type": "string",
"minLength": 1
},
"user": {
"type": [
"string",
"null"
]
},
"home": {
"type": [
"string",
"null"
]
},
"source": {
"type": "string"
}
},
"required": [
"name",
"method",
"url"
],
"type": "object"
},
"SnapInstall": {
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"channel": {
"type": [
"string",
"null"
]
},
"revision": {
"type": [
"integer",
"null"
],
"minimum": 0
},
"classic": {
"type": "boolean"
},
"devmode": {
"type": "boolean"
},
"dangerous": {
"type": "boolean"
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
},
"source": {
"type": "string"
},
"install_revision": {
"type": "boolean"
}
},
"required": [
"name"
],
"type": "object"
},
"FlatpakSnapshot": {
"additionalProperties": false,
"properties": {
"role_name": {
"const": "flatpak"
},
"system_flatpaks": {
"type": "array",
"items": {
"$ref": "#/$defs/FlatpakInstall"
}
},
"remotes": {
"type": "array",
"items": {
"$ref": "#/$defs/FlatpakRemote"
}
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"role_name"
],
"type": "object"
},
"SnapSnapshot": {
"additionalProperties": false,
"properties": {
"role_name": {
"const": "snap"
},
"system_snaps": {
"type": "array",
"items": {
"$ref": "#/$defs/SnapInstall"
}
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"role_name"
],
"type": "object"
}
},
"$id": "https://enroll.sh/schema/state.schema.json",
@ -762,6 +1226,18 @@
},
"firewall_runtime": {
"$ref": "#/$defs/FirewallRuntimeSnapshot"
},
"sysctl": {
"$ref": "#/$defs/SysctlSnapshot"
},
"flatpak": {
"$ref": "#/$defs/FlatpakSnapshot"
},
"snap": {
"$ref": "#/$defs/SnapSnapshot"
},
"container_images": {
"$ref": "#/$defs/ContainerImagesSnapshot"
}
},
"required": [

View file

@ -7,6 +7,8 @@ import tempfile
from pathlib import Path
from typing import Iterable, List, Optional
from .harvest_safety import ensure_safe_output_parent
class SopsError(RuntimeError):
pass
@ -46,7 +48,7 @@ def encrypt_file_binary(
sops = require_sops_cmd()
src_path = Path(src_path)
dst_path = Path(dst_path)
dst_path.parent.mkdir(parents=True, exist_ok=True)
ensure_safe_output_parent(dst_path, label="sops output")
res = subprocess.run(
[
@ -98,7 +100,7 @@ def decrypt_file_binary_to(
sops = require_sops_cmd()
src_path = Path(src_path)
dst_path = Path(dst_path)
dst_path.parent.mkdir(parents=True, exist_ok=True)
ensure_safe_output_parent(dst_path, label="sops output")
res = subprocess.run(
[

150
enroll/state.py Normal file
View file

@ -0,0 +1,150 @@
from __future__ import annotations
import json
import os
import stat
import tempfile
from pathlib import Path
from typing import Any, Dict, Mapping, TextIO, Union
from .fsutil import open_no_follow_path
BundlePath = Union[str, Path]
State = Dict[str, Any]
# state.json should contain structured metadata, not harvested file content. Keep
# this generous so large package inventories still work while rejecting obvious
# accidental/malicious memory-exhaustion inputs.
MAX_STATE_JSON_BYTES = 16 * 1024 * 1024
class StateSafetyError(RuntimeError):
"""Raised when a harvest bundle's state.json is unsafe to parse."""
def state_path(bundle_dir: BundlePath) -> Path:
"""Return the canonical state.json path for a harvest bundle."""
return Path(bundle_dir) / "state.json"
def _check_state_stat(path: Path, st: os.stat_result, *, max_bytes: int) -> None:
if stat.S_ISLNK(st.st_mode):
raise StateSafetyError(f"state.json is a symlink; refusing to read: {path}")
if not stat.S_ISREG(st.st_mode):
raise StateSafetyError(f"state.json is not a regular file: {path}")
if st.st_nlink > 1:
raise StateSafetyError(f"state.json is hardlinked; refusing to read: {path}")
if st.st_size > max_bytes:
raise StateSafetyError(
f"state.json is too large to parse safely "
f"({st.st_size} bytes > {max_bytes} bytes): {path}"
)
def open_state_file(bundle_dir: BundlePath, *, max_bytes: int | None = None) -> TextIO:
"""Open state.json only after verifying it is safe to parse.
Direct directory bundles are more mutable than SOPS/tar/remote bundles, so do
not follow a symlinked state.json and do not parse special files, hardlinks, or
unexpectedly huge inputs. The final open also uses no-follow semantics and the
inode is compared with the pre-open lstat result to catch swaps between the
check and open.
"""
if max_bytes is None:
max_bytes = MAX_STATE_JSON_BYTES
path = state_path(bundle_dir)
try:
pre = path.lstat()
except FileNotFoundError:
raise FileNotFoundError(f"missing state.json: {path}")
_check_state_stat(path, pre, max_bytes=max_bytes)
fd = -1
try:
fd = open_no_follow_path(str(path), write=False)
opened = os.fstat(fd)
if (opened.st_dev, opened.st_ino) != (pre.st_dev, pre.st_ino):
raise StateSafetyError(
f"state.json changed while it was being opened; refusing to read: {path}"
)
_check_state_stat(path, opened, max_bytes=max_bytes)
f = os.fdopen(fd, "r", encoding="utf-8")
fd = -1
return f
except OSError as e:
raise StateSafetyError(f"unable to safely open state.json: {path}: {e}") from e
finally:
if fd >= 0:
try:
os.close(fd)
except OSError:
pass
def load_state(bundle_dir: BundlePath) -> State:
"""Load state.json from a harvest bundle directory."""
with open_state_file(bundle_dir) as f:
return json.load(f)
def write_state(
bundle_dir: BundlePath,
state: Mapping[str, Any],
*,
indent: int = 2,
sort_keys: bool = True,
) -> Path:
"""Write state.json to a harvest bundle directory and return its path."""
path = state_path(bundle_dir)
path.parent.mkdir(parents=True, exist_ok=True)
fd = -1
tmp_name = ""
try:
fd, tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True
)
try:
os.fchmod(fd, 0o600)
except OSError:
pass
with os.fdopen(fd, "w", encoding="utf-8") as f:
fd = -1
json.dump(state, f, indent=indent, sort_keys=sort_keys)
os.replace(tmp_name, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
finally:
if fd >= 0:
os.close(fd)
if tmp_name:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass
return path
def roles_from_state(state: Mapping[str, Any]) -> Dict[str, Any]:
"""Return the roles mapping from a harvest state, or an empty mapping."""
roles = state.get("roles")
return dict(roles) if isinstance(roles, dict) else {}
def inventory_packages_from_state(state: Mapping[str, Any]) -> Dict[str, Any]:
"""Return inventory.packages from a harvest state, or an empty mapping."""
inventory = state.get("inventory")
if not isinstance(inventory, dict):
return {}
packages = inventory.get("packages")
return dict(packages) if isinstance(packages, dict) else {}

313
enroll/system_paths.py Normal file
View file

@ -0,0 +1,313 @@
from __future__ import annotations
import glob
import os
import re
from typing import Dict, List, Set, Tuple
ALLOWED_UNOWNED_EXTS = {
".cfg",
".cnf",
".conf",
".ini",
".json",
".link",
".mount",
".netdev",
".network",
".path",
".rules",
".service",
".socket",
".target",
".timer",
".toml",
".yaml",
".yml",
"", # allow extensionless (common in /etc/default and /etc/init.d)
}
MAX_FILES_CAP = 4000
MAX_UNOWNED_FILES_PER_ROLE = 500
def is_confish(path: str) -> bool:
base = os.path.basename(path)
_, ext = os.path.splitext(base)
return ext in ALLOWED_UNOWNED_EXTS
def scan_unowned_under_roots(
roots: List[str],
owned_etc: Set[str],
limit: int = MAX_UNOWNED_FILES_PER_ROLE,
*,
confish_only: bool = True,
) -> List[str]:
found: List[str] = []
for root in roots:
if not os.path.isdir(root):
continue
for dirpath, _, filenames in os.walk(root):
if len(found) >= limit:
return found
for fn in filenames:
if len(found) >= limit:
return found
p = os.path.join(dirpath, fn)
if not p.startswith("/etc/"):
continue
if p in owned_etc:
continue
if not os.path.isfile(p) or os.path.islink(p):
continue
if confish_only and not is_confish(p):
continue
found.append(p)
return found
def topdirs_for_package(pkg: str, pkg_to_etc_paths: Dict[str, List[str]]) -> Set[str]:
topdirs: Set[str] = set()
for path in pkg_to_etc_paths.get(pkg, []):
parts = path.split("/", 3)
if len(parts) >= 3 and parts[1] == "etc" and parts[2]:
topdirs.add(parts[2])
return topdirs
_APT_SOURCE_GLOBS = [
"/etc/apt/sources.list",
"/etc/apt/sources.list.d/*.list",
"/etc/apt/sources.list.d/*.sources",
]
_SYSTEM_CAPTURE_GLOBS: List[Tuple[str, str]] = [
("/etc/fstab", "system_mounts"),
("/etc/crypttab", "system_mounts"),
("/etc/sysctl.conf", "system_sysctl"),
("/etc/sysctl.d/*", "system_sysctl"),
("/etc/modprobe.d/*", "system_modprobe"),
("/etc/modules", "system_modprobe"),
("/etc/modules-load.d/*", "system_modprobe"),
("/etc/netplan/*", "system_network"),
("/etc/systemd/network/*", "system_network"),
("/etc/network/interfaces", "system_network"),
("/etc/network/interfaces.d/*", "system_network"),
("/etc/resolvconf.conf", "system_network"),
("/etc/resolvconf/resolv.conf.d/*", "system_network"),
("/etc/NetworkManager/system-connections/*", "system_network"),
("/etc/sysconfig/network*", "system_network"),
("/etc/sysconfig/network-scripts/*", "system_network"),
("/etc/nftables.conf", "system_firewall"),
("/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"),
("/etc/firewalld/zones/*", "system_firewall"),
("/etc/selinux/config", "system_security"),
("/etc/rc.local", "system_rc"),
]
_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_ipset_globs() -> List[str]:
return list(_PERSISTENT_IPSET_GLOBS)
def persistent_iptables_v4_globs() -> List[str]:
return list(_PERSISTENT_IPTABLES_V4_GLOBS)
def persistent_iptables_v6_globs() -> List[str]:
return list(_PERSISTENT_IPTABLES_V6_GLOBS)
def persistent_firewall_files(globs: List[str]) -> List[str]:
"""Return persistent firewall files matching ``globs``."""
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] = []
for p in glob.glob(spec):
if len(out) >= cap:
break
if os.path.islink(p):
continue
if os.path.isfile(p):
out.append(p)
continue
if os.path.isdir(p):
for dirpath, _, filenames in os.walk(p):
for fn in filenames:
if len(out) >= cap:
break
fp = os.path.join(dirpath, fn)
if os.path.islink(fp) or not os.path.isfile(fp):
continue
out.append(fp)
if len(out) >= cap:
break
return out
def parse_apt_signed_by(source_files: List[str]) -> Set[str]:
"""Return absolute keyring paths referenced via signed-by / Signed-By."""
out: Set[str] = set()
re_signed_by = re.compile(r"signed-by\s*=\s*([^\]\s]+)", re.IGNORECASE)
re_signed_by_hdr = re.compile(r"^\s*Signed-By\s*:\s*(.+)$", re.IGNORECASE)
for sf in source_files:
try:
with open(sf, "r", encoding="utf-8", errors="replace") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
m = re_signed_by_hdr.match(line)
if m:
val = m.group(1).strip()
if val.startswith("|"):
continue
toks = re.split(r"[\s,]+", val)
for t in toks:
if t.startswith("/"):
out.add(t)
continue
if "[" in line and "]" in line:
bracket = line.split("[", 1)[1].split("]", 1)[0]
for mm in re_signed_by.finditer(bracket):
val = mm.group(1).strip().strip("\"'")
for t in re.split(r"[\s,]+", val):
if t.startswith("/"):
out.add(t)
continue
for mm in re_signed_by.finditer(line):
val = mm.group(1).strip().strip("\"'")
for t in re.split(r"[\s,]+", val):
if t.startswith("/"):
out.add(t)
except OSError:
continue
return out
def iter_apt_capture_paths() -> List[Tuple[str, str]]:
"""Return (path, reason) pairs for APT configuration."""
reasons: Dict[str, str] = {}
if os.path.isdir("/etc/apt"):
for dirpath, _, filenames in os.walk("/etc/apt"):
for fn in filenames:
p = os.path.join(dirpath, fn)
if os.path.islink(p) or not os.path.isfile(p):
continue
reasons.setdefault(p, "apt_config")
apt_sources: List[str] = []
for g in _APT_SOURCE_GLOBS:
apt_sources.extend(iter_matching_files(g))
for p in sorted(set(apt_sources)):
reasons[p] = "apt_source"
for g in (
"/etc/apt/trusted.gpg",
"/etc/apt/trusted.gpg.d/*",
"/etc/apt/keyrings/*",
):
for p in iter_matching_files(g):
reasons[p] = "apt_keyring"
signed_by = parse_apt_signed_by(sorted(set(apt_sources)))
for p in sorted(signed_by):
if os.path.islink(p) or not os.path.isfile(p):
continue
if p.startswith("/etc/apt/"):
reasons[p] = "apt_keyring"
else:
reasons[p] = "apt_signed_by_keyring"
return [(p, reasons[p]) for p in sorted(reasons.keys())]
def iter_dnf_capture_paths() -> List[Tuple[str, str]]:
"""Return (path, reason) pairs for DNF/YUM configuration on RPM systems."""
reasons: Dict[str, str] = {}
for root, tag in (
("/etc/dnf", "dnf_config"),
("/etc/yum", "yum_config"),
):
if os.path.isdir(root):
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
p = os.path.join(dirpath, fn)
if os.path.islink(p) or not os.path.isfile(p):
continue
reasons.setdefault(p, tag)
for p in iter_matching_files("/etc/yum.conf"):
reasons[p] = "yum_conf"
for p in iter_matching_files("/etc/yum.repos.d/*.repo"):
reasons[p] = "yum_repo"
for p in iter_matching_files("/etc/pki/rpm-gpg/*"):
reasons[p] = "rpm_gpg_key"
return [(p, reasons[p]) for p in sorted(reasons.keys())]
def iter_system_capture_paths() -> List[Tuple[str, str]]:
out: List[Tuple[str, str]] = []
seen: Set[str] = set()
for spec, reason in _SYSTEM_CAPTURE_GLOBS:
for path in iter_matching_files(spec):
if path in seen:
continue
seen.add(path)
out.append((path, reason))
return sorted(out, key=lambda x: x[0])

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import json
import os
import stat
import urllib.request
from dataclasses import dataclass
from pathlib import Path
@ -9,6 +11,8 @@ from typing import Any, Dict, List, Optional, Set, Tuple
import jsonschema
from .diff import BundleRef, _bundle_from_input
from .manifest_safety import ArtifactSafetyError, safe_artifact_file
from .state import load_state
@dataclass
@ -96,6 +100,7 @@ def _iter_managed_files(state: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]
"users",
"apt_config",
"dnf_config",
"sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
@ -152,7 +157,7 @@ def validate_harvest(
)
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
state = load_state(bundle.dir)
except Exception as e: # noqa: BLE001
return ValidationResult(
errors=[f"failed to parse state.json: {e!r}"], warnings=[]
@ -169,7 +174,7 @@ def validate_harvest(
except Exception as e: # noqa: BLE001
errors.append(f"failed to load/validate schema: {e!r}")
# Artifact existence checks
# Artifact existence and safety checks.
artifacts_dir = bundle.dir / "artifacts"
referenced: Set[Tuple[str, str]] = set()
for role_name, mf in _iter_managed_files(state):
@ -186,15 +191,15 @@ def validate_harvest(
continue
referenced.add((role_name, src_rel))
p = artifacts_dir / role_name / src_rel
if not p.exists():
try:
safe_artifact_file(bundle.dir, role_name, src_rel)
except FileNotFoundError:
errors.append(
f"missing artifact for role {role_name}: artifacts/{role_name}/{src_rel}"
)
continue
if not p.is_file():
except ArtifactSafetyError as e:
errors.append(
f"artifact is not a file for role {role_name}: artifacts/{role_name}/{src_rel}"
f"unsafe artifact for role {role_name}: artifacts/{role_name}/{src_rel}: {e}"
)
# Runtime firewall snapshots are generated artifacts rather than managed files.
@ -209,39 +214,93 @@ def validate_harvest(
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():
role_name = str(fw.get("role_name") or "firewall_runtime")
referenced.add((role_name, src_rel))
try:
safe_artifact_file(bundle.dir, role_name, src_rel)
except FileNotFoundError:
errors.append(
"missing firewall runtime artifact: "
f"artifacts/{fw.get('role_name') or 'firewall_runtime'}/{src_rel}"
f"artifacts/{role_name}/{src_rel}"
)
elif not p.is_file():
except ArtifactSafetyError as e:
errors.append(
"firewall runtime artifact is not a file: "
f"artifacts/{fw.get('role_name') or 'firewall_runtime'}/{src_rel}"
"unsafe firewall runtime artifact: "
f"artifacts/{role_name}/{src_rel}: {e}"
)
# 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("*"):
if not fp.is_file():
# Validate the whole artifact tree too, so unreferenced symlinks,
# hardlinks, special files, and path-shaping tricks do not survive
# validation simply because no managed_file currently references them.
if artifacts_dir.exists():
try:
artifacts_st = artifacts_dir.lstat()
except OSError as e:
errors.append(f"unable to inspect artifacts directory: {e}")
else:
if stat.S_ISLNK(artifacts_st.st_mode):
errors.append(f"artifacts directory is a symlink: {artifacts_dir}")
elif not stat.S_ISDIR(artifacts_st.st_mode):
errors.append(f"artifacts path is not a directory: {artifacts_dir}")
else:
for root, dirs, files in os.walk(artifacts_dir, followlinks=False):
root_p = Path(root)
for name in list(dirs):
fp = root_p / name
try:
st = fp.lstat()
except FileNotFoundError:
continue
if stat.S_ISLNK(st.st_mode):
errors.append(f"artifact directory is a symlink: {fp}")
elif not stat.S_ISDIR(st.st_mode):
errors.append(
f"artifact directory is not a directory: {fp}"
)
for name in files:
fp = root_p / name
try:
st = fp.lstat()
except FileNotFoundError:
continue
try:
rel = fp.relative_to(artifacts_dir)
except ValueError:
errors.append(f"artifact escapes artifact root: {fp}")
continue
parts = rel.parts
if len(parts) < 2:
errors.append(
f"artifact is not under a role directory: {fp}"
)
continue
role_name = parts[0]
src_rel = "/".join(parts[1:])
if stat.S_ISLNK(st.st_mode):
errors.append(
f"artifact is a symlink: artifacts/{role_name}/{src_rel}"
)
continue
if not stat.S_ISREG(st.st_mode):
errors.append(
f"artifact is not a regular file: artifacts/{role_name}/{src_rel}"
)
continue
if st.st_nlink > 1:
errors.append(
f"artifact is hardlinked: artifacts/{role_name}/{src_rel}"
)
continue
try:
safe_artifact_file(bundle.dir, role_name, src_rel)
except (FileNotFoundError, ArtifactSafetyError) as e:
errors.append(
f"unsafe artifact: artifacts/{role_name}/{src_rel}: {e}"
)
continue
if (role_name, src_rel) not in referenced:
warnings.append(
f"unreferenced artifact present: artifacts/{role_name}/{src_rel}"

View file

@ -28,5 +28,6 @@ def get_enroll_version() -> str:
for dist in [*dist_names, "enroll"]:
try:
return version(dist)
except Exception:
except Exception: # nosec B112
continue
return "unknown"

87
enroll/yamlutil.py Normal file
View file

@ -0,0 +1,87 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Mapping
import yaml
from .render_safety import AnsibleUnsafeText
class IndentedSafeLoader(yaml.SafeLoader): # type: ignore[misc]
"""PyYAML loader that understands Ansible's ``!unsafe`` tag."""
def _construct_ansible_unsafe(
loader: yaml.Loader, node: yaml.Node
) -> AnsibleUnsafeText:
return AnsibleUnsafeText(loader.construct_scalar(node))
IndentedSafeLoader.add_constructor("!unsafe", _construct_ansible_unsafe)
class IndentedSafeDumper(yaml.SafeDumper): # type: ignore[misc]
"""PyYAML dumper that indents sequences under mapping keys."""
def increase_indent(self, flow: bool = False, indentless: bool = False):
# PyYAML calls this method with an ``indentless`` keyword, so the
# parameter name must stay intact even though Enroll deliberately
# ignores its value to force indented block sequences.
return super().increase_indent(flow, False)
def yaml_load_mapping(text: str) -> Dict[str, Any]:
"""Load YAML text and return a mapping, or an empty mapping on failure.
Enroll may re-read Ansible host_vars that contain ``!unsafe`` scalars
written during the same manifest operation, so the loader accepts that tag
while remaining otherwise based on PyYAML's SafeLoader.
"""
try:
obj = yaml.load(
text, Loader=IndentedSafeLoader
) # nosec B506 - subclasses yaml.SafeLoader; only adds !unsafe scalar support.
except Exception:
return {}
return obj if isinstance(obj, dict) else {}
def yaml_load_mapping_file(path: Path) -> Dict[str, Any]:
"""Load a YAML mapping from *path*, returning an empty mapping if absent."""
if not path.exists():
return {}
return yaml_load_mapping(path.read_text(encoding="utf-8"))
def _represent_ansible_unsafe(
dumper: yaml.Dumper, data: AnsibleUnsafeText
) -> yaml.Node:
return dumper.represent_scalar("!unsafe", str(data))
IndentedSafeDumper.add_representer(AnsibleUnsafeText, _represent_ansible_unsafe)
def yaml_dump_mapping(
obj: Mapping[str, Any],
*,
sort_keys: bool = True,
explicit_start: bool = False,
) -> str:
"""Dump a YAML mapping using Enroll's renderer-friendly formatting."""
return (
yaml.dump(
dict(obj),
Dumper=IndentedSafeDumper,
default_flow_style=False,
sort_keys=sort_keys,
indent=2,
allow_unicode=True,
explicit_start=explicit_start,
).rstrip()
+ "\n"
)

371
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "attrs"
@ -6,6 +6,7 @@ version = "26.1.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
{file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
@ -17,6 +18,7 @@ version = "5.0.0"
description = "Modern password hashing for your software and your servers"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be"},
{file = "bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2"},
@ -89,13 +91,14 @@ typecheck = ["mypy"]
[[package]]
name = "certifi"
version = "2026.4.22"
version = "2026.6.17"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["appimage"]
files = [
{file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"},
{file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"},
{file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
{file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
]
[[package]]
@ -104,6 +107,8 @@ version = "2.0.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
@ -200,6 +205,7 @@ version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["appimage"]
files = [
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
@ -338,6 +344,8 @@ version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@ -345,186 +353,170 @@ files = [
[[package]]
name = "coverage"
version = "7.14.0"
version = "7.14.2"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"},
{file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"},
{file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"},
{file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"},
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"},
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"},
{file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"},
{file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"},
{file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"},
{file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"},
{file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"},
{file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"},
{file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"},
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"},
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"},
{file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"},
{file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"},
{file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"},
{file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"},
{file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"},
{file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"},
{file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"},
{file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"},
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"},
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"},
{file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"},
{file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"},
{file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"},
{file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"},
{file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"},
{file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"},
{file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"},
{file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"},
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"},
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"},
{file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"},
{file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"},
{file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"},
{file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"},
{file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"},
{file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"},
{file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"},
{file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"},
{file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"},
{file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"},
{file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"},
{file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"},
{file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"},
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"},
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"},
{file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"},
{file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"},
{file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"},
{file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"},
{file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"},
{file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"},
{file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"},
{file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"},
{file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"},
{file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"},
{file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"},
{file = "coverage-7.14.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59b75818e3046e9319143157f3dc4b43679a550c2060a17cbf3e39cc0b552925"},
{file = "coverage-7.14.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66b08ba4c5cbf0eaa2e9692b203073f198d5d469d8b15d1c7a4854ce7032b2e2"},
{file = "coverage-7.14.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:70f266b536c590060b707dddfb6cf9f17e24fd30b992242e774543d256265c43"},
{file = "coverage-7.14.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb40cac5b1a6378fdccc99268f1033112ee4636e4fd9aaf240f6930d1fcea12c"},
{file = "coverage-7.14.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c301fe9990cb5c081bf4881cb498743807c8e0e93fad7b85c02788456492ef8"},
{file = "coverage-7.14.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d67b0462c8a3c3d93033e7c79cacdfc57d08e5220d9115bcb24a23edf5a5900d"},
{file = "coverage-7.14.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e763087828ee9644f0c89c57f9b75f0a50fdf3e8f5d8fac5cfc351337e89a99"},
{file = "coverage-7.14.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6d4da2baab6d96ceedd9176b3c142e1198b0310bc8dc04e18a3caab65c3a322c"},
{file = "coverage-7.14.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ab565a405bfdea61260145d8cc987aa66d1998fd0e0ccd4348008f4e6a39ee33"},
{file = "coverage-7.14.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c13230b688fbb9122251b74daa092175811eb64cb7bd1c98e2c8193dfa2b0bd5"},
{file = "coverage-7.14.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:014c83ba1ec97993cfe94e77fe6b56daa76bc0c218b86938971574c28942d044"},
{file = "coverage-7.14.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6caf54ffbf84b30470a8118f275afee9234e616572e4e41bae1dc19198c37294"},
{file = "coverage-7.14.2-cp310-cp310-win32.whl", hash = "sha256:4bf9d8a35f77df5638c61b5012ba5225109ec1cc15bc5eb097036b3c3cc939f3"},
{file = "coverage-7.14.2-cp310-cp310-win_amd64.whl", hash = "sha256:c1f17a8caebe0facd4556b1e0adfe0987c17feebed88e7bb6b5365c45c84c5d6"},
{file = "coverage-7.14.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:909f265c8c41f04c824bf741b2601fdcb56cab4bf56e018996b6494192ba0f58"},
{file = "coverage-7.14.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c8102deaf911938233f760426e6a5e287388521de95111d5c8de26c8a1028924"},
{file = "coverage-7.14.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:851f49e7bd7d1cdaf328f3133942b252d5e3d3380690131f423cba8e435b87f5"},
{file = "coverage-7.14.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04cb445bed86aaf00aaa97d41a8b6e30f100f21e81c34caaec4efc684cb57768"},
{file = "coverage-7.14.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7471bc920d97c51c37ea8127f13b2adca43c3d78c53313b26a1f428e99d2c254"},
{file = "coverage-7.14.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da5057e1bb257c967feee8ba67f3ebf379e801c7717f238b3d8c9caf00fc8f93"},
{file = "coverage-7.14.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33c0da852e8a40246cd8e20cf3b2fc17ca52a45e9b5f7983c93db26f5d24b87b"},
{file = "coverage-7.14.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f48a85bb437fab7782021c40bfee6b15146928b96960d008ace41b6901a0f21d"},
{file = "coverage-7.14.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f44e7579a769a21d5b5e3166916bfe30ee175aaffff750324cbb11be2dbec5ad"},
{file = "coverage-7.14.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:78853ca3c6ca2f012daa2b07dbabbb8db0f09d4dbe8ee828d294b3445d3f4cd8"},
{file = "coverage-7.14.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c9c2795ee3692097ff226ab806005d36bb9691fca9b35353542b57ea749cc830"},
{file = "coverage-7.14.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2f5cc48a845d755b6db236f8c29c2b54773eb4c7e4ee2ead43812d73718784b0"},
{file = "coverage-7.14.2-cp311-cp311-win32.whl", hash = "sha256:9c61cb7eaabcfa609c5bc0f5ff5869d72a2f02f17994e5fba5f971de516f3c82"},
{file = "coverage-7.14.2-cp311-cp311-win_amd64.whl", hash = "sha256:e715909b0966d1774d8a26e14e2f4a3ae75909dca526901c6306286b2dcbfbdc"},
{file = "coverage-7.14.2-cp311-cp311-win_arm64.whl", hash = "sha256:9193f7150937a4fd836b10eaa123e15d98e961d1fabac07e60adf2d4785f888a"},
{file = "coverage-7.14.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:37c94712e533ea06f0b1e4d934811c520b1914ce0e4da3916220717aa7a86bc6"},
{file = "coverage-7.14.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c050bbc7bba94c77e4ed7438f4fda1babe98ab145691d80aa6f60df934a1468b"},
{file = "coverage-7.14.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a7af571767a2ee342a171c16fc1b1a07a0bf511606d381703fb7cf397fe49d46"},
{file = "coverage-7.14.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8b4910cce599cd2438f8da65f5ef199a70a1cdb6ab314926df78271ca5954240"},
{file = "coverage-7.14.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c33e9e4878972f430b0cc06de3bf2a28d054a9efb4f8426d27de0d9cb81396ff"},
{file = "coverage-7.14.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7967ea55c6dea6becba4d5870e2fa0aa4915a8be7ebff1bb79e6207aa75ce8d"},
{file = "coverage-7.14.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1322f237c2979b84096f4239c17828ff17fea6b3bbe96c44381c5f587c44c26"},
{file = "coverage-7.14.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:77849525340c99f516d793dddbcee16b18d50af892ac43c8de1a6f343d41e3b5"},
{file = "coverage-7.14.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef11695493ec3f06f7b2678ca274bcabb4ca04057317df268ddbfd8b05f661a8"},
{file = "coverage-7.14.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8134f0e0723e080d1c27bbe8fc149f0162e429fa1852482150015d0fce83eaf1"},
{file = "coverage-7.14.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:914eead2b843fc357f733b3fe39cc94f1b53d466e8cfe03080b1ed9d24ccfc73"},
{file = "coverage-7.14.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4b2d5e847fb7958583b74910cc19e5ec4ece514487385677b26433b2546116e"},
{file = "coverage-7.14.2-cp312-cp312-win32.whl", hash = "sha256:e753db9e40dda7302e0ac3e1e6e1325fb7f7b4694f87a7314ab15dd5d57911a7"},
{file = "coverage-7.14.2-cp312-cp312-win_amd64.whl", hash = "sha256:d32e5ca5f16dafb269ee50b60d32b00c704b3f6f78e238105f1d94a3a5f24bf5"},
{file = "coverage-7.14.2-cp312-cp312-win_arm64.whl", hash = "sha256:dc366f158e2fb2add9d4e57338ca48f12611024278688ee657eb0b853fcb5de5"},
{file = "coverage-7.14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e5f077641a6713ce9d38df9e85d4fb9e008677fc0775cbaeb32ddfc3b319d4ca"},
{file = "coverage-7.14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0907f39b49ae818fe8af50aaa0f19afbc8ca164aea0865181ca7af17a3ac690b"},
{file = "coverage-7.14.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734d47669118d75c28981e562d4530ceb77342d31ffef6def5edd5ad4f05d7b"},
{file = "coverage-7.14.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d9a1b5813d00ea6151f6ccf64d1fa16892771dfdda12ba87162d15ec4ea3e1e"},
{file = "coverage-7.14.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f0a80f4c8ac3f774210b1cc1bc0e31e75502f2818dda9a144ff90e702c4d91d"},
{file = "coverage-7.14.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e66f3f22d6c1515ce70f2e7c3e9c6f3ff0ff33480125c9f9c53e8f6508e30f"},
{file = "coverage-7.14.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a2c37c3114f87ca7f10113756026eecb49656514debad600dcbec21f355ccea"},
{file = "coverage-7.14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b16a7959d04b1497281c062c180413565c3f3469211d78799ad5b9a75f67796"},
{file = "coverage-7.14.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6466c6999545cf00c4c142dfcbbf2db396dc735f005dcf8f91d57e351a79472b"},
{file = "coverage-7.14.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c60915ebb8f562317ba5ff6b8c32e25c0882289b201a9f2fb2987f91efd95d8"},
{file = "coverage-7.14.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:33b830850488acbcd358c78a4fecfafe7031667b4da8ddff5546295dc962cdeb"},
{file = "coverage-7.14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d0f845539230b8269aec902bc978b0cc403f52f002d18a04492efc943404d0bc"},
{file = "coverage-7.14.2-cp313-cp313-win32.whl", hash = "sha256:a8ac51a2e441e9119b9395f4d893fbc4934c64c8ba58be9b9eaa85591249e548"},
{file = "coverage-7.14.2-cp313-cp313-win_amd64.whl", hash = "sha256:039b264cdb31c44b48f9821e2afbf8f37df49e0fb837e24a942918b36c567e31"},
{file = "coverage-7.14.2-cp313-cp313-win_arm64.whl", hash = "sha256:7f2ef591e381cc36b8e53334e1b842c760c520c8a52d01e8626209400e93fe6a"},
{file = "coverage-7.14.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7a0d1f026b72d627fa5c8a57cbc86ad209b64aa2a65833c83b290ace5cbee126"},
{file = "coverage-7.14.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4d2b86f81c1c9310a7e774e3cc9e927a3d0bf583ecbfa01498dd626930025428"},
{file = "coverage-7.14.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d76bdc1f9396ae70a55d050cf9743d88141c62ce0a22a3f627fab1d11c2f8bc6"},
{file = "coverage-7.14.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cda36d8e7bfd63b3e44e75163265429caa5d935b672b00f71bccc8c010518c64"},
{file = "coverage-7.14.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0904f3b79d7b845bef0715afe1900da634d12b97f05b9479cb472880ca07cb9c"},
{file = "coverage-7.14.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6795ca4198d6cb7fc2c6163214f6555a6bc5f0ae1e268e76139dec4b37c4499"},
{file = "coverage-7.14.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c41e9b60fc0fa57f5d73306417d2f9d668202cca6944f9435878c55a5e7ae213"},
{file = "coverage-7.14.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419d2aadd5746efc2e9df0f33c05570d8192e6f6a6098ab05acce586f44ce8a5"},
{file = "coverage-7.14.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1c5d273c5f1411c0d26c4f066c398d4a434b1f97bb5fa409189bedce86d4add4"},
{file = "coverage-7.14.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5fe465bc691264adce601527a972990c1174075d86bcbe9968fd20c95e0b1948"},
{file = "coverage-7.14.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6fbb61617af1c56f95d53170ae9fa6c9aef6de1abd02fcc50064bfc672efb18d"},
{file = "coverage-7.14.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e1eff22b831dfd5694989cc1f0789980f18391f614ac67c851af9a8e6d25e9ba"},
{file = "coverage-7.14.2-cp314-cp314-win32.whl", hash = "sha256:58e91be0a233adef698d3e6be54f10401bb91fd7854c0d4c4d50e0d3711e72f1"},
{file = "coverage-7.14.2-cp314-cp314-win_amd64.whl", hash = "sha256:d8429bf97906bfe6c61f9dbfb3342e0d88120da61939da8bd04f830cc3eab3b8"},
{file = "coverage-7.14.2-cp314-cp314-win_arm64.whl", hash = "sha256:13609d9d77249447aa73357b14831b0f3b95f275026c9ff20dd105f981f53a0c"},
{file = "coverage-7.14.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9818486c2bac88ae931df7e04905ee29bef49fd218c00f5f02bed4855254a101"},
{file = "coverage-7.14.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:58055adffabfa243516a197aa9f85f0dd56d905b0fba1a10193269759c29ccb0"},
{file = "coverage-7.14.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:535747dbc200349d7fb434cffcb28e770f0290f69b225f56dc3803aa7210cdea"},
{file = "coverage-7.14.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:420c66e35d85c0ca5dc6a38147d83ef239762542900e5921ebbdb89333c540ea"},
{file = "coverage-7.14.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2cf17b33773be446a588551ea6a746b2d70dd0bc90dc31f1dd7648975a63c6b"},
{file = "coverage-7.14.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adb4a5fef041f7179bb264203add873c147d169cf2f8d0adae89ff2e51271bac"},
{file = "coverage-7.14.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c012ec357dec9408a83dad5541172a63c5cfa1421709f2e5811480d31ae1b28"},
{file = "coverage-7.14.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dacd0ecd08fda3cb2f85b60cabea7da326dcb2fc15fbb23a88830a80144cc9f2"},
{file = "coverage-7.14.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f27e980f2feba5dfe7a32b22b125470de69c0bd113c75e16165de909a777f512"},
{file = "coverage-7.14.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:105c00efb65c863630b2b63cbf7b8267e4da2d44b62284efbb19a03b04c337d4"},
{file = "coverage-7.14.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:571173fa04c8e8d6235ab32ae67fecca97777e2e1b4a1a30f3022c34e397c1c1"},
{file = "coverage-7.14.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e532f34d42d1a421fa00ed6b7735d14ac2e340256c1bad26a5e1dc1252b0bed7"},
{file = "coverage-7.14.2-cp314-cp314t-win32.whl", hash = "sha256:243971550fb46c3039257f75e65610002d84304c505f609bbd9779e20a653a0a"},
{file = "coverage-7.14.2-cp314-cp314t-win_amd64.whl", hash = "sha256:60fb0ca084a92da96474b8b405a7ea76dfecac3c68db54383e7934b6f3871169"},
{file = "coverage-7.14.2-cp314-cp314t-win_arm64.whl", hash = "sha256:36a0a3f42ed7dfdbca2a69a541519ffd5064a5692152fc0018109e74370d7345"},
{file = "coverage-7.14.2-py3-none-any.whl", hash = "sha256:04d92589e481a8b68a005a5a1e0646a91c76f322c397c4635298c57cf63699b5"},
{file = "coverage-7.14.2.tar.gz", hash = "sha256:7a2da3d81cfe17c18038c6d98e6592aa9147d596d056119b0ee612c3c8bd5230"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
version = "48.0.0"
version = "49.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.9"
groups = ["main"]
files = [
{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"},
{file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"},
{file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"},
{file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"},
{file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"},
{file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"},
{file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"},
{file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"},
{file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"},
{file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"},
{file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"},
{file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"},
{file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"},
{file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"},
]
[package.dependencies]
cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""}
typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11\""}
typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""}
[package.extras]
ssh = ["bcrypt (>=3.1.5)"]
@ -535,6 +527,7 @@ version = "5.0"
description = "A library for working with .desktop files"
optional = false
python-versions = ">=3.10"
groups = ["appimage"]
files = [
{file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"},
{file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"},
@ -549,6 +542,8 @@ version = "1.3.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
markers = "python_version == \"3.10\""
files = [
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
@ -562,13 +557,14 @@ test = ["pytest (>=6)"]
[[package]]
name = "idna"
version = "3.15"
version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["appimage"]
files = [
{file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"},
{file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"},
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
]
[package.extras]
@ -580,6 +576,7 @@ version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
@ -591,6 +588,7 @@ version = "3.0.3"
description = "Pythonic task execution"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053"},
{file = "invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"},
@ -602,6 +600,7 @@ version = "4.26.0"
description = "An implementation of JSON Schema validation for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"},
{file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"},
@ -609,7 +608,7 @@ files = [
[package.dependencies]
attrs = ">=22.2.0"
jsonschema-specifications = ">=2023.03.6"
jsonschema-specifications = ">=2023.3.6"
referencing = ">=0.28.4"
rpds-py = ">=0.25.0"
@ -623,6 +622,7 @@ version = "2025.9.1"
description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"},
{file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"},
@ -637,6 +637,7 @@ version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
@ -648,6 +649,7 @@ version = "5.0.0"
description = "SSH2 protocol library"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c"},
{file = "paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79"},
@ -665,6 +667,7 @@ version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@ -680,6 +683,8 @@ version = "3.0"
description = "C parser in Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
@ -691,6 +696,7 @@ version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
@ -705,6 +711,7 @@ version = "1.6.2"
description = "Python binding to the Networking and Cryptography (NaCl) library"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"},
{file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"},
@ -746,6 +753,7 @@ version = "4.2"
description = "Generate AppImages from your Python projects"
optional = false
python-versions = ">=3.9"
groups = ["appimage"]
files = [
{file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"},
{file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"},
@ -762,6 +770,7 @@ version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
@ -785,6 +794,7 @@ version = "5.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
{file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
@ -803,6 +813,7 @@ version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
@ -885,6 +896,7 @@ version = "0.37.0"
description = "JSON Referencing + Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"},
{file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"},
@ -897,13 +909,14 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
[[package]]
name = "requests"
version = "2.34.1"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["appimage"]
files = [
{file = "requests-2.34.1-py3-none-any.whl", hash = "sha256:bf38a3ff993960d3dd819c08862c40b3c703306eb7c744fcd9f4ddbb95b548f0"},
{file = "requests-2.34.1.tar.gz", hash = "sha256:0fc5669f2b69704449fe1552360bd2a73a54512dfd03e65529157f1513322beb"},
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]
[package.dependencies]
@ -922,6 +935,7 @@ version = "0.30.0"
description = "Python bindings to Rust's persistent data structures (rpds)"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"},
{file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"},
@ -1046,6 +1060,7 @@ version = "2.4.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["appimage", "dev"]
files = [
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
@ -1095,6 +1110,7 @@ files = [
{file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
{file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
]
markers = {appimage = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""}
[[package]]
name = "typing-extensions"
@ -1102,10 +1118,12 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
markers = {main = "python_version < \"3.13\"", dev = "python_version == \"3.10\""}
[[package]]
name = "urllib3"
@ -1113,18 +1131,19 @@ version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
groups = ["appimage"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "30e16396439f2cdd69005a5b7bdf8144aac33422a77a63accbc9eaa74151d851"
lock-version = "2.1"
python-versions = ">=3.10"
content-hash = "30b921854cfa120876ec47a9969f8ff29668f438357b9957c1c47c77ce267b67"

View file

@ -1,34 +1,49 @@
[tool.poetry]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[project]
name = "enroll"
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"
version = "0.7.0b7"
description = "Enroll a server's running state retrospectively into Ansible, Puppet or Salt"
readme = "README.md"
packages = [{ include = "enroll" }]
repository = "https://git.mig5.net/mig5/enroll"
include = [
{ path = "enroll/schema/state.schema.json", format = ["sdist", "wheel"] }
requires-python = ">=3.10"
license = "GPL-3.0-or-later"
authors = [
{ name = "Miguel Jacq", email = "mig@mig5.net" },
]
dependencies = [
"PyYAML>=6,<7",
"paramiko>=3.5",
"jsonschema>=4.23,<5",
]
[tool.poetry.dependencies]
python = "^3.10"
pyyaml = "^6"
paramiko = ">=3.5"
jsonschema = "^4.23.0"
[project.urls]
Repository = "https://git.mig5.net/mig5/enroll"
[tool.poetry.scripts]
[project.scripts]
enroll = "enroll.cli:main"
[build-system]
requires = ["poetry-core>=1.8.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
requires-poetry = ">=2.0"
packages = [{ include = "enroll" }]
include = [
{ path = "enroll/schema/state.schema.json", format = ["sdist", "wheel"] },
]
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
pytest = ">=8,<9"
pytest-cov = ">=5,<6"
[tool.poetry.group.appimage]
optional = true
[tool.poetry.group.appimage.dependencies]
pyproject-appimage = ">=4.2,<5"
[tool.pyproject-appimage]
script = "enroll"
output = "Enroll.AppImage"
[tool.poetry.dev-dependencies]
pytest = "^8"
pytest-cov = "^5"
pyproject-appimage = "^4.2"

5
pytests.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/bash
set -eou pipefail
poetry run python -m pytest -q tests -vvv --cov=enroll --cov-report=term-missing

View file

@ -7,11 +7,9 @@ filedust -y .
# Publish to Pypi
poetry build
poetry publish
# Make AppImage
poetry run pyproject-appimage
mv Enroll.AppImage dist/
poetry run pyproject-appimage --output dist/Enroll.AppImage
# Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
@ -87,6 +85,9 @@ for dist in ${DISTS[@]}; do
qubes-gpg-client --local-user "$KEYID" --detach-sign --armor "$RPM_REPO/repodata/repomd.xml" > "$RPM_REPO/repodata/repomd.xml.asc"
done
# If we got this far, publish to Poetry too
poetry publish
echo "==> Syncing repo to server..."
rsync -aHPvz --exclude=.git --delete "$REPO_ROOT/" "$REMOTE/"

555
tests.sh
View file

@ -1,53 +1,524 @@
#!/bin/bash
set -eo pipefail
set -Eeuo pipefail
# Pytests
poetry run pytest -vvvv --cov=enroll --cov-report=term-missing --disable-warnings
if [[ -d /opt/puppetlabs/bin ]]; then
export PATH="/opt/puppetlabs/bin:${PATH}"
fi
BUNDLE_DIR="/tmp/bundle"
ANSIBLE_DIR="/tmp/ansible"
rm -rf "${BUNDLE_DIR}" "${ANSIBLE_DIR}"
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP_PARENT="${TMPDIR:-/tmp}"
KEEP_WORKDIR=0
if [[ -n "${ENROLL_TEST_WORKDIR:-}" ]]; then
WORK_DIR="${ENROLL_TEST_WORKDIR}"
KEEP_WORKDIR=1
mkdir -p "${WORK_DIR}"
else
WORK_DIR="$(mktemp -d "${TMP_PARENT%/}/enroll-tests.XXXXXX")"
fi
# Install something that has symlinks like apache2,
# to extend the manifests that will be linted later
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends apache2
BUNDLE_DIR="${WORK_DIR}/bundle"
BUNDLE_DIFF_DIR="${WORK_DIR}/bundle-diff"
ANSIBLE_DIR="${WORK_DIR}/ansible"
ANSIBLE_NO_COMMON_DIR="${WORK_DIR}/ansible-no-common"
ANSIBLE_FQDN_DIR="${WORK_DIR}/ansible-fqdn"
PUPPET_DIR="${WORK_DIR}/puppet"
PUPPET_FQDN_DIR="${WORK_DIR}/puppet-fqdn"
SALT_DIR="${WORK_DIR}/salt"
SALT_FQDN_DIR="${WORK_DIR}/salt-fqdn"
ANSIBLE_JINJATURTLE_DIR="${WORK_DIR}/ansible-jinjaturtle"
ANSIBLE_NO_JINJATURTLE_DIR="${WORK_DIR}/ansible-no-jinjaturtle"
PUPPET_JINJATURTLE_DIR="${WORK_DIR}/puppet-jinjaturtle"
PUPPET_NO_JINJATURTLE_DIR="${WORK_DIR}/puppet-no-jinjaturtle"
SALT_JINJATURTLE_DIR="${WORK_DIR}/salt-jinjaturtle"
SALT_NO_JINJATURTLE_DIR="${WORK_DIR}/salt-no-jinjaturtle"
TEST_FQDN="${ENROLL_TEST_FQDN:-enroll-ci.example.test}"
JINJATURTLE_FIXTURE="${WORK_DIR}/enroll-tests-jinjaturtle.ini"
ANSIBLE_PLAYBOOK_EXTRA_ARGS=()
# Generate data
poetry run \
enroll single-shot \
--harvest "${BUNDLE_DIR}" \
--out "${ANSIBLE_DIR}"
cleanup() {
if [[ "${KEEP_WORKDIR}" -eq 0 ]]; then
rm -rf "${WORK_DIR}"
else
printf '\nKeeping ENROLL_TEST_WORKDIR: %s\n' "${WORK_DIR}"
fi
}
trap cleanup EXIT
# Analyse
poetry run \
enroll explain "${BUNDLE_DIR}"
poetry run \
enroll explain "${BUNDLE_DIR}" --format json | jq
section() {
printf '\n================================================================================\n'
printf '%s\n' "$1"
printf '================================================================================\n'
}
# Validate
poetry run \
enroll validate --fail-on-warnings "${BUNDLE_DIR}"
run() {
printf '+ '
printf '%q ' "$@"
printf '\n'
"$@"
}
# Install/remove something, harvest again and diff the harvests
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cowsay
poetry run \
enroll harvest --out "${BUNDLE_DIR}2"
# Validate
poetry run \
enroll validate --fail-on-warnings "${BUNDLE_DIR}2"
# Diff
poetry run \
enroll diff \
--old "${BUNDLE_DIR}" \
--new "${BUNDLE_DIR}2" \
--format json | jq
DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge cowsay
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
# Ansible test
builtin cd "${ANSIBLE_DIR}"
# Lint
ansible-lint "${ANSIBLE_DIR}"
require_root() {
if [[ "$(id -u)" -ne 0 ]]; then
fail "tests.sh must be run as root so harvest and CM noop tests can inspect/apply system state."
fi
}
# Run
ansible-playbook playbook.yml -i "localhost," -c local --check --diff
require_supported_ci_os() {
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
case "${ID:-}" in
debian)
if [[ "${VERSION_ID:-}" != "13" ]]; then
printf 'WARNING: tests.sh is maintained for Debian 13 CI; detected Debian %s.\n' "${VERSION_ID:-unknown}" >&2
fi
;;
almalinux|rhel|rocky|centos|fedora)
printf 'Detected RPM-family CI host: %s %s.\n' "${ID:-unknown}" "${VERSION_ID:-unknown}" >&2
;;
*)
printf 'WARNING: tests.sh is maintained for Debian 13 and AlmaLinux/RHEL-family CI; detected %s %s.\n' "${ID:-unknown}" "${VERSION_ID:-unknown}" >&2
;;
esac
fi
}
pid1_comm() {
if [[ -r /proc/1/comm ]]; then
tr -d '[:space:]' </proc/1/comm || true
return
fi
if command -v ps >/dev/null 2>&1; then
ps -p 1 -o comm= 2>/dev/null | tr -d '[:space:]' || true
fi
}
configure_ansible_playbook_extra_args() {
local pid1
pid1="$(pid1_comm)"
ANSIBLE_PLAYBOOK_EXTRA_ARGS=()
if [[ "${pid1}" != "systemd" ]]; then
section "Setup: Ansible systemd runtime guard"
printf 'PID 1 is %s, not systemd; disabling generated Ansible systemd runtime enforcement for CI noop plays.\n' "${pid1:-unknown}"
ANSIBLE_PLAYBOOK_EXTRA_ARGS=(-e enroll_manage_systemd_runtime=false)
fi
}
os_id() {
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
printf '%s' "${ID:-unknown}"
else
printf 'unknown'
fi
}
os_version_major() {
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
printf '%s' "${VERSION_ID%%.*}"
else
printf 'unknown'
fi
}
is_debian() {
[[ "$(os_id)" == "debian" ]]
}
is_rpm_family() {
case "$(os_id)" in
almalinux|rhel|rocky|centos|fedora) return 0 ;;
*) return 1 ;;
esac
}
pkg_update_once() {
if is_debian; then
if [[ -z "${APT_UPDATED:-}" ]]; then
section "Setup: apt metadata"
run apt-get update
APT_UPDATED=1
fi
elif is_rpm_family; then
if [[ -z "${DNF_UPDATED:-}" ]]; then
section "Setup: dnf metadata"
run dnf -y makecache
DNF_UPDATED=1
fi
else
fail "Unsupported package manager for OS $(os_id)."
fi
}
translate_packages() {
local translated=()
local pkg
for pkg in "$@"; do
if is_debian; then
translated+=("${pkg}")
continue
fi
case "${pkg}" in
ansible) translated+=(ansible-core) ;;
apache2) translated+=(httpd) ;;
gnupg) translated+=(gnupg2) ;;
curl) translated+=(curl-minimal) ;;
lsb-release) translated+=(redhat-lsb-core) ;;
puppet) translated+=(puppet-agent) ;;
python3-apt) ;;
python3-jsonschema) translated+=(python3-jsonschema) ;;
python3-venv) ;;
systemctl) translated+=(systemd) ;;
*) translated+=("${pkg}") ;;
esac
done
printf '%s\n' "${translated[@]}"
}
pkg_install() {
local packages=()
local pkg
while IFS= read -r pkg; do
[[ -n "${pkg}" ]] && packages+=("${pkg}")
done < <(translate_packages "$@")
if [[ "${#packages[@]}" -eq 0 ]]; then
return 0
fi
pkg_update_once
if is_debian; then
run env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}"
elif is_rpm_family; then
ensure_epel_repo
run dnf -y install "${packages[@]}"
else
fail "Unsupported package manager for OS $(os_id)."
fi
}
pkg_remove_purge() {
if is_debian; then
run env DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge "$@"
elif is_rpm_family; then
run dnf -y remove "$@"
else
fail "Unsupported package manager for OS $(os_id)."
fi
}
ensure_epel_repo() {
if ! is_rpm_family; then
return
fi
if rpm -q epel-release >/dev/null 2>&1; then
return
fi
run dnf -y install dnf-plugins-core epel-release
run dnf -y config-manager --set-enabled crb || true
DNF_UPDATED=
}
ensure_salt_repo() {
if is_debian; then
if [[ -e /etc/apt/sources.list.d/salt.sources ]]; then
return
fi
section "Setup: Salt apt repository"
pkg_install ca-certificates curl gnupg
run mkdir -m 755 -p /etc/apt/keyrings
run bash -c "curl -fsSL https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public | gpg --dearmor --yes -o /etc/apt/keyrings/salt-archive-keyring.pgp"
run bash -c "curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources > /etc/apt/sources.list.d/salt.sources"
APT_UPDATED=
elif is_rpm_family; then
if [[ -e /etc/yum.repos.d/salt.repo ]]; then
return
fi
section "Setup: Salt dnf repository"
pkg_install ca-certificates curl
run bash -c "curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo > /etc/yum.repos.d/salt.repo"
DNF_UPDATED=
fi
}
ensure_puppet_repo() {
if ! is_rpm_family; then
return
fi
if rpm -q puppet8-release >/dev/null 2>&1 || [[ -e /etc/yum.repos.d/puppet8-release.repo ]]; then
return
fi
section "Setup: Puppet dnf repository"
local major
major="$(os_version_major)"
run dnf -y install "https://yum.puppet.com/puppet8-release-el-${major}.noarch.rpm"
DNF_UPDATED=
}
ensure_jinjaturtle() {
section "Setup: JinjaTurtle package"
if command -v jinjaturtle >/dev/null 2>&1; then
printf 'jinjaturtle already available at: %s\n' "$(command -v jinjaturtle)"
return
fi
if is_debian; then
pkg_install ca-certificates curl gnupg lsb-release
run mkdir -p /usr/share/keyrings
run bash -c "curl -fsSL https://mig5.net/static/mig5.asc | gpg --dearmor --yes -o /usr/share/keyrings/mig5.gpg"
local codename
codename="$(lsb_release -cs)"
run bash -c "printf '%s\n' 'deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net ${codename} main' > /etc/apt/sources.list.d/mig5.list"
run apt-get update
APT_UPDATED=1
run env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends jinjaturtle
elif is_rpm_family; then
printf 'Skipping JinjaTurtle package integration on RPM-family CI;\n'
return
else
fail "Unsupported OS for JinjaTurtle package install: $(os_id)."
fi
}
require_cmd() {
local cmd="$1"
local hint="$2"
if ! command -v "${cmd}" >/dev/null 2>&1; then
fail "Required command '${cmd}' was not found. ${hint}"
fi
}
ensure_ansible() {
ensure_epel_repo
if ! command -v ansible-playbook >/dev/null 2>&1 || ! command -v ansible-lint >/dev/null 2>&1; then
pkg_install ansible ansible-lint
fi
require_cmd ansible-playbook "Install the ansible/ansible-core package."
require_cmd ansible-lint "Install the ansible-lint package."
}
ensure_puppet() {
ensure_puppet_repo
if ! command -v puppet >/dev/null 2>&1; then
pkg_install puppet || pkg_install puppet-agent
fi
require_cmd puppet "Install Puppet before running the Puppet noop integration tests."
}
ensure_salt() {
ensure_salt_repo
if ! command -v salt-call >/dev/null 2>&1; then
pkg_install salt-minion || true
fi
require_cmd salt-call "Install Salt's salt-call binary before running the Salt noop integration tests. This may require configuring the upstream Salt/Broadcom package repository first."
}
run_pytests() {
section "Python unit tests"
cd "${PROJECT_ROOT}"
run poetry run python -m pytest -vvvv --cov=enroll --cov-report=term-missing --disable-warnings
}
prepare_harvest_fixture() {
section "Common harvest fixture and CLI smoke checks"
pkg_install jq apache2
cat >"${JINJATURTLE_FIXTURE}" <<'EOF'
[enroll_tests]
enabled = true
answer = 42
EOF
cd "${PROJECT_ROOT}"
rm -rf "${BUNDLE_DIR}" "${BUNDLE_DIFF_DIR}"
run poetry run enroll harvest --out "${BUNDLE_DIR}" --include-path "${JINJATURTLE_FIXTURE}"
run poetry run enroll explain "${BUNDLE_DIR}"
run bash -c "poetry run enroll explain '${BUNDLE_DIR}' --format json | jq"
run poetry run enroll validate --fail-on-warnings "${BUNDLE_DIR}"
pkg_install cowsay
run poetry run enroll harvest --out "${BUNDLE_DIFF_DIR}" --include-path "${JINJATURTLE_FIXTURE}"
run poetry run enroll validate --fail-on-warnings "${BUNDLE_DIFF_DIR}"
run bash -c "poetry run enroll diff --old '${BUNDLE_DIR}' --new '${BUNDLE_DIFF_DIR}' --format json | jq"
pkg_remove_purge cowsay
}
assert_template_files() {
local manifest_dir="$1"
local extension="$2"
local expected="$3"
local label="$4"
local found
found="$(find "${manifest_dir}" -type f -name "*.${extension}" -print -quit)"
if [[ "${expected}" == "present" ]]; then
if [[ -z "${found}" ]]; then
fail "Expected ${label} to contain at least one .${extension} template, but none were found."
fi
printf 'Found expected .%s template in %s: %s\n' "${extension}" "${label}" "${found}"
else
if [[ -n "${found}" ]]; then
fail "Expected ${label} to contain no .${extension} templates, but found ${found}."
fi
printf 'Confirmed no .%s templates in %s.\n' "${extension}" "${label}"
fi
}
run_ansible_jinjaturtle_variant() {
local out_dir="$1"
local expected="$2"
local label="$3"
shift 3
ensure_ansible
cd "${PROJECT_ROOT}"
rm -rf "${out_dir}"
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${out_dir}" --target ansible "$@"
assert_template_files "${out_dir}" "j2" "${expected}" "${label}"
ansible-galaxy install -r "${out_dir}/requirements.yml"
run ansible-lint "${out_dir}"
cd "${out_dir}"
run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
}
run_puppet_jinjaturtle_variant() {
local out_dir="$1"
local expected="$2"
local label="$3"
shift 3
ensure_puppet
cd "${PROJECT_ROOT}"
rm -rf "${out_dir}"
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${out_dir}" --target puppet "$@"
assert_template_files "${out_dir}" "erb" "${expected}" "${label}"
run puppet apply --modulepath "${out_dir}/modules" "${out_dir}/manifests/site.pp" --noop
}
run_salt_jinjaturtle_variant() {
local out_dir="$1"
local expected="$2"
local label="$3"
shift 3
ensure_salt
cd "${PROJECT_ROOT}"
rm -rf "${out_dir}"
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${out_dir}" --target salt "$@"
assert_template_files "${out_dir}" "j2" "${expected}" "${label}"
run salt-call --local --retcode-passthrough --file-root "${out_dir}/states" state.apply test=True
}
run_jinjaturtle_manifest_tests() {
if is_rpm_family ; then
section "JinjaTurtle integration matrix"
printf 'Skipping JinjaTurtle package integration on RPM-family CI;\n'
return
fi
ensure_jinjaturtle
require_cmd jinjaturtle "Install JinjaTurtle before running the JinjaTurtle integration matrix."
section "Ansible JinjaTurtle manifest noop tests"
run_ansible_jinjaturtle_variant "${ANSIBLE_JINJATURTLE_DIR}" present "Ansible with JinjaTurtle on PATH"
run_ansible_jinjaturtle_variant "${ANSIBLE_NO_JINJATURTLE_DIR}" absent "Ansible with --no-jinjaturtle" --no-jinjaturtle
section "Puppet JinjaTurtle manifest noop tests"
run_puppet_jinjaturtle_variant "${PUPPET_JINJATURTLE_DIR}" present "Puppet with JinjaTurtle on PATH"
run_puppet_jinjaturtle_variant "${PUPPET_NO_JINJATURTLE_DIR}" absent "Puppet with --no-jinjaturtle" --no-jinjaturtle
section "Salt JinjaTurtle manifest noop tests"
run_salt_jinjaturtle_variant "${SALT_JINJATURTLE_DIR}" present "Salt with JinjaTurtle on PATH"
run_salt_jinjaturtle_variant "${SALT_NO_JINJATURTLE_DIR}" absent "Salt with --no-jinjaturtle" --no-jinjaturtle
}
run_ansible_noop_tests() {
section "Ansible manifest noop tests"
ensure_ansible
cd "${PROJECT_ROOT}"
rm -rf "${ANSIBLE_DIR}" "${ANSIBLE_NO_COMMON_DIR}" "${ANSIBLE_FQDN_DIR}"
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${ANSIBLE_DIR}" --target ansible
ansible-galaxy install -r "${ANSIBLE_DIR}/requirements.yml"
run ansible-lint "${ANSIBLE_DIR}"
cd "${ANSIBLE_DIR}"
run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
cd "${PROJECT_ROOT}"
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${ANSIBLE_NO_COMMON_DIR}" --target ansible --no-common-roles
ansible-galaxy install -r "${ANSIBLE_NO_COMMON_DIR}/requirements.yml"
cd "${ANSIBLE_NO_COMMON_DIR}"
run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
cd "${PROJECT_ROOT}"
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${ANSIBLE_FQDN_DIR}" --target ansible --fqdn "${TEST_FQDN}"
ansible-galaxy install -r "${ANSIBLE_FQDN_DIR}/requirements.yml"
cd "${ANSIBLE_FQDN_DIR}"
run ansible-playbook "playbooks/${TEST_FQDN}.yml" -i inventory/hosts.ini -c local --limit "${TEST_FQDN}" --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
}
run_puppet_noop_tests() {
section "Puppet manifest noop tests"
ensure_puppet
cd "${PROJECT_ROOT}"
rm -rf "${PUPPET_DIR}" "${PUPPET_FQDN_DIR}"
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${PUPPET_DIR}" --target puppet
run puppet apply --modulepath "${PUPPET_DIR}/modules" "${PUPPET_DIR}/manifests/site.pp" --noop
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${PUPPET_FQDN_DIR}" --target puppet --fqdn "${TEST_FQDN}"
run puppet apply \
--modulepath "${PUPPET_FQDN_DIR}/modules" \
--hiera_config "${PUPPET_FQDN_DIR}/hiera.yaml" \
--certname "${TEST_FQDN}" \
"${PUPPET_FQDN_DIR}/manifests/site.pp" \
--noop
}
run_salt_noop_tests() {
section "Salt manifest noop tests"
ensure_salt
cd "${PROJECT_ROOT}"
rm -rf "${SALT_DIR}" "${SALT_FQDN_DIR}"
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_DIR}" --target salt
run salt-call --local --retcode-passthrough --file-root "${SALT_DIR}/states" state.apply test=True
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_FQDN_DIR}" --target salt --fqdn "${TEST_FQDN}"
run salt-call \
--local \
--retcode-passthrough \
--id "${TEST_FQDN}" \
--file-root "${SALT_FQDN_DIR}/states" \
--pillar-root "${SALT_FQDN_DIR}/pillar" \
state.apply test=True
}
main() {
require_root
require_supported_ci_os
run_pytests
prepare_harvest_fixture
configure_ansible_playbook_extra_args
run_ansible_noop_tests
run_puppet_noop_tests
run_salt_noop_tests
run_jinjaturtle_manifest_tests
}
main "$@"

View file

@ -1,7 +1,21 @@
import sys
from pathlib import Path
import pytest
# Ensure repository root is on sys.path so `import enroll` resolves to the local package.
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@pytest.fixture(autouse=True)
def _disable_cli_root_path_prompt_by_default(monkeypatch):
"""Keep CLI tests deterministic when the test runner itself runs as root.
Individual tests that cover the root PATH guard can override this monkeypatch.
"""
import enroll.cli as cli
monkeypatch.setattr(cli, "_is_effective_root", lambda: False)

234
tests/state_helpers.py Normal file
View file

@ -0,0 +1,234 @@
from __future__ import annotations
import copy
import json
from pathlib import Path
from typing import Any
_VALID_REASON_FALLBACKS = {
"dangerous_user_dotfile": "user_shell_rc",
"possible_secret": "sensitive_content",
}
_COMMON_ROLES = {
"users",
"apt_config",
"dnf_config",
"etc_custom",
"usr_local_custom",
"extra_paths",
}
def _common_role(name: str) -> dict[str, Any]:
out: dict[str, Any] = {
"role_name": name,
"managed_dirs": [],
"managed_files": [],
"excluded": [],
"notes": [],
}
if name == "users":
out["users"] = []
if name == "extra_paths":
out["include_patterns"] = []
out["exclude_patterns"] = []
out["managed_links"] = []
return out
def _normalise_managed_file(mf: dict[str, Any]) -> None:
reason = mf.get("reason")
if reason in _VALID_REASON_FALLBACKS:
mf["reason"] = _VALID_REASON_FALLBACKS[reason]
mf.setdefault("owner", "root")
mf.setdefault("group", "root")
mf.setdefault("mode", "0644")
mf.setdefault("reason", "modified_conffile")
def _normalise_managed_dir(md: dict[str, Any]) -> None:
md.setdefault("owner", "root")
md.setdefault("group", "root")
md.setdefault("mode", "0755")
if md.get("reason") in {None, "parent_dir"}:
md["reason"] = "parent_of_managed_file"
def _normalise_managed_link(ml: dict[str, Any]) -> None:
ml.setdefault("reason", "enabled_symlink")
def _normalise_common_role(role: dict[str, Any], name: str) -> None:
role.setdefault("role_name", name)
role.setdefault("managed_dirs", [])
role.setdefault("managed_files", [])
role.setdefault("excluded", [])
role.setdefault("notes", [])
for mf in role.get("managed_files") or []:
if isinstance(mf, dict):
_normalise_managed_file(mf)
for md in role.get("managed_dirs") or []:
if isinstance(md, dict):
_normalise_managed_dir(md)
for ml in role.get("managed_links") or []:
if isinstance(ml, dict):
_normalise_managed_link(ml)
for ex in role.get("excluded") or []:
if isinstance(ex, dict) and ex.get("reason") in _VALID_REASON_FALLBACKS:
ex["reason"] = _VALID_REASON_FALLBACKS[ex["reason"]]
def make_schema_valid_state(state: dict[str, Any]) -> dict[str, Any]:
"""Return a current-schema harvest state from a compact renderer fixture.
Many renderer tests intentionally build only the fields needed by the
renderer under test. Manifest now validates strictly before rendering, so
those fixtures need current-schema boilerplate too.
"""
st = copy.deepcopy(state)
st.pop("schema_version", None)
enroll = st.setdefault("enroll", {})
enroll.setdefault("version", "0.0.test")
enroll.setdefault("harvest_time", 0)
host = st.setdefault("host", {})
host.setdefault("hostname", "testhost")
host.setdefault("os", "unknown")
host.setdefault("pkg_backend", "dpkg")
host.setdefault("os_release", {})
inv = st.setdefault("inventory", {})
inv.setdefault("packages", {})
for pkg in (inv.get("packages") or {}).values():
if not isinstance(pkg, dict):
continue
pkg.setdefault("version", None)
pkg.setdefault("arches", [])
installations = pkg.setdefault("installations", [])
for inst in installations:
if isinstance(inst, dict):
inst.setdefault("version", str(pkg.get("version") or "1.0"))
inst.setdefault("arch", "amd64")
observed = pkg.setdefault("observed_via", [])
for ov in observed:
if isinstance(ov, dict) and ov.get("kind") not in {
"user_installed",
"systemd_unit",
"package_role",
"firewall_runtime",
}:
ov["kind"] = "package_role"
ov.setdefault("ref", "package")
pkg.setdefault("roles", [])
roles = st.setdefault("roles", {})
for name in _COMMON_ROLES:
cur = roles.get(name)
if not isinstance(cur, dict):
roles[name] = _common_role(name)
else:
_normalise_common_role(cur, name)
roles.setdefault("services", [])
roles.setdefault("packages", [])
users = roles.get("users") or {}
users.setdefault("users", [])
for user in users.get("users") or []:
if not isinstance(user, dict):
continue
user.setdefault("uid", 0)
user.setdefault("gid", user.get("uid", 0))
user.setdefault("gecos", "")
user.setdefault("home", f"/home/{user.get('name', 'user')}")
user.setdefault("shell", "/bin/sh")
user.setdefault("primary_group", user.get("name", "users"))
user.setdefault("supplementary_groups", [])
extra = roles.get("extra_paths") or {}
extra.setdefault("include_patterns", [])
extra.setdefault("exclude_patterns", [])
extra.setdefault("managed_links", [])
for svc in roles.get("services") or []:
if not isinstance(svc, dict):
continue
_normalise_common_role(svc, str(svc.get("role_name") or "service_role"))
svc.setdefault("unit", "example.service")
svc.setdefault("packages", [])
svc.setdefault("active_state", None)
svc.setdefault("sub_state", None)
svc.setdefault("unit_file_state", None)
svc.setdefault("condition_result", None)
for pkg in roles.get("packages") or []:
if not isinstance(pkg, dict):
continue
_normalise_common_role(
pkg, str(pkg.get("role_name") or pkg.get("package") or "package_role")
)
pkg.setdefault("package", str(pkg.get("role_name") or "package"))
if isinstance(roles.get("sysctl"), dict):
sysctl = roles["sysctl"]
sysctl.setdefault("role_name", "sysctl")
sysctl.setdefault("managed_files", [])
sysctl.setdefault("parameters", {})
sysctl.setdefault("notes", [])
sysctl.pop("managed_dirs", None)
sysctl.pop("managed_links", None)
for mf in sysctl.get("managed_files") or []:
if isinstance(mf, dict):
_normalise_managed_file(mf)
if isinstance(roles.get("firewall_runtime"), dict):
fw = roles["firewall_runtime"]
fw.setdefault("role_name", "firewall_runtime")
fw.setdefault("packages", [])
fw.setdefault("ipset_save", None)
fw.setdefault("ipset_sets", [])
fw.setdefault("iptables_v4_save", None)
fw.setdefault("iptables_v6_save", None)
fw.setdefault("notes", [])
if isinstance(roles.get("flatpak"), dict):
roles["flatpak"].setdefault("role_name", "flatpak")
if isinstance(roles.get("snap"), dict):
roles["snap"].setdefault("role_name", "snap")
if isinstance(roles.get("container_images"), dict):
ci = roles["container_images"]
ci.setdefault("role_name", "container_images")
ci.setdefault("images", [])
ci.setdefault("notes", [])
for img in ci.get("images") or []:
if not isinstance(img, dict):
continue
img.setdefault("engine", "docker")
img.setdefault("scope", "system")
img.setdefault("user", None)
img.setdefault("home", None)
img.setdefault("image_id", None)
img.setdefault("repo_tags", [])
img.setdefault("repo_digests", [])
img.setdefault("pull_ref", None)
img.setdefault("tag_aliases", [])
img.setdefault("os", None)
img.setdefault("architecture", None)
img.setdefault("variant", None)
img.setdefault("platform", None)
img.setdefault("size", None)
img.setdefault("created", None)
img.setdefault("source", "test")
img.setdefault("notes", [])
return st
def write_schema_state(bundle: Path, state: dict[str, Any]) -> None:
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(
json.dumps(make_schema_valid_state(state), indent=2), encoding="utf-8"
)

View file

@ -141,3 +141,375 @@ 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_ignores_symlinked_ssh_dir(tmp_path: Path):
"""A user who replaces ~/.ssh with a symlink to a sensitive directory must
not have files inside it harvested through the symlinked parent. os.path.isdir
follows symlinks, so the directory itself must be checked with islink().
"""
from enroll.accounts import find_user_ssh_files
sensitive = tmp_path / "sensitive"
sensitive.mkdir()
(sensitive / "authorized_keys").write_text("ssh-rsa AAAA...\n", encoding="utf-8")
home = tmp_path / "home" / "mallory"
home.mkdir(parents=True)
os.symlink(str(sensitive), str(home / ".ssh"))
assert find_user_ssh_files(str(home)) == []
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
def test_find_flatpaks_in_root_detects_remote_branch_and_arch(tmp_path: Path):
import enroll.accounts as a
root = tmp_path / "flatpak"
(root / "repo").mkdir(parents=True)
(root / "repo" / "config").write_text(
'[remote "acme"]\nurl=https://flatpak.example/repo/\n',
encoding="utf-8",
)
ref = (
root
/ "repo"
/ "refs"
/ "remotes"
/ "acme"
/ "app"
/ "com.example.App"
/ "x86_64"
/ "stable"
)
ref.parent.mkdir(parents=True)
ref.write_text("checksum\n", encoding="utf-8")
active = root / "app" / "com.example.App" / "x86_64" / "stable" / "active"
active.mkdir(parents=True)
remotes = a.find_flatpak_remotes(str(root), method="system")
assert [(r.name, r.url, r.method) for r in remotes] == [
("acme", "https://flatpak.example/repo/", "system")
]
apps = a._find_flatpaks_in_root(str(root), method="system")
assert len(apps) == 1
assert apps[0].name == "com.example.App"
assert apps[0].remote == "acme"
assert apps[0].branch == "stable"
assert apps[0].arch == "x86_64"
def test_parse_snap_list_output_detects_channel_revision_and_modes():
import enroll.accounts as a
output = """Name Version Rev Tracking Publisher Notes
code abc 123 latest/stable vscode classic
mydev 1.0 42 latest/edge example devmode,dangerous
bare 1.0 5 latest/stable canonical base
"""
snaps = {snap.name: snap for snap in a._parse_snap_list_output(output)}
assert snaps["code"].channel == "latest/stable"
assert snaps["code"].revision == 123
assert snaps["code"].classic is True
assert snaps["mydev"].devmode is True
assert snaps["mydev"].dangerous is True
assert snaps["bare"].notes == ["base"]
def test_parse_flatpak_list_output_detects_system_refs():
from enroll.accounts import _parse_flatpak_list_output
output = "\n".join(
[
"app/org.example.App/x86_64/stable\tflathub\tstable\tx86_64",
"runtime/org.freedesktop.Platform/x86_64/24.08\tflathub\t24.08\tx86_64",
]
)
refs = _parse_flatpak_list_output(
output, method="system", columns=("ref", "origin", "branch", "arch")
)
assert [(r.kind, r.name, r.remote, r.branch, r.arch) for r in refs] == [
("app", "org.example.App", "flathub", "stable", "x86_64"),
("runtime", "org.freedesktop.Platform", "flathub", "24.08", "x86_64"),
]
assert refs[0].source == "flatpak-list"
def test_find_system_flatpaks_prefers_flatpak_list(monkeypatch):
import subprocess
import enroll.accounts as a
calls = []
def fake_run(args, **kwargs):
calls.append(args)
if args == ["flatpak", "list", "--columns=help"]:
return subprocess.CompletedProcess(
args,
0,
stdout="application\norigin\nbranch\narch\n",
stderr="",
)
return subprocess.CompletedProcess(
args,
0,
stdout="app/org.example.App/x86_64/stable\tacme\tstable\tx86_64\n",
stderr="",
)
monkeypatch.setattr(a.shutil, "which", lambda cmd: "/usr/bin/flatpak")
monkeypatch.setattr(a.subprocess, "run", fake_run)
monkeypatch.setattr(
a,
"_find_flatpaks_in_root",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("fallback used")),
)
refs = a.find_system_flatpaks()
assert calls[0] == ["flatpak", "list", "--columns=help"]
assert calls[1][:3] == ["flatpak", "list", "--system"]
assert refs[0].name == "org.example.App"
assert refs[0].method == "system"
assert refs[0].remote == "acme"
def test_parse_flatpak_list_output_detects_application_columns():
from enroll.accounts import _parse_flatpak_list_output
output = "org.example.App\tflathub\tstable\tx86_64\n"
refs = _parse_flatpak_list_output(
output, method="system", columns=("application", "origin", "branch", "arch")
)
assert len(refs) == 1
assert refs[0].name == "org.example.App"
assert refs[0].kind is None
assert refs[0].remote == "flathub"
assert refs[0].branch == "stable"
assert refs[0].arch == "x86_64"
def test_parse_plain_flatpak_list_output_like_default_table():
from enroll.accounts import _parse_flatpak_list_output
output = """Name Application ID Version Branch Installation
Mesa org.freedesktop.Platform.GL.default 26.0.6 25.08 system
Mesa (Extra) org.freedesktop.Platform.GL.default 26.0.6 25.08-extra system
Codecs Extra Extension org.freedesktop.Platform.codecs-extra 25.08-extra system
KDE Application Platform org.kde.Platform 6.10 system
OnionShare org.onionshare.OnionShare 2.6.4 stable system
"""
refs = _parse_flatpak_list_output(output, method="system", columns=None)
by_name_branch = {(r.name, r.branch) for r in refs}
assert ("org.onionshare.OnionShare", "stable") in by_name_branch
assert ("org.freedesktop.Platform.GL.default", "25.08") in by_name_branch
assert ("org.freedesktop.Platform.GL.default", "25.08-extra") in by_name_branch
assert ("org.kde.Platform", "6.10") in by_name_branch
def test_parse_flatpak_columns_help_handles_description_table():
from enroll.accounts import _parse_flatpak_columns_help
output = """
Available columns:
application The application ID
branch The branch
installation The installation
"""
assert _parse_flatpak_columns_help(output) >= {
"application",
"branch",
"installation",
}
def test_flatpak_list_attempts_respect_supported_columns():
from enroll.accounts import _flatpak_list_attempts
attempts = _flatpak_list_attempts(
"--system", {"application", "branch", "installation"}
)
command_strings = [" ".join(args) for args, _columns in attempts]
assert any("--columns=application,branch" in cmd for cmd in command_strings)
assert not any("origin" in cmd for cmd in command_strings)
assert command_strings[-1] == "flatpak list --system"

View file

@ -31,3 +31,108 @@ 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")
def test_ensure_dir_secure_refuses_symlink_parent(tmp_path: Path):
from enroll.cache import _ensure_dir_secure
target = tmp_path / "target"
target.mkdir()
link = tmp_path / "link"
link.symlink_to(target, target_is_directory=True)
with pytest.raises(RuntimeError, match="symlink"):
_ensure_dir_secure(link / "enroll" / "harvest")
assert not (target / "enroll" / "harvest").exists()
def test_ensure_dir_secure_rejects_unsafe_root_parent(tmp_path: Path, monkeypatch):
from enroll.cache import _ensure_dir_secure
import enroll.harvest_safety as hs
untrusted = tmp_path / "untrusted"
untrusted.mkdir()
untrusted.chmod(0o777)
monkeypatch.setattr(hs, "_effective_uid", lambda: 0)
with pytest.raises(RuntimeError, match="not owned by root|writable by group/other"):
_ensure_dir_secure(untrusted / "cache")
def test_ensure_dir_secure_rejects_existing_file_when_not_root(
tmp_path: Path, monkeypatch
):
from enroll.cache import _ensure_dir_secure
import enroll.harvest_safety as hs
path = tmp_path / "cache"
path.write_text("not a dir", encoding="utf-8")
monkeypatch.setattr(hs, "_effective_uid", lambda: 1000)
with pytest.raises(RuntimeError, match="not a directory"):
_ensure_dir_secure(path)

View file

@ -47,6 +47,8 @@ def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
# Common manifest args should be passed through by the CLI.
called["fqdn"] = kwargs.get("fqdn")
called["jinjaturtle"] = kwargs.get("jinjaturtle")
called["no_common_roles"] = kwargs.get("no_common_roles")
called["target"] = kwargs.get("target")
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
@ -67,6 +69,125 @@ def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
assert called["out"] == str(tmp_path / "ansible")
assert called["fqdn"] is None
assert called["jinjaturtle"] == "auto"
assert called["no_common_roles"] is False
assert called["target"] == "ansible"
def test_cli_force_unsafe_path_before_subcommand_reaches_guard(monkeypatch, tmp_path):
seen = {}
def fake_confirm(*, force: bool = False) -> None:
seen["force"] = force
def fake_manifest(_harvest_dir: str, _out_dir: str, **_kwargs):
return None
monkeypatch.setattr(cli, "_confirm_root_path_safety", fake_confirm)
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"--assume-safe-path",
"manifest",
"--harvest",
str(tmp_path / "bundle"),
"--out",
str(tmp_path / "ansible"),
],
)
cli.main()
assert seen["force"] is True
def test_cli_force_unsafe_path_after_subcommand_reaches_guard(monkeypatch, tmp_path):
seen = {}
def fake_confirm(*, force: bool = False) -> None:
seen["force"] = force
def fake_manifest(_harvest_dir: str, _out_dir: str, **_kwargs):
return None
monkeypatch.setattr(cli, "_confirm_root_path_safety", fake_confirm)
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"manifest",
"--assume-safe-path",
"--harvest",
str(tmp_path / "bundle"),
"--out",
str(tmp_path / "ansible"),
],
)
cli.main()
assert seen["force"] is True
def test_cli_manifest_target_puppet_is_forwarded(monkeypatch, tmp_path):
called = {}
def fake_manifest(harvest_dir: str, out_dir: str, **kwargs):
called["harvest"] = harvest_dir
called["out"] = out_dir
called["target"] = kwargs.get("target")
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"manifest",
"--harvest",
str(tmp_path / "bundle"),
"--out",
str(tmp_path / "puppet"),
"--target",
"puppet",
],
)
cli.main()
assert called["harvest"] == str(tmp_path / "bundle")
assert called["out"] == str(tmp_path / "puppet")
assert called["target"] == "puppet"
def test_cli_manifest_no_common_roles_is_forwarded(monkeypatch, tmp_path):
called = {}
def fake_manifest(harvest_dir: str, out_dir: str, **kwargs):
called["harvest"] = harvest_dir
called["out"] = out_dir
called["no_common_roles"] = kwargs.get("no_common_roles")
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"manifest",
"--harvest",
str(tmp_path / "bundle"),
"--out",
str(tmp_path / "ansible"),
"--no-common-roles",
],
)
cli.main()
assert called["harvest"] == str(tmp_path / "bundle")
assert called["out"] == str(tmp_path / "ansible")
assert called["no_common_roles"] is True
def test_cli_enroll_subcommand_runs_harvest_then_manifest(monkeypatch, tmp_path):

View file

@ -23,10 +23,10 @@ def test_discover_config_path_precedence(monkeypatch, tmp_path: Path):
assert _discover_config_path(["harvest"]) == cfg
def test_discover_config_path_finds_local_and_xdg(monkeypatch, tmp_path: Path):
def test_discover_config_path_ignores_local_and_finds_xdg(monkeypatch, tmp_path: Path):
from enroll.cli import _discover_config_path
# local file in cwd
# local files in cwd are deliberately ignored unless passed via --config
cwd = tmp_path / "cwd"
cwd.mkdir()
local = cwd / "enroll.ini"
@ -35,7 +35,8 @@ def test_discover_config_path_finds_local_and_xdg(monkeypatch, tmp_path: Path):
monkeypatch.chdir(cwd)
monkeypatch.delenv("ENROLL_CONFIG", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
assert _discover_config_path(["harvest"]) == local
assert _discover_config_path(["harvest"]) is None
assert _discover_config_path(["--config", str(local), "harvest"]) == local
# xdg config fallback
monkeypatch.chdir(tmp_path)

View file

@ -2,13 +2,14 @@ from __future__ import annotations
import argparse
import configparser
import os
import types
import textwrap
from pathlib import Path
def test_discover_config_path_precedence(tmp_path: Path, monkeypatch):
"""_discover_config_path: --config > ENROLL_CONFIG > ./enroll.ini > XDG."""
"""_discover_config_path: --config > ENROLL_CONFIG > XDG."""
from enroll.cli import _discover_config_path
cfg1 = tmp_path / "one.ini"
@ -27,14 +28,14 @@ def test_discover_config_path_precedence(tmp_path: Path, monkeypatch):
monkeypatch.setenv("ENROLL_CONFIG", str(cfg2))
assert _discover_config_path([]) == cfg2
# Local ./enroll.ini fallback.
# Local ./enroll.ini is ignored unless passed explicitly.
monkeypatch.delenv("ENROLL_CONFIG", raising=False)
local = tmp_path / "enroll.ini"
local.write_text("[enroll]\n", encoding="utf-8")
assert _discover_config_path([]) == local
assert _discover_config_path([]) is None
assert _discover_config_path(["--config", str(local)]) == local
# XDG fallback.
local.unlink()
xdg = tmp_path / "xdg"
cfg3 = xdg / "enroll" / "enroll.ini"
cfg3.parent.mkdir(parents=True)
@ -175,3 +176,87 @@ def test_resolve_sops_out_file(tmp_path: Path, monkeypatch):
cli._resolve_sops_out_file(out=None, hint="bundle.tar.gz")
== fake_cache.dir / "harvest.tar.gz.sops"
)
def test_unsafe_root_path_reasons_flags_current_and_writable_dirs(tmp_path: Path):
from enroll.cli import _unsafe_root_path_reasons
group_writable = tmp_path / "group-writable"
world_writable = tmp_path / "world-writable"
safe = tmp_path / "safe"
group_writable.mkdir()
world_writable.mkdir()
safe.mkdir()
group_writable.chmod(0o775)
world_writable.chmod(0o777)
safe.chmod(0o755)
reasons = _unsafe_root_path_reasons(
os.pathsep.join(
[
"",
".",
"relative-bin",
str(group_writable),
str(world_writable),
str(safe),
]
)
)
text = "\n".join(reasons)
assert "<empty>: empty PATH entry" in text
assert "'.' resolves" in text
assert "relative-bin: relative PATH entry" in text
assert f"{group_writable}: directory is group-writable" in text
assert f"{world_writable}: directory is world-writable" in text
assert str(safe) not in text
def test_confirm_root_path_safety_refuses_noninteractive(monkeypatch):
from enroll import cli
monkeypatch.setattr(cli, "_is_effective_root", lambda: True)
monkeypatch.setattr(
cli,
"_unsafe_root_path_reasons",
lambda path_value=None: [".: '.' resolves to the current directory"],
)
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: False)
try:
cli._confirm_root_path_safety(force=False)
except SystemExit as e:
assert "--assume-safe-path" in str(e)
else: # pragma: no cover - defensive assertion path
raise AssertionError("expected SystemExit")
def test_confirm_root_path_safety_force_skips_prompt(monkeypatch):
from enroll import cli
monkeypatch.setattr(cli, "_is_effective_root", lambda: True)
monkeypatch.setattr(
cli,
"_unsafe_root_path_reasons",
lambda path_value=None: [".: '.' resolves to the current directory"],
)
cli._confirm_root_path_safety(force=True)
def test_unsafe_root_path_reasons_flags_non_root_owned_dir(tmp_path: Path, monkeypatch):
from enroll import cli
non_root_owned = tmp_path / "user-bin"
non_root_owned.mkdir()
if hasattr(os, "geteuid") and os.geteuid() == 0:
try:
os.chown(non_root_owned, 65534, -1)
except OSError:
pass
monkeypatch.setattr(cli, "_is_effective_root", lambda: True)
reasons = cli._unsafe_root_path_reasons(str(non_root_owned))
assert any("not owned by root" in reason for reason in reasons)

107
tests/test_cm.py Normal file
View file

@ -0,0 +1,107 @@
from __future__ import annotations
from enroll.cm import CMModule, resolve_catalog_conflicts
def test_resolve_catalog_conflicts_dedupes_before_rendering():
first = CMModule(role_name="admin", module_name="admin")
first.packages.add("curl")
first.dirs["/etc/default"] = {"owner": "root"}
first.files["/etc/foo.conf"] = {"owner": "root"}
second = CMModule(role_name="misc", module_name="misc")
second.packages.add("curl")
second.dirs["/etc/default"] = {"owner": "root"}
second.dirs["/etc/foo.conf"] = {"owner": "root"}
second.files["/etc/foo.conf"] = {"owner": "root"}
resolve_catalog_conflicts([first, second])
assert first.packages == {"curl"}
assert "/etc/default" in first.dirs
assert "/etc/foo.conf" in first.files
assert second.packages == set()
assert second.dirs == {}
assert second.files == {}
assert any("duplicate Package[curl]" in note for note in second.notes)
assert any("duplicate File[/etc/default]" in note for note in second.notes)
assert any("a file or link with the same path" in note for note in second.notes)
def test_cm_module_uses_shared_state_io(tmp_path):
state = {"roles": {"packages": []}}
written = CMModule.write_state(tmp_path, state)
assert written == tmp_path / "state.json"
assert CMModule.state_path(tmp_path) == written
assert CMModule.load_state(tmp_path) == state
assert CMModule._load_state(tmp_path) == state
def test_active_service_units_for_package_snapshot_is_conservative():
entries = [
{
"kind": "service",
"snapshot": {
"unit": "docker.service",
"role_name": "docker",
"packages": ["docker.io"],
"active_state": "active",
},
},
{
"kind": "service",
"snapshot": {
"unit": "docker-cleanup.service",
"role_name": "docker_cleanup",
"packages": ["docker.io"],
"active_state": "inactive",
},
},
]
by_package = CMModule.active_service_units_by_package(entries)
assert by_package == {
"docker.io": [{"unit": "docker.service", "role_name": "docker"}]
}
assert CMModule.active_service_units_for_package_snapshot(
{"package": "docker.io", "role_name": "docker"}, by_package
) == ["docker.service"]
def test_active_service_units_for_package_snapshot_avoids_ambiguous_restarts():
entries = [
{
"kind": "service",
"snapshot": {
"unit": "alpha.service",
"role_name": "alpha",
"packages": ["shared"],
"active_state": "active",
},
},
{
"kind": "service",
"snapshot": {
"unit": "beta.service",
"role_name": "beta",
"packages": ["shared"],
"active_state": "active",
},
},
]
by_package = CMModule.active_service_units_by_package(entries)
assert (
CMModule.active_service_units_for_package_snapshot(
{"package": "shared", "role_name": "shared"}, by_package
)
== []
)
assert CMModule.active_service_units_for_package_snapshot(
{"package": "shared", "role_name": "beta"}, by_package
) == ["beta.service"]

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
import pytest
def test_dpkg_owner_parses_output(monkeypatch):
@ -96,3 +97,448 @@ 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\tweb\nvim\t8.2\tamd64\teditors\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 result["nginx"][0]["section"] == "web"
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_missing_status(tmp_path: Path):
import enroll.debian as d
assert d.parse_status_conffiles(str(tmp_path / "missing-status")) == {}
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

View file

@ -244,6 +244,7 @@ def test_enforce_old_harvest_runs_ansible_with_tags_from_file_drift(
# Stub manifest generation to only create playbook.yml (fast, no real roles needed).
def fake_manifest(_harvest_dir: str, out_dir: str, **_kwargs):
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=False)
(out / "playbook.yml").write_text(
"---\n- hosts: all\n gather_facts: false\n roles: []\n",
encoding="utf-8",
@ -309,6 +310,165 @@ def test_enforce_old_harvest_runs_ansible_with_tags_from_file_drift(
assert "role_usr_local_custom" in str(argv[i + 1])
def test_enforce_old_harvest_runs_puppet_target(monkeypatch, tmp_path: Path):
import enroll.diff as d
import enroll.manifest as mf
monkeypatch.setattr(
d.shutil,
"which",
lambda name: "/usr/bin/puppet" if name == "puppet" else None,
)
calls: dict[str, object] = {}
def fake_manifest(_harvest_dir: str, out_dir: str, **kwargs):
calls["manifest_target"] = kwargs.get("target")
out = Path(out_dir)
(out / "manifests").mkdir(parents=True)
(out / "modules").mkdir(parents=True)
(out / "manifests" / "site.pp").write_text(
"node default { }\n", encoding="utf-8"
)
monkeypatch.setattr(mf, "manifest", fake_manifest)
def fake_run(
argv, cwd=None, env=None, capture_output=False, text=False, check=False
):
calls["argv"] = list(argv)
calls["cwd"] = cwd
return types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
monkeypatch.setattr(d.subprocess, "run", fake_run)
old = tmp_path / "old"
_write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
report = {
"packages": {"added": [], "removed": ["curl"], "version_changed": []},
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
"users": {"added": [], "removed": [], "changed": []},
"files": {"added": [], "removed": [], "changed": []},
}
info = d.enforce_old_harvest(str(old), report=report, target="puppet")
assert info["status"] == "applied"
assert info["target"] == "puppet"
assert info["tool"] == "puppet apply"
assert info["scope"] == "full_manifest"
assert info["tags"] == []
assert calls["manifest_target"] == "puppet"
argv = calls.get("argv")
assert argv and argv[:2] == ["/usr/bin/puppet", "apply"]
assert "--modulepath" in argv
assert any(
str(Path(calls["cwd"]) / "manifest" / "manifests" / "site.pp") == str(a)
for a in argv
)
def test_enforce_old_harvest_runs_salt_target(monkeypatch, tmp_path: Path):
import enroll.diff as d
import enroll.manifest as mf
monkeypatch.setattr(
d.shutil,
"which",
lambda name: "/usr/bin/salt-call" if name == "salt-call" else None,
)
calls: dict[str, object] = {}
def fake_manifest(_harvest_dir: str, out_dir: str, **kwargs):
calls["manifest_target"] = kwargs.get("target")
out = Path(out_dir)
(out / "states").mkdir(parents=True)
(out / "states" / "top.sls").write_text("base:\n '*': []\n", encoding="utf-8")
monkeypatch.setattr(mf, "manifest", fake_manifest)
def fake_run(
argv, cwd=None, env=None, capture_output=False, text=False, check=False
):
calls["argv"] = list(argv)
calls["cwd"] = cwd
return types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
monkeypatch.setattr(d.subprocess, "run", fake_run)
old = tmp_path / "old"
_write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
report = {
"packages": {"added": [], "removed": ["curl"], "version_changed": []},
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
"users": {"added": [], "removed": [], "changed": []},
"files": {"added": [], "removed": [], "changed": []},
}
info = d.enforce_old_harvest(str(old), report=report, target="salt")
assert info["status"] == "applied"
assert info["target"] == "salt"
assert info["tool"] == "salt-call"
assert info["scope"] == "full_manifest"
assert calls["manifest_target"] == "salt"
argv = calls.get("argv")
assert argv and argv[0] == "/usr/bin/salt-call"
assert "--local" in argv
assert "--file-root" in argv
assert "state.apply" in argv
assert str(Path(calls["cwd"]) / "manifest" / "states") in argv
def test_cli_diff_enforce_forwards_target(monkeypatch):
import enroll.cli as cli
report = {
"packages": {"added": [], "removed": ["curl"], "version_changed": []},
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
"users": {"added": [], "removed": [], "changed": []},
"files": {"added": [], "removed": [], "changed": []},
}
monkeypatch.setattr(cli, "compare_harvests", lambda *a, **k: (report, True))
monkeypatch.setattr(cli, "has_enforceable_drift", lambda r: True)
calls: dict[str, object] = {}
def fake_enforce(old, **kwargs):
calls["old"] = old
calls.update(kwargs)
return {"status": "applied", "target": kwargs.get("target"), "returncode": 0}
monkeypatch.setattr(cli, "enforce_old_harvest", fake_enforce)
monkeypatch.setattr(cli, "format_report", lambda report, fmt="text": "R\n")
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"diff",
"--old",
"/tmp/old",
"--new",
"/tmp/new",
"--enforce",
"--target",
"puppet",
],
)
cli.main()
assert calls["old"] == "/tmp/old"
assert calls["target"] == "puppet"
assert calls["report"] is report
def test_cli_diff_forwards_exclude_and_ignore_flags(monkeypatch, capsys):
import enroll.cli as cli

View file

@ -81,3 +81,42 @@ def test_send_email_raises_when_no_delivery_method(monkeypatch):
from_addr="a@example.com",
to_addrs=["b@example.com"],
)
def test_send_email_refuses_smtp_auth_without_starttls(monkeypatch):
from enroll.diff import send_email
class FakeSMTP:
def __init__(self, *_args, **_kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def ehlo(self):
pass
def starttls(self):
raise RuntimeError("no starttls")
def login(self, *_args):
raise AssertionError("login should not be called without TLS")
def send_message(self, *_args):
raise AssertionError("message should not be sent without TLS")
monkeypatch.setattr("smtplib.SMTP", FakeSMTP)
with pytest.raises(RuntimeError, match="STARTTLS failed"):
send_email(
subject="Subj",
body="Body",
from_addr="a@example.com",
to_addrs=["b@example.com"],
smtp="smtp.example.com:587",
smtp_user="user",
smtp_password="secret",
)

View file

@ -23,3 +23,54 @@ def test_stat_triplet_reports_mode(tmp_path: Path):
assert mode == "0600"
assert owner # non-empty string
assert group # non-empty string
def test_open_no_follow_path_reads_regular_file(tmp_path: Path):
from enroll.fsutil import open_no_follow_path
nested = tmp_path / "a" / "b"
nested.mkdir(parents=True)
f = nested / "file.txt"
f.write_text("hello\n", encoding="utf-8")
fd = open_no_follow_path(str(f))
try:
assert os.read(fd, 100) == b"hello\n"
finally:
os.close(fd)
def test_open_no_follow_path_refuses_symlinked_parent(tmp_path: Path):
import errno
from enroll.fsutil import open_no_follow_path
real = tmp_path / "real"
real.mkdir()
(real / "file.txt").write_text("x\n", encoding="utf-8")
(tmp_path / "link").symlink_to(real)
try:
fd = open_no_follow_path(str(tmp_path / "link" / "file.txt"))
os.close(fd)
raise AssertionError("expected OSError for symlinked parent")
except OSError as e:
assert e.errno == errno.ELOOP
def test_open_no_follow_path_refuses_symlinked_leaf(tmp_path: Path):
import errno
from enroll.fsutil import open_no_follow_path
target = tmp_path / "target.txt"
target.write_text("x\n", encoding="utf-8")
link = tmp_path / "link.txt"
link.symlink_to(target)
try:
fd = open_no_follow_path(str(link))
os.close(fd)
raise AssertionError("expected OSError for symlinked leaf")
except OSError as e:
assert e.errno == errno.ELOOP

View file

@ -1,9 +1,35 @@
import json
import os
import pytest
from pathlib import Path
import enroll.harvest as h
import enroll.harvest as harvest
import enroll.system_paths as system_paths
from enroll.platform import PlatformInfo
from enroll.systemd import UnitInfo
from enroll.pathfilter import PathFilter
import enroll.capture as capture
from enroll.capture import (
capture_file as _capture_file,
capture_link as _capture_link,
capture_user_shell_dotfiles,
files_differ,
)
from enroll.harvest_types import ExcludedFile, ManagedFile, ManagedLink
from enroll.ignore import IgnorePolicy
from enroll.package_hints import (
add_pkgs_from_etc_topdirs,
hint_names as _hint_names,
)
from enroll.system_paths import (
is_confish as _is_confish,
iter_matching_files as _iter_matching_files,
parse_apt_signed_by as _parse_apt_signed_by,
topdirs_for_package as _topdirs_for_package,
)
from unittest.mock import MagicMock
class AllowAllPolicy:
@ -154,17 +180,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,
@ -183,7 +209,12 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
owned_etc = {"/etc/openvpn/server.conf"}
etc_owner_map = {"/etc/openvpn/server.conf": "openvpn"}
topdir_to_pkgs = {"openvpn": {"openvpn"}}
pkg_to_etc_paths = {"openvpn": ["/etc/openvpn/server.conf"], "curl": []}
# curl has a package-owned /etc path, but no changed/custom harvested
# artifacts. That should still be considered a simple package role.
pkg_to_etc_paths = {
"openvpn": ["/etc/openvpn/server.conf"],
"curl": ["/etc/curl/curlrc"],
}
backend = FakeBackend(
name="dpkg",
@ -199,11 +230,24 @@ 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: [])
import enroll.accounts as accounts
monkeypatch.setattr(accounts, "find_system_flatpaks", lambda: [])
monkeypatch.setattr(accounts, "find_system_flatpak_remotes", lambda: [])
monkeypatch.setattr(
accounts, "find_user_flatpak_remotes", lambda home, user=None: []
)
monkeypatch.setattr(
accounts,
"find_system_snaps",
lambda: [accounts.SnapInstall(name="code", channel="latest/stable")],
)
def fake_stat_triplet(p: str):
if p == "/usr/local/bin/myscript":
@ -211,7 +255,8 @@ 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)
monkeypatch.setattr(capture, "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 +264,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(capture, "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"]
@ -232,6 +277,9 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
pkg_roles = st["roles"]["packages"]
assert all(pr["package"] != "openvpn" for pr in pkg_roles)
assert any(pr["package"] == "curl" for pr in pkg_roles)
curl_role = next(pr for pr in pkg_roles if pr["package"] == "curl")
assert curl_role["has_config"] is False
assert any("No changed or custom configuration" in n for n in curl_role["notes"])
# Inventory provenance: openvpn should be observed via systemd unit.
openvpn_obs = inv["openvpn"]["observed_via"]
@ -240,6 +288,9 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
for o in openvpn_obs
)
assert st["roles"]["snap"]["role_name"] == "snap"
assert st["roles"]["snap"]["system_snaps"][0]["name"] == "code"
# Service role captured modified conffile
svc = st["roles"]["services"][0]
assert svc["unit"] == "openvpn.service"
@ -274,21 +325,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")]
system_paths,
"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 +370,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 +395,22 @@ 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(capture, "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(capture, "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 +423,720 @@ 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 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 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 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 = _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 = _topdirs_for_package("multi", pkg_to_etc_paths)
assert result == {"nginx", "ssh"}
def test_topdirs_for_package_empty():
result = _topdirs_for_package("empty", {})
assert result == set()
def test_topdirs_for_package_no_etc():
pkg_to_etc_paths = {
"other": ["/usr/share/doc/file"],
}
result = _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 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 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 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 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 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 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 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 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 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 = _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 = _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 = _hint_names("nginx.service", set())
assert "nginx" in result
def test_hint_names_empty():
"""Test _hint_names with empty inputs."""
result = _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()
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()
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 _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 _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 _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 _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 _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 _is_confish(str(file1)) is False
def test_is_confish_nonexistent():
"""Test _is_confish returns False for nonexistent files."""
assert _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
def test_user_shell_dotfiles_are_not_auto_captured_without_dangerous(tmp_path: Path):
home = tmp_path / "home" / "alice"
home.mkdir(parents=True)
(home / ".bashrc").write_text("export DEMO=value\n", encoding="utf-8")
(home / ".bash_aliases").write_text("alias ll='ls -la'\n", encoding="utf-8")
managed: list[ManagedFile] = []
excluded: list[ExcludedFile] = []
captured = capture_user_shell_dotfiles(
bundle_dir=str(tmp_path / "bundle"),
role_name="users",
home=str(home),
skel_dir=str(tmp_path / "skel"),
enabled=False,
policy=IgnorePolicy(dangerous=False),
path_filter=PathFilter(),
managed_out=managed,
excluded_out=excluded,
seen_role=set(),
seen_global=set(),
)
assert captured == 0
assert managed == []
assert excluded == []
assert not (tmp_path / "bundle" / "artifacts" / "users").exists()
def test_user_shell_dotfiles_dangerous_captures_changed_files_only(tmp_path: Path):
skel = tmp_path / "skel"
home = tmp_path / "home" / "alice"
skel.mkdir(parents=True)
home.mkdir(parents=True)
(skel / ".bashrc").write_text("# default bashrc\n", encoding="utf-8")
(home / ".bashrc").write_text("# customised bashrc\n", encoding="utf-8")
(skel / ".profile").write_text("# default profile\n", encoding="utf-8")
(home / ".profile").write_text("# default profile\n", encoding="utf-8")
(home / ".bash_aliases").write_text("alias ll='ls -la'\n", encoding="utf-8")
target = home / "target"
target.write_text("# symlink target\n", encoding="utf-8")
os.symlink(target, home / ".bash_logout")
managed: list[ManagedFile] = []
excluded: list[ExcludedFile] = []
captured = capture_user_shell_dotfiles(
bundle_dir=str(tmp_path / "bundle"),
role_name="users",
home=str(home),
skel_dir=str(skel),
enabled=True,
policy=IgnorePolicy(dangerous=True),
path_filter=PathFilter(),
managed_out=managed,
excluded_out=excluded,
seen_role=set(),
seen_global=set(),
)
captured_paths = {mf.path for mf in managed}
assert captured == 2
assert str(home / ".bashrc") in captured_paths
assert str(home / ".bash_aliases") in captured_paths
assert str(home / ".profile") not in captured_paths
assert str(home / ".bash_logout") not in captured_paths
assert excluded == []
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View file

@ -0,0 +1,446 @@
from __future__ import annotations
from pathlib import Path
from enroll.harvest_collectors.context import HarvestContext
from enroll.harvest_collectors.paths import ExtraPathsCollector, UsrLocalCustomCollector
from enroll.harvest_collectors.runtime import RuntimeStateCollector
from enroll.harvest_types import FirewallRuntimeSnapshot, ManagedFile, SysctlSnapshot
from enroll.ignore import IgnorePolicy
from enroll.pathfilter import PathFilter
class _Backend:
name = "dpkg"
def _context(tmp_path: Path, *, include=(), exclude=(), policy=None) -> HarvestContext:
return HarvestContext(
bundle_dir=str(tmp_path / "bundle"),
policy=policy or IgnorePolicy(),
path_filter=PathFilter(include=include, exclude=exclude),
platform={},
backend=_Backend(),
installed_pkgs={},
installed_names=set(),
owned_etc=set(),
etc_owner_map={},
topdir_to_pkgs={},
pkg_to_etc_paths={},
captured_global=set(),
)
def test_runtime_state_collector_preserves_non_root_skip_schema(monkeypatch, tmp_path):
monkeypatch.setattr("enroll.harvest.os.geteuid", lambda: 1000)
result = RuntimeStateCollector(_context(tmp_path)).collect()
assert isinstance(result.firewall_runtime_snapshot, FirewallRuntimeSnapshot)
assert isinstance(result.sysctl_snapshot, SysctlSnapshot)
assert result.firewall_runtime_snapshot.role_name == "firewall_runtime"
assert result.sysctl_snapshot.role_name == "sysctl"
assert "not running as root" in result.firewall_runtime_snapshot.notes[0]
assert "not running as root" in result.sysctl_snapshot.notes[0]
def test_container_images_collector_records_digest_pinned_docker_images(
monkeypatch, tmp_path
):
import json
import subprocess
from enroll.harvest_collectors import container_images as ci
from enroll.harvest_collectors.container_images import ContainerImagesCollector
def fake_which(cmd):
return f"/usr/bin/{cmd}" if cmd == "docker" else None
def fake_run(argv, check=False, stdout=None, stderr=None, text=False, timeout=None):
if argv[:4] == ["/usr/bin/docker", "image", "ls", "-q"]:
return subprocess.CompletedProcess(argv, 0, "sha256:" + "a" * 64 + "\n", "")
if argv[:3] == ["/usr/bin/docker", "image", "inspect"]:
return subprocess.CompletedProcess(
argv,
0,
json.dumps(
[
{
"Id": "sha256:" + "a" * 64,
"RepoTags": ["docker.io/library/nginx:1.27"],
"RepoDigests": [
"docker.io/library/nginx@sha256:" + "b" * 64
],
"Os": "linux",
"Architecture": "amd64",
"Size": 123,
"Created": "2026-01-01T00:00:00Z",
}
]
),
"",
)
raise AssertionError(argv)
monkeypatch.setattr(ci.shutil, "which", fake_which)
monkeypatch.setattr(ci.subprocess, "run", fake_run)
result = ContainerImagesCollector(_context(tmp_path)).collect()
assert result.role_name == "container_images"
assert len(result.images) == 1
image = result.images[0]
assert image["engine"] == "docker"
assert image["pull_ref"] == "docker.io/library/nginx@sha256:" + "b" * 64
assert image["platform"] == "linux/amd64"
assert image["tag_aliases"] == [
{
"ref": "docker.io/library/nginx:1.27",
"repository": "docker.io/library/nginx",
"tag": "1.27",
}
]
def test_container_images_collector_records_unpullable_tagged_images(
monkeypatch, tmp_path
):
import json
import subprocess
from enroll.harvest_collectors import container_images as ci
from enroll.harvest_collectors.container_images import ContainerImagesCollector
def fake_which(cmd):
return "/usr/bin/podman" if cmd == "podman" else None
monkeypatch.setattr(ci.shutil, "which", fake_which)
def fake_run(argv, check=False, stdout=None, stderr=None, text=False, timeout=None):
if argv[:4] == ["/usr/bin/podman", "image", "ls", "-q"]:
return subprocess.CompletedProcess(argv, 0, "c" * 64 + "\n", "")
if argv[:3] == ["/usr/bin/podman", "image", "inspect"]:
return subprocess.CompletedProcess(
argv,
0,
json.dumps(
[
{
"Id": "c" * 64,
"RepoTags": ["localhost/demo:latest"],
"RepoDigests": [],
"Os": "linux",
"Architecture": "amd64",
}
]
),
"",
)
raise AssertionError(argv)
monkeypatch.setattr(ci.subprocess, "run", fake_run)
result = ContainerImagesCollector(_context(tmp_path)).collect()
assert result.images[0]["pull_ref"] is None
assert "exact digest-pinned pull cannot be rendered" in result.images[0]["notes"][0]
def test_container_images_collector_notes_list_exceptions(monkeypatch, tmp_path):
from enroll.harvest_collectors import container_images as ci
from enroll.harvest_collectors.container_images import ContainerImagesCollector
monkeypatch.setattr(
ci.shutil,
"which",
lambda cmd: f"/usr/bin/{cmd}" if cmd == "docker" else None,
)
def boom(_argv, *, timeout=20):
raise RuntimeError("socket unavailable")
monkeypatch.setattr(ci, "_run_command", boom)
result = ContainerImagesCollector(_context(tmp_path)).collect()
assert result.images == []
assert "Failed to list docker images" in result.notes[0]
def test_container_images_collector_notes_list_nonzero_without_detail(
monkeypatch, tmp_path
):
import subprocess
from enroll.harvest_collectors import container_images as ci
from enroll.harvest_collectors.container_images import ContainerImagesCollector
monkeypatch.setattr(
ci.shutil,
"which",
lambda cmd: f"/usr/bin/{cmd}" if cmd == "podman" else None,
)
monkeypatch.setattr(
ci,
"_run_command",
lambda argv, *, timeout=20: subprocess.CompletedProcess(argv, 42, "", ""),
)
result = ContainerImagesCollector(_context(tmp_path)).collect()
assert result.images == []
assert "exit 42" in result.notes[0]
def test_container_images_collector_notes_bad_inspect_json(monkeypatch, tmp_path):
import subprocess
from enroll.harvest_collectors import container_images as ci
from enroll.harvest_collectors.container_images import ContainerImagesCollector
image_id = "sha256:" + "d" * 64
monkeypatch.setattr(
ci.shutil,
"which",
lambda cmd: f"/usr/bin/{cmd}" if cmd == "docker" else None,
)
def fake_run(argv, *, timeout=20):
if argv[:4] == ["/usr/bin/docker", "image", "ls", "-q"]:
return subprocess.CompletedProcess(argv, 0, image_id + "\n", "")
if argv[:3] == ["/usr/bin/docker", "image", "inspect"]:
return subprocess.CompletedProcess(argv, 0, "not json", "")
raise AssertionError(argv)
monkeypatch.setattr(ci, "_run_command", fake_run)
result = ContainerImagesCollector(_context(tmp_path)).collect()
assert result.images == []
assert "Failed to parse docker image inspect JSON" in result.notes[0]
def test_container_images_collector_notes_unexpected_inspect_shape(
monkeypatch, tmp_path
):
import subprocess
from enroll.harvest_collectors import container_images as ci
from enroll.harvest_collectors.container_images import ContainerImagesCollector
image_id = "sha256:" + "e" * 64
monkeypatch.setattr(
ci.shutil,
"which",
lambda cmd: f"/usr/bin/{cmd}" if cmd == "docker" else None,
)
def fake_run(argv, *, timeout=20):
if argv[:4] == ["/usr/bin/docker", "image", "ls", "-q"]:
return subprocess.CompletedProcess(argv, 0, image_id + "\n", "")
if argv[:3] == ["/usr/bin/docker", "image", "inspect"]:
return subprocess.CompletedProcess(argv, 0, '{"not":"a-list"}', "")
raise AssertionError(argv)
monkeypatch.setattr(ci, "_run_command", fake_run)
result = ContainerImagesCollector(_context(tmp_path)).collect()
assert result.images == []
assert "Unexpected docker image inspect JSON shape" in result.notes[0]
def test_extra_paths_collector_records_dirs_files_notes_and_excludes(
monkeypatch, tmp_path
):
from enroll.harvest_collectors import paths
root = tmp_path / "include"
sub = root / "sub"
skip = root / "skip"
sub.mkdir(parents=True)
skip.mkdir()
keep_file = sub / "keep.conf"
keep_file.write_text("ok", encoding="utf-8")
skip_file = skip / "skip.conf"
skip_file.write_text("no", encoding="utf-8")
class Policy(IgnorePolicy):
def deny_reason_dir(self, path: str):
return "denied_dir" if path == str(sub) else None
def fake_stat_triplet(path: str):
return ("root", "root", "0755")
def fake_capture_file(**kwargs):
kwargs["managed_out"].append(
ManagedFile(
path=kwargs["abs_path"],
src_rel=kwargs["abs_path"].lstrip("/"),
owner="root",
group="root",
mode="0644",
reason=kwargs["reason"],
)
)
return True
monkeypatch.setattr(paths.h, "stat_triplet", fake_stat_triplet)
monkeypatch.setattr(paths, "capture_file", lambda *a, **kw: fake_capture_file(**kw))
ctx = _context(
tmp_path,
include=[str(root)],
exclude=[str(skip)],
policy=Policy(),
)
result = ExtraPathsCollector(
ctx,
seen_by_role={},
already_all=set(),
include_paths=[str(root)],
exclude_paths=[str(skip)],
).collect()
managed_dirs = {d.path for d in result.managed_dirs}
assert str(root) in managed_dirs
assert str(sub) not in managed_dirs # denied by policy
assert str(skip) not in managed_dirs # pruned by exclude filter
assert [m.path for m in result.managed_files] == [str(keep_file)]
assert "User include patterns:" in result.notes
assert f"- {root}" in result.notes
assert f"- {skip}" in result.notes
def test_extra_paths_collector_skips_already_captured_files(monkeypatch, tmp_path):
from enroll.harvest_collectors import paths
root = tmp_path / "include"
root.mkdir()
file_path = root / "keep.conf"
file_path.write_text("ok", encoding="utf-8")
calls: list[str] = []
monkeypatch.setattr(paths.h, "stat_triplet", lambda p: ("root", "root", "0755"))
monkeypatch.setattr(
paths, "capture_file", lambda *a, **kw: calls.append(kw["abs_path"]) or True
)
ctx = _context(tmp_path, include=[str(root)])
result = ExtraPathsCollector(
ctx,
seen_by_role={},
already_all={str(file_path)},
include_paths=[str(root)],
).collect()
assert result.managed_files == []
assert calls == []
def test_usr_local_custom_collector_scans_executable_bin_and_notes_cap(
monkeypatch, tmp_path
):
from enroll.harvest_collectors import paths
captured: list[str] = []
def fake_isdir(path: str) -> bool:
return path in {"/usr/local/etc", "/usr/local/bin"}
def fake_walk(root: str):
if root == "/usr/local/etc":
yield root, [], ["app.conf"]
elif root == "/usr/local/bin":
yield root, [], ["tool", "not-exec"]
def fake_isfile(path: str) -> bool:
return path in {
"/usr/local/etc/app.conf",
"/usr/local/bin/tool",
"/usr/local/bin/not-exec",
}
def fake_stat_triplet(path: str):
mode = "0755" if path == "/usr/local/bin/tool" else "0644"
return ("root", "root", mode)
def fake_capture_file(**kwargs):
captured.append(kwargs["abs_path"])
kwargs["managed_out"].append(
ManagedFile(
path=kwargs["abs_path"],
src_rel=kwargs["abs_path"].lstrip("/"),
owner="root",
group="root",
mode="0644",
reason=kwargs["reason"],
)
)
return True
monkeypatch.setattr(paths.os.path, "isdir", fake_isdir)
monkeypatch.setattr(paths.os, "walk", fake_walk)
monkeypatch.setattr(paths.os.path, "isfile", fake_isfile)
monkeypatch.setattr(paths.os.path, "islink", lambda p: False)
monkeypatch.setattr(paths.h, "stat_triplet", fake_stat_triplet)
monkeypatch.setattr(paths, "capture_file", lambda *a, **kw: fake_capture_file(**kw))
ctx = _context(tmp_path)
result = UsrLocalCustomCollector(ctx, seen_by_role={}, already_all=set()).collect()
assert captured == ["/usr/local/etc/app.conf", "/usr/local/bin/tool"]
assert [m.reason for m in result.managed_files] == [
"usr_local_etc_custom",
"usr_local_bin_script",
]
def test_extra_paths_collector_records_symlinks_without_following(tmp_path):
root = tmp_path / "include"
root.mkdir()
real_file = root / "real.conf"
real_file.write_text("ok", encoding="utf-8")
(root / "link.conf").symlink_to("real.conf")
outside = tmp_path / "outside"
outside.mkdir()
(outside / "outside.conf").write_text("do-not-follow", encoding="utf-8")
(root / "shared").symlink_to(outside, target_is_directory=True)
ctx = _context(tmp_path, include=[str(root)])
result = ExtraPathsCollector(
ctx,
seen_by_role={},
already_all=set(),
include_paths=[str(root)],
).collect()
links = {(link.path, link.target, link.reason) for link in result.managed_links}
assert (str(root / "link.conf"), "real.conf", "user_include_link") in links
assert (str(root / "shared"), str(outside), "user_include_link") in links
managed_files = {mf.path for mf in result.managed_files}
assert str(real_file) in managed_files
assert str(outside / "outside.conf") not in managed_files
def test_extra_paths_collector_records_include_path_that_is_symlink(tmp_path):
real_root = tmp_path / "real"
real_root.mkdir()
(real_root / "inside.conf").write_text("do-not-follow", encoding="utf-8")
link_root = tmp_path / "linked-root"
link_root.symlink_to(real_root, target_is_directory=True)
ctx = _context(tmp_path, include=[str(link_root)])
result = ExtraPathsCollector(
ctx,
seen_by_role={},
already_all=set(),
include_paths=[str(link_root)],
).collect()
assert [(link.path, link.target, link.reason) for link in result.managed_links] == [
(str(link_root), str(real_root), "user_include_link")
]
assert result.managed_files == []

View file

@ -0,0 +1,84 @@
from __future__ import annotations
from pathlib import Path
from enroll.harvest_collectors.context import HarvestContext
from enroll.harvest_collectors.package_manager import PackageManagerConfigCollector
from enroll.harvest_types import ManagedFile
from enroll.ignore import IgnorePolicy
from enroll.pathfilter import PathFilter
class _Backend:
def __init__(self, name: str):
self.name = name
def _context(tmp_path: Path, backend_name: str) -> HarvestContext:
return HarvestContext(
bundle_dir=str(tmp_path / "bundle"),
policy=IgnorePolicy(),
path_filter=PathFilter(include=(), exclude=()),
platform={},
backend=_Backend(backend_name),
installed_pkgs={},
installed_names=set(),
owned_etc=set(),
etc_owner_map={},
topdir_to_pkgs={},
pkg_to_etc_paths={},
captured_global=set(),
)
def _fake_capture(**kwargs):
kwargs["managed_out"].append(
ManagedFile(
path=kwargs["abs_path"],
src_rel=kwargs["abs_path"].lstrip("/"),
owner="root",
group="root",
mode="0644",
reason=kwargs["reason"],
)
)
return True
def test_package_manager_config_collector_captures_apt_branch(monkeypatch, tmp_path):
from enroll.harvest_collectors import package_manager as pm
monkeypatch.setattr(
pm, "iter_apt_capture_paths", lambda: [("/etc/apt/a.conf", "apt")]
)
monkeypatch.setattr(pm, "capture_file", lambda *a, **kw: _fake_capture(**kw))
result = PackageManagerConfigCollector(_context(tmp_path, "dpkg"), {}).collect()
assert [m.path for m in result.apt_config_snapshot.managed_files] == [
"/etc/apt/a.conf"
]
assert result.dnf_config_snapshot.managed_files == []
def test_package_manager_config_collector_captures_dnf_branch(monkeypatch, tmp_path):
from enroll.harvest_collectors import package_manager as pm
monkeypatch.setattr(
pm, "iter_dnf_capture_paths", lambda: [("/etc/dnf/d.conf", "dnf")]
)
monkeypatch.setattr(pm, "capture_file", lambda *a, **kw: _fake_capture(**kw))
result = PackageManagerConfigCollector(_context(tmp_path, "rpm"), {}).collect()
assert result.apt_config_snapshot.managed_files == []
assert [m.path for m in result.dnf_config_snapshot.managed_files] == [
"/etc/dnf/d.conf"
]
def test_package_manager_config_collector_unknown_backend_returns_empty(tmp_path):
result = PackageManagerConfigCollector(_context(tmp_path, "apk"), {}).collect()
assert result.apt_config_snapshot.managed_files == []
assert result.dnf_config_snapshot.managed_files == []

View file

@ -4,6 +4,8 @@ import json
from pathlib import Path
import enroll.harvest as h
import enroll.capture as capture
import enroll.harvest_collectors.cron_logrotate as cron_logrotate
from enroll.platform import PlatformInfo
from enroll.systemd import UnitInfo
@ -89,7 +91,7 @@ def test_harvest_unifies_cron_and_logrotate_into_dedicated_package_roles(
}
return list(mapping.get(spec, []))[:cap]
monkeypatch.setattr(h, "_iter_matching_files", fake_iter_matching)
monkeypatch.setattr(cron_logrotate, "iter_matching_files", fake_iter_matching)
# Avoid real system probing.
monkeypatch.setattr(
@ -128,7 +130,7 @@ def test_harvest_unifies_cron_and_logrotate_into_dedicated_package_roles(
)
monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
monkeypatch.setattr(
h,
capture,
"stat_triplet",
lambda p: ("alice" if "alice" in p else "root", "root", "0644"),
)
@ -139,7 +141,7 @@ def test_harvest_unifies_cron_and_logrotate_into_dedicated_package_roles(
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(capture, "copy_into_bundle", fake_copy)
state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
st = json.loads(Path(state_path).read_text(encoding="utf-8"))

View file

@ -4,6 +4,8 @@ import os
from pathlib import Path
import enroll.harvest as h
import enroll.system_paths as sp
from enroll.package_hints import role_name_from_pkg, role_name_from_unit
def test_iter_matching_files_skips_symlinks_and_walks_dirs(monkeypatch, tmp_path: Path):
@ -24,12 +26,12 @@ def test_iter_matching_files_skips_symlinks_and_walks_dirs(monkeypatch, tmp_path
str(root / "link"): "link",
}
monkeypatch.setattr(h.glob, "glob", lambda spec: [str(root), str(root / "link")])
monkeypatch.setattr(h.os.path, "islink", lambda p: paths.get(p) == "link")
monkeypatch.setattr(h.os.path, "isfile", lambda p: paths.get(p) == "file")
monkeypatch.setattr(h.os.path, "isdir", lambda p: paths.get(p) == "dir")
monkeypatch.setattr(sp.glob, "glob", lambda spec: [str(root), str(root / "link")])
monkeypatch.setattr(sp.os.path, "islink", lambda p: paths.get(p) == "link")
monkeypatch.setattr(sp.os.path, "isfile", lambda p: paths.get(p) == "file")
monkeypatch.setattr(sp.os.path, "isdir", lambda p: paths.get(p) == "dir")
monkeypatch.setattr(
h.os,
sp.os,
"walk",
lambda p: [
(str(root), ["sub"], ["real.txt", "link"]),
@ -37,7 +39,7 @@ def test_iter_matching_files_skips_symlinks_and_walks_dirs(monkeypatch, tmp_path
],
)
out = h._iter_matching_files("/whatever/*", cap=100)
out = sp.iter_matching_files("/whatever/*", cap=100)
assert str(root / "real.txt") in out
assert str(root / "sub" / "nested.txt") in out
assert str(root / "link") not in out
@ -57,7 +59,7 @@ def test_parse_apt_signed_by_extracts_keyrings(tmp_path: Path):
f3 = tmp_path / "c.sources"
f3.write_text("Signed-By: | /bin/echo nope\n", encoding="utf-8")
out = h._parse_apt_signed_by([str(f1), str(f2), str(f3)])
out = sp.parse_apt_signed_by([str(f1), str(f2), str(f3)])
assert "/usr/share/keyrings/foo.gpg" in out
assert "/etc/apt/keyrings/bar.gpg" in out
assert "/usr/share/keyrings/baz.gpg" in out
@ -74,9 +76,9 @@ def test_iter_apt_capture_paths_includes_signed_by_keyring(monkeypatch):
"/usr/share/keyrings/ext.gpg": "file",
}
monkeypatch.setattr(h.os.path, "isdir", lambda p: p in {"/etc/apt"})
monkeypatch.setattr(sp.os.path, "isdir", lambda p: p in {"/etc/apt"})
monkeypatch.setattr(
h.os,
sp.os,
"walk",
lambda root: [
("/etc/apt", ["apt.conf.d", "sources.list.d"], []),
@ -84,8 +86,8 @@ def test_iter_apt_capture_paths_includes_signed_by_keyring(monkeypatch):
("/etc/apt/sources.list.d", [], ["test.list"]),
],
)
monkeypatch.setattr(h.os.path, "islink", lambda p: False)
monkeypatch.setattr(h.os.path, "isfile", lambda p: files.get(p) == "file")
monkeypatch.setattr(sp.os.path, "islink", lambda p: False)
monkeypatch.setattr(sp.os.path, "isfile", lambda p: files.get(p) == "file")
# Only treat the sources glob as having a hit.
def fake_iter_matching(spec: str, cap: int = 10000):
@ -93,7 +95,7 @@ def test_iter_apt_capture_paths_includes_signed_by_keyring(monkeypatch):
return ["/etc/apt/sources.list.d/test.list"]
return []
monkeypatch.setattr(h, "_iter_matching_files", fake_iter_matching)
monkeypatch.setattr(sp, "iter_matching_files", fake_iter_matching)
# Provide file contents for the sources file.
real_open = open
@ -105,10 +107,10 @@ def test_iter_apt_capture_paths_includes_signed_by_keyring(monkeypatch):
# Easier: patch _parse_apt_signed_by directly to avoid filesystem reads.
monkeypatch.setattr(
h, "_parse_apt_signed_by", lambda sfs: {"/usr/share/keyrings/ext.gpg"}
sp, "parse_apt_signed_by", lambda sfs: {"/usr/share/keyrings/ext.gpg"}
)
out = h._iter_apt_capture_paths()
out = sp.iter_apt_capture_paths()
paths = {p for p, _r in out}
reasons = {p: r for p, r in out}
assert "/etc/apt/apt.conf.d/00test" in paths
@ -138,19 +140,23 @@ def test_iter_dnf_capture_paths(monkeypatch):
return [("/etc/pki/rpm-gpg", [], ["RPM-GPG-KEY"])]
return []
monkeypatch.setattr(h.os.path, "isdir", isdir)
monkeypatch.setattr(h.os, "walk", walk)
monkeypatch.setattr(h.os.path, "islink", lambda p: False)
monkeypatch.setattr(h.os.path, "isfile", lambda p: files.get(p) == "file")
monkeypatch.setattr(
h,
"_iter_matching_files",
lambda spec, cap=10000: (
["/etc/yum.repos.d/test.repo"] if spec.endswith("*.repo") else []
),
)
monkeypatch.setattr(sp.os.path, "isdir", isdir)
monkeypatch.setattr(sp.os, "walk", walk)
monkeypatch.setattr(sp.os.path, "islink", lambda p: False)
monkeypatch.setattr(sp.os.path, "isfile", lambda p: files.get(p) == "file")
out = h._iter_dnf_capture_paths()
def fake_iter_matching(spec: str, cap: int = 10000):
if spec == "/etc/yum.conf":
return ["/etc/yum.conf"]
if spec.endswith("*.repo"):
return ["/etc/yum.repos.d/test.repo"]
if spec == "/etc/pki/rpm-gpg/*":
return ["/etc/pki/rpm-gpg/RPM-GPG-KEY"]
return []
monkeypatch.setattr(sp, "iter_matching_files", fake_iter_matching)
out = sp.iter_dnf_capture_paths()
paths = {p for p, _r in out}
assert "/etc/dnf/dnf.conf" in paths
assert "/etc/yum/yum.conf" in paths
@ -160,13 +166,13 @@ def test_iter_dnf_capture_paths(monkeypatch):
def test_iter_system_capture_paths_dedupes_first_reason(monkeypatch):
monkeypatch.setattr(h, "_SYSTEM_CAPTURE_GLOBS", [("/a", "r1"), ("/b", "r2")])
monkeypatch.setattr(sp, "_SYSTEM_CAPTURE_GLOBS", [("/a", "r1"), ("/b", "r2")])
monkeypatch.setattr(
h,
"_iter_matching_files",
sp,
"iter_matching_files",
lambda spec, cap=10000: ["/dup"] if spec in {"/a", "/b"} else [],
)
out = h._iter_system_capture_paths()
out = sp.iter_system_capture_paths()
assert out == [("/dup", "r1")]
@ -286,3 +292,107 @@ def test_collect_firewall_runtime_snapshot_is_per_family_fallback(
assert (
tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v6"
).exists()
def test_package_role_names_do_not_collide_with_singleton_roles():
assert role_name_from_pkg("flatpak") == "package_flatpak"
assert role_name_from_pkg("snap") == "package_snap"
assert role_name_from_pkg("users") == "package_users"
assert role_name_from_pkg("nginx") == "nginx"
def test_service_role_names_do_not_collide_with_singleton_roles():
assert role_name_from_unit("flatpak.service") == "service_flatpak"
assert role_name_from_unit("users.service") == "service_users"
assert role_name_from_unit("nginx.service") == "nginx"
def test_parse_sysctl_a_output_keeps_persistable_values(monkeypatch):
monkeypatch.setattr(
h,
"_sysctl_key_is_persistable",
lambda key: (key != "kernel.hostname", "test"),
)
params, skipped = h._parse_sysctl_a_output(
"net.ipv4.ip_forward = 1\n"
"kernel.hostname = example\n"
"malformed line\n"
"dev.cdrom.info = \n"
"net.ipv4.ip_forward = 0\n"
)
assert params == {"net.ipv4.ip_forward": "1"}
assert skipped["non_persistable"] == 1
assert skipped["malformed"] == 1
assert skipped["empty_value"] == 1
assert skipped["duplicate"] == 1
def test_sysctl_filter_skips_non_replayable_runtime_keys(monkeypatch):
for key in (
"fs.binfmt_misc.status",
"fs.binfmt_misc.register",
"kernel.kexec_load_disabled",
"kernel.kexec_load_limit_panic",
"kernel.kexec_load_limit_reboot",
"kernel.max_rcu_stall_to_panic",
"kernel.modules_disabled",
"kernel.sched_domain.cpu0.domain0.flags",
):
ok, reason = h._sysctl_key_is_persistable(key)
assert ok is False
assert reason == "volatile/action key"
monkeypatch.setattr(h, "_sysctl_key_is_persistable", lambda key: (True, ""))
for key in (
"vm.dirty_background_bytes",
"vm.dirty_background_ratio",
"vm.dirty_bytes",
"vm.dirty_ratio",
):
ok, reason = h._sysctl_entry_is_persistable(key, "0")
assert ok is False
assert reason == "inactive mutually-exclusive zero value"
assert h._sysctl_entry_is_persistable(key, "10")[0] is True
def test_parse_sysctl_a_output_skips_non_replayable_values(monkeypatch):
monkeypatch.setattr(
h,
"_sysctl_key_is_persistable",
lambda key: (key != "kernel.modules_disabled", "volatile/action key"),
)
params, skipped = h._parse_sysctl_a_output(
"kernel.modules_disabled = 0\n"
"vm.dirty_background_bytes = 0\n"
"vm.dirty_ratio = 20\n"
"net.ipv4.ip_forward = 1\n"
)
assert params == {"net.ipv4.ip_forward": "1", "vm.dirty_ratio": "20"}
assert skipped["non_persistable"] == 2
def test_collect_sysctl_snapshot_writes_generated_artifact(monkeypatch, tmp_path: Path):
monkeypatch.setattr(
h,
"_run_capture_command",
lambda command_key, *, timeout=10: (
"net.ipv4.ip_forward = 1\nvm.swappiness = 10\n",
None,
),
)
monkeypatch.setattr(h, "_sysctl_key_is_persistable", lambda key: (True, ""))
snap = h._collect_sysctl_snapshot(str(tmp_path))
assert snap.role_name == "sysctl"
assert snap.parameters == {"net.ipv4.ip_forward": "1", "vm.swappiness": "10"}
assert len(snap.managed_files) == 1
assert snap.managed_files[0].path == "/etc/sysctl.d/99-enroll.conf"
conf = tmp_path / "artifacts" / "sysctl" / "sysctl" / "99-enroll.conf"
text = conf.read_text(encoding="utf-8")
assert "net.ipv4.ip_forward = 1" in text
assert "vm.swappiness = 10" in text

View file

@ -0,0 +1,322 @@
from __future__ import annotations
import os
import stat
from pathlib import Path
import pytest
from enroll.capture import capture_file
from enroll.harvest import harvest
from enroll.harvest_types import ExcludedFile, ManagedFile
from enroll.ignore import FileInspection, IgnorePolicy
from enroll.manifest_safety import prepare_manifest_output_dir
from enroll.harvest_safety import OutputSafetyError, prepare_new_private_dir
from enroll.pathfilter import PathFilter
import enroll.harvest_safety as hs
class _RacePolicy(IgnorePolicy):
def inspect_file(self, path: str):
fd = os.open(path, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0))
try:
st = os.fstat(fd)
data = os.read(fd, st.st_size)
finally:
os.close(fd)
Path(path).write_bytes(b"changed-after-inspection")
return None, FileInspection(data=data, stat_result=st)
def test_prepare_new_private_dir_refuses_existing_path(tmp_path: Path):
out = tmp_path / "bundle"
out.mkdir()
with pytest.raises(OutputSafetyError, match="already exists"):
prepare_new_private_dir(out, label="harvest output")
def test_prepare_new_private_dir_creates_0700(tmp_path: Path):
out = prepare_new_private_dir(tmp_path / "bundle", label="harvest output")
assert out.exists()
assert (out.stat().st_mode & 0o777) == 0o700
def test_harvest_refuses_existing_plaintext_output_dir(tmp_path: Path):
out = tmp_path / "bundle"
out.mkdir()
with pytest.raises(OutputSafetyError, match="already exists"):
harvest(str(out))
def test_manifest_output_dir_is_private_by_default(tmp_path: Path):
out = prepare_manifest_output_dir(tmp_path / "manifest")
assert (out.stat().st_mode & 0o777) == 0o700
def test_capture_file_writes_inspected_bytes_not_later_source(tmp_path: Path):
source = tmp_path / "source.conf"
source.write_bytes(b"safe-original")
bundle = tmp_path / "bundle"
bundle.mkdir()
managed: list[ManagedFile] = []
excluded: list[ExcludedFile] = []
ok = capture_file(
bundle_dir=str(bundle),
role_name="role",
abs_path=str(source),
reason="test",
policy=_RacePolicy(),
path_filter=PathFilter(),
managed_out=managed,
excluded_out=excluded,
)
assert ok is True
artifact = bundle / "artifacts" / "role" / str(source).lstrip("/")
assert artifact.read_bytes() == b"safe-original"
assert source.read_bytes() == b"changed-after-inspection"
def test_capture_file_rejects_symlink_source_with_ignore_policy(tmp_path: Path):
target = tmp_path / "target.conf"
target.write_text("safe=true\n", encoding="utf-8")
link = tmp_path / "link.conf"
link.symlink_to(target)
bundle = tmp_path / "bundle"
bundle.mkdir()
managed: list[ManagedFile] = []
excluded: list[ExcludedFile] = []
ok = capture_file(
bundle_dir=str(bundle),
role_name="role",
abs_path=str(link),
reason="test",
policy=IgnorePolicy(),
path_filter=PathFilter(),
managed_out=managed,
excluded_out=excluded,
)
assert ok is False
assert managed == []
# Symlinked sources are now reported with the dedicated symlink_component
# reason (covers both symlinked leaves and symlinked parent directories),
# which is more precise than the old generic not_regular_file.
assert excluded and excluded[0].reason == "symlink_component"
def test_capture_file_rejects_symlinked_parent_with_ignore_policy(tmp_path: Path):
"""O_NOFOLLOW only guards the final component. A regular file reached
through a symlinked *parent* directory must still be refused, otherwise a
file whose real location is deny-globbed could be captured while its
logical (recorded) path looks safe.
"""
secret = tmp_path / "secretroot"
secret.mkdir()
(secret / "config").write_text("listen_port=8080\n", encoding="utf-8")
(tmp_path / "allowed").symlink_to(secret, target_is_directory=True)
bundle = tmp_path / "bundle"
bundle.mkdir()
managed: list[ManagedFile] = []
excluded: list[ExcludedFile] = []
ok = capture_file(
bundle_dir=str(bundle),
role_name="role",
abs_path=str(tmp_path / "allowed" / "config"),
reason="test",
policy=IgnorePolicy(),
path_filter=PathFilter(),
managed_out=managed,
excluded_out=excluded,
)
assert ok is False
assert managed == []
assert excluded and excluded[0].reason == "symlink_component"
# Nothing should have been written into the bundle.
artifact = bundle / "artifacts" / "role" / "allowed" / "config"
assert not artifact.exists()
def test_prepare_new_private_dir_rejects_symlink_parent(tmp_path: Path):
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
with pytest.raises(OutputSafetyError, match="parent path contains a symlink"):
prepare_new_private_dir(link / "bundle", label="harvest output")
def test_manifest_output_dir_rejects_symlink_parent(tmp_path: Path):
from enroll.manifest_safety import ManifestOutputError
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
with pytest.raises(ManifestOutputError, match="parent path contains a symlink"):
prepare_manifest_output_dir(link / "manifest")
def test_prepare_new_private_dir_rejects_untrusted_root_parent(
tmp_path: Path, monkeypatch
):
import enroll.harvest_safety as hs
untrusted = tmp_path / "untrusted"
untrusted.mkdir()
if hasattr(os, "geteuid") and os.geteuid() == 0:
try:
os.chown(untrusted, 65534, -1)
except OSError:
pass
monkeypatch.setattr(hs, "_effective_uid", lambda: 0)
with pytest.raises(OutputSafetyError, match="not owned by root"):
prepare_new_private_dir(untrusted / "bundle", label="harvest output")
def test_prepare_new_private_dir_uses_real_euid_despite_os_geteuid_monkeypatch(
tmp_path: Path, monkeypatch
):
import enroll.harvest_safety as hs
monkeypatch.setattr(hs.os, "geteuid", lambda: 0)
out = prepare_new_private_dir(tmp_path / "bundle", label="harvest output")
assert out.is_dir()
assert (out.stat().st_mode & 0o777) == 0o700
def test_write_text_output_file_replaces_final_symlink_not_target(tmp_path: Path):
from enroll.harvest_safety import write_text_output_file
target = tmp_path / "target.txt"
target.write_text("old\n", encoding="utf-8")
link = tmp_path / "report.txt"
link.symlink_to(target)
write_text_output_file(link, "new\n", label="test report")
assert not link.is_symlink()
assert link.read_text(encoding="utf-8") == "new\n"
assert target.read_text(encoding="utf-8") == "old\n"
def test_safe_output_parent_does_not_descend_into_raced_symlink(
tmp_path: Path, monkeypatch
):
import enroll.harvest_safety as hs
target = tmp_path / "target"
target.mkdir()
link = tmp_path / "link"
real_mkdir = os.mkdir
def racing_mkdir(path, mode=0o777, *, dir_fd=None):
if Path(path) == link and not link.exists():
link.symlink_to(target, target_is_directory=True)
if dir_fd is not None:
return real_mkdir(path, mode, dir_fd=dir_fd)
return real_mkdir(path, mode)
monkeypatch.setattr(hs.os, "mkdir", racing_mkdir)
with pytest.raises(OutputSafetyError, match="parent path contains a symlink"):
hs.ensure_safe_output_parent(link / "subdir" / "report.txt", label="report")
assert not (target / "subdir").exists()
def _stat_result(mode: int, *, uid: int = 0) -> os.stat_result:
return os.stat_result((mode, 1, 1, 1, uid, 0, 0, 0, 0, 0))
def test_effective_uid_handles_missing_geteuid(monkeypatch):
monkeypatch.setattr(hs, "_OS_GETEUID", None)
assert hs._effective_uid() is None
def test_effective_uid_handles_geteuid_error(monkeypatch):
def boom():
raise OSError("no euid")
monkeypatch.setattr(hs, "_OS_GETEUID", boom)
assert hs._effective_uid() is None
def test_trusted_root_parent_skips_checks_when_not_root(monkeypatch):
monkeypatch.setattr(hs, "_effective_uid", lambda: 1000)
hs._assert_trusted_root_parent(
Path("not-a-dir"), _stat_result(stat.S_IFREG | 0o644, uid=1234), label="x"
)
def test_trusted_root_parent_rejects_non_directory(monkeypatch):
monkeypatch.setattr(hs, "_effective_uid", lambda: 0)
with pytest.raises(OutputSafetyError, match="parent is not a directory"):
hs._assert_trusted_root_parent(
Path("file"), _stat_result(stat.S_IFREG | 0o644), label="x"
)
def test_trusted_root_parent_rejects_group_or_world_writable(monkeypatch):
monkeypatch.setattr(hs, "_effective_uid", lambda: 0)
with pytest.raises(OutputSafetyError, match="writable by group/other"):
hs._assert_trusted_root_parent(
Path("open-dir"), _stat_result(stat.S_IFDIR | 0o777), label="x"
)
def test_trusted_root_parent_allows_root_owned_sticky_shared_dir(monkeypatch):
monkeypatch.setattr(hs, "_effective_uid", lambda: 0)
hs._assert_trusted_root_parent(
Path("tmp"), _stat_result(stat.S_IFDIR | stat.S_ISVTX | 0o777), label="x"
)
def test_assert_no_existing_symlink_components_without_root_trust_still_rejects_symlink(
tmp_path: Path,
):
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
with pytest.raises(OutputSafetyError, match="parent path contains a symlink"):
hs._assert_no_existing_symlink_components(
link / "leaf", label="x", require_trusted_root_parents=False
)
def test_ensure_private_empty_dir_rejects_bad_existing_paths(tmp_path: Path):
file_path = tmp_path / "file"
file_path.write_text("x", encoding="utf-8")
with pytest.raises(OutputSafetyError, match="not a directory"):
hs.ensure_private_empty_dir(file_path, label="cache")
nonempty = tmp_path / "nonempty"
nonempty.mkdir()
(nonempty / "child").write_text("x", encoding="utf-8")
with pytest.raises(OutputSafetyError, match="not empty"):
hs.ensure_private_empty_dir(nonempty, label="cache")
real = tmp_path / "real"
real.mkdir()
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
with pytest.raises(OutputSafetyError, match="symlink"):
hs.ensure_private_empty_dir(link, label="cache")
def test_ensure_private_empty_dir_creates_private_dir(tmp_path: Path):
out = hs.ensure_private_empty_dir(tmp_path / "new-cache", label="cache")
assert out.is_dir()
assert (out.stat().st_mode & 0o777) == 0o700

View file

@ -2,6 +2,8 @@ import json
from pathlib import Path
import enroll.harvest as h
import enroll.harvest_collectors.services as services
import enroll.capture as capture
from enroll.platform import PlatformInfo
from enroll.systemd import UnitInfo
@ -78,7 +80,7 @@ def _base_monkeypatches(monkeypatch, *, unit: str):
# Avoid walking the real filesystem.
monkeypatch.setattr(h.os, "walk", lambda root: iter(()))
monkeypatch.setattr(h, "_copy_into_bundle", lambda *a, **k: None)
monkeypatch.setattr(capture, "copy_into_bundle", lambda *a, **k: None)
# Default to a "no files exist" view of the world unless a test overrides.
monkeypatch.setattr(h.os.path, "isfile", lambda p: False)
@ -119,7 +121,7 @@ def test_harvest_captures_nginx_enabled_symlinks(monkeypatch, tmp_path: Path):
return ["/etc/nginx/modules-enabled/mod-http"]
return []
monkeypatch.setattr(h.glob, "glob", fake_glob)
monkeypatch.setattr(services.glob, "glob", fake_glob)
state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
st = json.loads(Path(state_path).read_text(encoding="utf-8"))
@ -158,7 +160,7 @@ def test_harvest_does_not_capture_enabled_symlinks_without_role(
},
)
monkeypatch.setattr(
h.glob, "glob", lambda pat: ["/etc/nginx/sites-enabled/default"]
services.glob, "glob", lambda pat: ["/etc/nginx/sites-enabled/default"]
)
monkeypatch.setattr(h.os.path, "islink", lambda p: True)
monkeypatch.setattr(h.os, "readlink", lambda p: "../sites-available/default")
@ -186,7 +188,7 @@ def test_harvest_symlink_capture_respects_ignore_policy(monkeypatch, tmp_path: P
monkeypatch.setattr(h.os.path, "islink", lambda p: p in links)
monkeypatch.setattr(h.os, "readlink", lambda p: links[p])
monkeypatch.setattr(
h.glob,
services.glob,
"glob",
lambda pat: (
sorted(list(links.keys())) if pat == "/etc/nginx/sites-enabled/*" else []
@ -251,7 +253,7 @@ def test_harvest_captures_apache2_enabled_symlinks(monkeypatch, tmp_path: Path):
return ["/etc/apache2/conf-enabled/security.conf"]
return []
monkeypatch.setattr(h.glob, "glob", fake_glob)
monkeypatch.setattr(services.glob, "glob", fake_glob)
state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
st = json.loads(Path(state_path).read_text(encoding="utf-8"))

View file

@ -1,3 +1,8 @@
from __future__ import annotations
import os
from pathlib import Path
from enroll.ignore import IgnorePolicy
@ -8,3 +13,406 @@ 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_sensitive_common_assignment_keys(tmp_path: Path):
pol = IgnorePolicy()
cases = {
"password_yaml": "password: hunter2\n",
"password_json": '{"password": "hunter2"}\n',
"db_password": "db_password: hunter2\n",
"client_secret": "client_secret: abc123\n",
"secret_key": "secret_key = abc123\n",
"auth_token": "auth_token: abc123\n",
"passphrase": "passphrase: abc123\n",
"credentials": "credentials = abc123\n",
}
for name, text in cases.items():
config = tmp_path / name
config.write_text(text, encoding="utf-8")
assert pol.deny_reason(str(config)) == "sensitive_content", name
def test_deny_reason_sensitive_common_cloud_assignment_keys(tmp_path: Path):
pol = IgnorePolicy()
cases = {
"aws_access_key_id": "aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n",
"aws_secret_access_key": "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCY\n",
"azure_client_secret": "azure_client_secret: abc123\n",
"google_application_credentials": "GOOGLE_APPLICATION_CREDENTIALS=/etc/app/key.json\n",
"gcp_service_account": "gcp_service_account: svc@example.iam.gserviceaccount.com\n",
"service_account_key": "service_account_key: abc123\n",
}
for name, text in cases.items():
config = tmp_path / name
config.write_text(text, encoding="utf-8")
assert pol.deny_reason(str(config)) == "sensitive_content", name
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))
# A symlinked path (final component or parent) is refused with the
# dedicated symlink_component reason so operators can tell symlink
# redirection apart from genuine non-regular files (sockets, devices).
assert reason == "symlink_component"
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"
def test_detects_encrypted_private_key_marker(tmp_path):
p = tmp_path / "key.pem"
p.write_text(
"-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----\n",
encoding="utf-8",
)
assert IgnorePolicy().deny_reason(str(p)) == "sensitive_content"
def test_detects_pgp_private_key_marker(tmp_path):
p = tmp_path / "pgp.asc"
p.write_text(
"-----BEGIN PGP PRIVATE KEY BLOCK-----\nabc\n-----END PGP PRIVATE KEY BLOCK-----\n",
encoding="utf-8",
)
assert IgnorePolicy().deny_reason(str(p)) == "sensitive_content"
def test_secret_scan_reads_whole_file_under_size_cap(tmp_path):
p = tmp_path / "large.conf"
p.write_bytes(b"A" * 70_000 + b"\nlate_token = abc123\n")
assert IgnorePolicy().deny_reason(str(p)) == "sensitive_content"
def test_normalize_for_match_collapses_noncanonical_paths():
from enroll.ignore import normalize_for_match
assert normalize_for_match("/etc/shadow") == "/etc/shadow"
assert normalize_for_match("/etc//shadow") == "/etc/shadow"
assert normalize_for_match("/etc/foo/../shadow") == "/etc/shadow"
assert normalize_for_match("/etc/./shadow") == "/etc/shadow"
assert normalize_for_match("/etc/shadow/") == "/etc/shadow"
# A leading "//" is POSIX-significant to normpath but must collapse for
# glob matching anchored at "/".
assert normalize_for_match("//etc/shadow") == "/etc/shadow"
# "///" collapses to "/" via normpath already; ensure we don't mangle it.
assert normalize_for_match("///etc/shadow") == "/etc/shadow"
# Empty stays empty (no crash).
assert normalize_for_match("") == ""
def test_deny_reason_denies_noncanonical_sensitive_paths():
# Regression: non-canonical spellings of a denied path must still be denied
# rather than slipping past the deny glob. Defense-in-depth on top of the
# O_NOFOLLOW open in inspect_file(); see normalize_for_match().
pol = IgnorePolicy()
assert pol._path_deny_reason("/etc//shadow") == "denied_path"
assert pol._path_deny_reason("/etc/foo/../shadow") == "denied_path"
assert pol._path_deny_reason("/etc/./shadow") == "denied_path"
assert pol._path_deny_reason("/etc/ssl/private/../private/key") == "denied_path"
assert pol._path_deny_reason("//etc/shadow") == "denied_path"
# A normal config path is unaffected.
assert pol._path_deny_reason("/etc/nginx/nginx.conf") is None
def test_deny_reason_dir_denies_noncanonical_sensitive_paths():
pol = IgnorePolicy()
# normpath("/etc/ssl/private/../private") -> "/etc/ssl/private" which is the
# glob root itself, so use paths that still resolve to a child of it.
assert pol.deny_reason_dir("/etc/ssl/private/sub/../child") == "denied_path"
assert pol.deny_reason_dir("/etc//ssl/private/sub") == "denied_path"
def test_deny_reason_link_denies_noncanonical_sensitive_paths():
pol = IgnorePolicy()
assert pol.deny_reason_link("/etc/ssh/../ssh/ssh_host_rsa_key") == "denied_path"
assert pol.deny_reason_link("/etc//ssh/ssh_host_ed25519_key") == "denied_path"
def test_noncanonical_backup_and_log_fastpaths():
pol = IgnorePolicy()
assert pol._path_deny_reason("/var/log/foo/../bar.log") == "log_file"
assert pol._path_deny_reason("/etc/foo/../something~") == "backup_file"
assert pol._path_deny_reason("/etc//passwd-") == "backup_file"
def test_inspect_file_refuses_symlinked_parent_directory(tmp_path: Path):
"""A regular file reached through a symlinked *parent* directory must be
refused, even though O_NOFOLLOW alone would only guard the final
component. Otherwise a file whose real location is deny-globbed (or whose
content is benign) could be captured while its logical path looks safe.
"""
pol = IgnorePolicy()
secret = tmp_path / "secretroot"
secret.mkdir()
(secret / "config").write_text("listen_port=8080\n", encoding="utf-8")
(tmp_path / "allowed").symlink_to(secret)
reason, inspection = pol.inspect_file(str(tmp_path / "allowed" / "config"))
assert reason == "symlink_component"
assert inspection is None
def test_inspect_file_refuses_denyglob_evasion_via_symlinked_parent(tmp_path: Path):
"""The strongest variant: the real file lives under a deny-globbed dir,
but is reached via a symlinked parent so the *logical* path does not match
the deny glob. Content is non-secret-looking (DH params), so only the
parent-symlink check stands between the operator and disclosure.
"""
pol = IgnorePolicy()
realdir = tmp_path / "ssl_private"
realdir.mkdir()
(realdir / "dhparam.pem").write_text(
"-----BEGIN DH PARAMETERS-----\nMII...\n-----END DH PARAMETERS-----\n",
encoding="utf-8",
)
(tmp_path / "innocent").symlink_to(realdir)
reason, inspection = pol.inspect_file(str(tmp_path / "innocent" / "dhparam.pem"))
assert reason == "symlink_component"
assert inspection is None
def test_inspect_file_still_captures_normal_nested_file(tmp_path: Path):
"""Regression guard: ordinary files in real (non-symlinked) directories
must still be inspected and returned.
"""
pol = IgnorePolicy()
nested = tmp_path / "etc" / "myapp"
nested.mkdir(parents=True)
(nested / "app.conf").write_text("workers=4\n", encoding="utf-8")
reason, inspection = pol.inspect_file(str(nested / "app.conf"))
assert reason is None
assert inspection is not None
assert inspection.data == b"workers=4\n"

View file

@ -1,7 +1,8 @@
import json
from pathlib import Path
from state_helpers import write_schema_state
import enroll.manifest as manifest_mod
import enroll.jinjaturtle as jinjaturtle_mod
from enroll.jinjaturtle import JinjifyResult
@ -31,7 +32,10 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
"foo": {
"version": "1.0",
"arches": [],
"installations": [{"version": "1.0", "arch": "amd64"}],
"installations": [
{"version": "1.0", "arch": "amd64", "section": "utils"}
],
"section": "utils",
"observed_via": [{"kind": "systemd_unit", "ref": "foo.service"}],
"roles": ["foo"],
}
@ -99,11 +103,11 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
write_schema_state(bundle, state)
# Pretend jinjaturtle exists.
monkeypatch.setattr(
manifest_mod, "find_jinjaturtle_cmd", lambda: "/usr/bin/jinjaturtle"
jinjaturtle_mod, "find_jinjaturtle_cmd", lambda: "/usr/bin/jinjaturtle"
)
# Stub jinjaturtle output.
@ -116,20 +120,20 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
vars_text="foo_key: 1\n",
)
monkeypatch.setattr(manifest_mod, "run_jinjaturtle", fake_run_jinjaturtle)
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
manifest_mod.manifest(str(bundle), str(out), jinjaturtle="on")
# Template should exist in the role.
assert (out / "roles" / "foo" / "templates" / "etc" / "foo.ini.j2").exists()
role_dir = out / "roles" / "utils"
# Template should exist in the grouped section role.
assert (role_dir / "templates" / "etc" / "foo.ini.j2").exists()
# Raw file should NOT be copied into role files/ because it was templatised.
assert not (out / "roles" / "foo" / "files" / "etc" / "foo.ini").exists()
assert not (role_dir / "files" / "etc" / "foo.ini").exists()
# Defaults should include jinjaturtle vars.
defaults = (out / "roles" / "foo" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
defaults = (role_dir / "defaults" / "main.yml").read_text(encoding="utf-8")
assert "foo_key: 1" in defaults
@ -143,3 +147,118 @@ def test_openssh_paths_are_jinjaturtle_supported_and_forced_to_ssh() -> None:
assert can_jinjify_path("/etc/ssh/sshd_config")
assert can_jinjify_path("/etc/ssh/ssh_config")
def test_jinjify_managed_files_namespaces_multiple_templates(
monkeypatch, tmp_path: Path
):
from enroll.jinjaturtle import jinjify_managed_files
bundle = tmp_path / "bundle"
template_root = tmp_path / "templates"
for rel in ("etc/foo/a.yaml", "etc/foo/b.yaml"):
path = bundle / "artifacts" / "foo" / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("ignore: []\n", encoding="utf-8")
calls = []
def fake_run_jinjaturtle(jt_exe, src_path, *, role_name, force_format=None):
calls.append((Path(src_path).name, role_name))
return JinjifyResult(
template_text=f"ignore: {{{{ {role_name}_ignore }}}}\n",
vars_text=f"{role_name}_ignore: []\n",
)
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
templated, vars_text = jinjify_managed_files(
bundle,
"foo",
template_root,
[
{"path": "/etc/foo/a.yaml", "src_rel": "etc/foo/a.yaml"},
{"path": "/etc/foo/b.yaml", "src_rel": "etc/foo/b.yaml"},
],
jt_exe="jinjaturtle",
jt_enabled=True,
overwrite_templates=True,
role_name="foo",
)
assert templated == {"etc/foo/a.yaml", "etc/foo/b.yaml"}
assert calls == [
("a.yaml", "foo_etc_foo_a_yaml"),
("b.yaml", "foo_etc_foo_b_yaml"),
]
assert "foo_etc_foo_a_yaml_ignore: []" in vars_text
assert "foo_etc_foo_b_yaml_ignore: []" in vars_text
assert (template_root / "etc" / "foo" / "a.yaml.j2").read_text(
encoding="utf-8"
) == "ignore: {{ foo_etc_foo_a_yaml_ignore }}\n"
def test_jinjify_managed_files_rejects_templates_with_missing_defaults(
monkeypatch, tmp_path: Path
):
from enroll.jinjaturtle import jinjify_managed_files
bundle = tmp_path / "bundle"
template_root = tmp_path / "templates"
artifact = bundle / "artifacts" / "foo" / "etc" / "foo" / "pdk.yaml"
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text("ignore: []\n", encoding="utf-8")
def fake_run_jinjaturtle(jt_exe, src_path, *, role_name, force_format=None):
return JinjifyResult(
template_text=f"ignore: {{{{ {role_name}_ignore }}}}\n",
vars_text="--- {}\n",
)
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
templated, vars_text = jinjify_managed_files(
bundle,
"foo",
template_root,
[{"path": "/etc/foo/pdk.yaml", "src_rel": "etc/foo/pdk.yaml"}],
jt_exe="jinjaturtle",
jt_enabled=True,
overwrite_templates=True,
role_name="foo",
)
assert templated == set()
assert vars_text == ""
assert not (template_root / "etc" / "foo" / "pdk.yaml.j2").exists()
def test_jinjify_artifact_rejects_unsafe_src_rel(monkeypatch, tmp_path: Path):
from enroll.jinjaturtle import jinjify_artifact
bundle = tmp_path / "bundle"
template_root = tmp_path / "templates"
outside = tmp_path / "outside.yaml"
outside.write_text("key: value\n", encoding="utf-8")
called = False
def fake_run_jinjaturtle(*_args, **_kwargs):
nonlocal called
called = True
return JinjifyResult(template_text="key: {{ key }}\n", vars_text="key: value\n")
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
result = jinjify_artifact(
bundle,
"foo",
"../outside.yaml",
"/etc/foo.yaml",
template_root,
jt_exe="jinjaturtle",
jt_enabled=True,
)
assert result is None
assert called is False

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
from __future__ import annotations
from enroll.cm import CMModule
from enroll.ansible import AnsibleRole
def test_ansible_role_extends_cm_module_and_normalises_service_snapshot():
role = AnsibleRole("network")
role.add_service_snapshot(
{
"role_name": "networking",
"unit": "networking.service",
"packages": ["ifupdown"],
"active_state": "active",
"unit_file_state": "enabled",
"managed_dirs": [
{
"path": "/etc/network",
"owner": "root",
"group": "root",
"mode": "0755",
}
],
"managed_files": [
{
"path": "/etc/network/interfaces",
"src_rel": "etc/network/interfaces",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "service_config",
}
],
"managed_links": [
{
"path": "/etc/systemd/system/multi-user.target.wants/networking.service",
"target": "/usr/lib/systemd/system/networking.service",
}
],
"excluded": [{"path": "/etc/network/secrets", "reason": "secret"}],
"notes": ["captured for test"],
}
)
assert isinstance(role, CMModule)
assert role.sorted_packages == ["ifupdown"]
assert role.dirs["/etc/network"]["mode"] == "0755"
assert role.files["/etc/network/interfaces"]["src_rel"] == "etc/network/interfaces"
assert (
role.links["/etc/systemd/system/multi-user.target.wants/networking.service"][
"src"
]
== "/usr/lib/systemd/system/networking.service"
)
assert role.systemd_units_var == [
{
"name": "networking.service",
"manage": True,
"enabled": True,
"state": "started",
}
]
assert role.excluded == [{"path": "/etc/network/secrets", "reason": "secret"}]
assert role.notes == ["captured for test"]
assert "service `networking.service` from role `networking`" in role.origin_lines
def test_ansible_role_normalises_package_snapshot():
role = AnsibleRole("admin")
role.add_package_snapshot(
{
"role_name": "curl",
"package": "curl",
"managed_files": [
{
"path": "/etc/curlrc",
"src_rel": "etc/curlrc",
"owner": "root",
"group": "root",
"mode": "0644",
}
],
}
)
assert isinstance(role, CMModule)
assert role.sorted_packages == ["curl"]
assert role.files["/etc/curlrc"]["dest"] == "/etc/curlrc"
assert role.services == {}
assert role.origin_lines == ["package `curl` from role `curl`"]
from pathlib import Path
from state_helpers import write_schema_state
from enroll import manifest, yamlutil as yaml_helpers
def _ansible_jinja_payload_state(payload: str) -> dict:
return {
"schema_version": 3,
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
"inventory": {"packages": {}},
"roles": {
"users": {
"role_name": "users",
"users": [
{
"name": "alice",
"uid": 1000,
"gid": 1000,
"gecos": payload,
"home": "/home/alice",
"shell": "/bin/bash",
"primary_group": "alice",
"supplementary_groups": [],
}
],
"managed_dirs": [],
"managed_files": [],
"managed_links": [],
"excluded": [],
"notes": [],
},
"services": [],
"packages": [],
},
}
def test_ansible_static_marks_harvested_jinja_values_unsafe(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "out"
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
manifest.manifest(str(bundle), str(out), target="ansible")
defaults = out / "roles" / "users" / "defaults" / "main.yml"
text = defaults.read_text(encoding="utf-8")
assert "gecos: !unsafe" in text
assert "lookup(''pipe'',''touch /tmp/PWNED_BY_ENROLL_ANSIBLE'')" in text
loaded = yaml_helpers.yaml_load_mapping(text)
assert loaded["users_users"][0]["gecos"] == payload
def test_ansible_fqdn_marks_harvested_jinja_values_unsafe(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "out"
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
manifest.manifest(str(bundle), str(out), target="ansible", fqdn="host.example.test")
hostvars = out / "inventory" / "host_vars" / "host.example.test" / "users.yml"
text = hostvars.read_text(encoding="utf-8")
assert "gecos: !unsafe" in text
assert "lookup(''pipe'',''touch /tmp/PWNED_BY_ENROLL_ANSIBLE'')" in text
loaded = yaml_helpers.yaml_load_mapping(text)
assert loaded["users_users"][0]["gecos"] == payload

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,126 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
from enroll.manifest_safety import (
ArtifactSafetyError,
ManifestOutputError,
copy_safe_artifact_file,
iter_safe_artifact_files,
prepare_manifest_output_dir,
safe_artifact_file,
validate_site_fqdn,
)
def test_validate_site_fqdn_accepts_and_normalises_simple_values():
assert validate_site_fqdn(None) is None
assert validate_site_fqdn(" ") is None
assert validate_site_fqdn("host_1.example") == "host_1.example"
@pytest.mark.parametrize(
"value", ["../host", "host/name", "host\\name", "host\nname", "-bad", ".", ".."]
)
def test_validate_site_fqdn_rejects_path_or_inventory_injection(value: str):
with pytest.raises(ManifestOutputError):
validate_site_fqdn(value)
def test_prepare_manifest_output_dir_allows_existing_clean_tree_in_site_mode(
tmp_path: Path,
):
out = tmp_path / "site"
out.mkdir()
(out / ".git").mkdir()
(out / ".git" / "ignored-link").symlink_to(tmp_path, target_is_directory=True)
assert prepare_manifest_output_dir(out, allow_existing=True) == out
def test_prepare_manifest_output_dir_rejects_existing_tree_symlink(tmp_path: Path):
out = tmp_path / "site"
out.mkdir()
(out / "bad-link").symlink_to(tmp_path, target_is_directory=True)
with pytest.raises(ManifestOutputError, match="contains a symlink"):
prepare_manifest_output_dir(out, allow_existing=True)
def test_safe_artifact_file_accepts_regular_file_and_copy(tmp_path: Path):
bundle = tmp_path / "bundle"
artifact = bundle / "artifacts" / "role" / "etc" / "app.conf"
artifact.parent.mkdir(parents=True)
artifact.write_text("managed=true\n", encoding="utf-8")
assert safe_artifact_file(bundle, "role", "etc/app.conf") == artifact
dst = tmp_path / "copy.conf"
copy_safe_artifact_file(artifact, dst)
assert dst.read_text(encoding="utf-8") == "managed=true\n"
def test_safe_artifact_file_rejects_unsafe_role_and_src(tmp_path: Path):
bundle = tmp_path / "bundle"
with pytest.raises(ArtifactSafetyError, match="must be relative"):
safe_artifact_file(bundle, "/role", "file")
with pytest.raises(ArtifactSafetyError, match="unsafe path component"):
safe_artifact_file(bundle, "role", "../file")
with pytest.raises(ArtifactSafetyError, match="NUL"):
safe_artifact_file(bundle, "role", "bad\x00file")
def test_safe_artifact_file_rejects_artifacts_symlink(tmp_path: Path):
bundle = tmp_path / "bundle"
bundle.mkdir()
(bundle / "artifacts").symlink_to(tmp_path, target_is_directory=True)
with pytest.raises(ArtifactSafetyError, match="artifacts directory is a symlink"):
safe_artifact_file(bundle, "role", "file")
def test_safe_artifact_file_rejects_bad_artifact_kinds(tmp_path: Path):
bundle = tmp_path / "bundle"
role_dir = bundle / "artifacts" / "role"
role_dir.mkdir(parents=True)
target = role_dir / "target"
target.write_text("x", encoding="utf-8")
(role_dir / "link").symlink_to(target)
with pytest.raises(ArtifactSafetyError, match="symlink"):
safe_artifact_file(bundle, "role", "link")
(role_dir / "dir-artifact").mkdir()
with pytest.raises(ArtifactSafetyError, match="not a regular file"):
safe_artifact_file(bundle, "role", "dir-artifact")
hardlink = role_dir / "hardlink"
os.link(target, hardlink)
with pytest.raises(ArtifactSafetyError, match="hardlinked"):
safe_artifact_file(bundle, "role", "target")
def test_iter_safe_artifact_files_handles_missing_and_bad_role_dirs(tmp_path: Path):
bundle = tmp_path / "bundle"
assert list(iter_safe_artifact_files(bundle, "missing")) == []
role_file = bundle / "artifacts" / "role"
role_file.parent.mkdir(parents=True)
role_file.write_text("not a dir", encoding="utf-8")
with pytest.raises(ArtifactSafetyError, match="not a directory"):
list(iter_safe_artifact_files(bundle, "role"))
def test_iter_safe_artifact_files_rejects_symlink_subdir(tmp_path: Path):
bundle = tmp_path / "bundle"
role_dir = bundle / "artifacts" / "role"
role_dir.mkdir(parents=True)
real = tmp_path / "real"
real.mkdir()
(role_dir / "linkdir").symlink_to(real, target_is_directory=True)
with pytest.raises(ArtifactSafetyError, match="directory is a symlink"):
list(iter_safe_artifact_files(bundle, "role"))

1045
tests/test_manifest_salt.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
import json
from pathlib import Path
from state_helpers import write_schema_state
import enroll.manifest as manifest
@ -10,7 +11,20 @@ def test_manifest_emits_symlink_tasks_and_vars(tmp_path: Path):
state = {
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
"inventory": {"packages": {}},
"inventory": {
"packages": {
"nginx": {
"version": "1.0",
"arches": ["amd64"],
"installations": [
{"version": "1.0", "arch": "amd64", "section": "httpd"}
],
"section": "httpd",
"observed_via": [{"kind": "systemd_unit", "ref": "nginx.service"}],
"roles": ["nginx"],
}
}
},
"roles": {
"users": {
"role_name": "users",
@ -79,18 +93,17 @@ def test_manifest_emits_symlink_tasks_and_vars(tmp_path: Path):
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "artifacts").mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state), encoding="utf-8")
write_schema_state(bundle, state)
manifest.manifest(str(bundle), str(out))
tasks = (out / "roles" / "nginx" / "tasks" / "main.yml").read_text(encoding="utf-8")
role_dir = out / "roles" / "httpd"
tasks = (role_dir / "tasks" / "main.yml").read_text(encoding="utf-8")
assert "- name: Ensure managed symlinks exist" in tasks
assert 'loop: "{{ nginx_managed_links | default([]) }}"' in tasks
assert 'loop: "{{ httpd_managed_links | default([]) }}"' in tasks
defaults = (out / "roles" / "nginx" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
# The role defaults should include the converted link mapping.
assert "nginx_managed_links:" in defaults
defaults = (role_dir / "defaults" / "main.yml").read_text(encoding="utf-8")
# The grouped role defaults should include the converted link mapping.
assert "httpd_managed_links:" in defaults
assert "dest: /etc/nginx/sites-enabled/default" in defaults
assert "src: ../sites-available/default" in defaults

View file

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

View file

@ -0,0 +1,70 @@
from __future__ import annotations
from enroll.package_hints import (
add_pkgs_from_etc_topdirs,
hint_names,
maybe_add_specific_paths,
package_section_from_installations,
role_id,
role_name_from_pkg,
role_name_from_unit,
safe_name,
)
class _Backend:
def __init__(self, *, fail: bool = False):
self.fail = fail
def specific_paths_for_hints(self, hints):
if self.fail:
raise RuntimeError("backend unavailable")
return [f"/backend/{h}" for h in sorted(hints)]
def test_role_name_helpers_sanitise_reserved_and_odd_names():
assert safe_name("pkg.name+with-dash") == "pkg_name_with_dash"
assert role_id("123 Camel-Case!!") == "r_123_camel_case"
assert role_name_from_unit("class.service") == "class"
assert role_name_from_pkg("flatpak") == "package_flatpak"
def test_package_section_from_installations_filters_empty_and_unspecified():
assert package_section_from_installations([]) is None
assert (
package_section_from_installations([{"section": "none"}, {"group": ""}]) is None
)
assert (
package_section_from_installations([{"section": "z-utils"}, {"group": "admin"}])
== "admin"
)
def test_hint_names_expands_templates_packages_and_dot_prefixes():
assert hint_names("postgresql@14-main.service", {"postgresql.14"}) == {
"postgresql@14-main",
"postgresql",
"postgresql.14",
}
def test_add_pkgs_from_etc_topdirs_skips_shared_dirs():
pkgs: set[str] = set()
add_pkgs_from_etc_topdirs(
{"ssh", "nginx"},
{"ssh": {"openssh-server"}, "nginx": {"nginx"}, "nginx.d": {"nginx-extra"}},
pkgs,
)
assert pkgs == {"nginx", "nginx-extra"}
def test_maybe_add_specific_paths_uses_backend_and_fallback():
assert maybe_add_specific_paths({"a", "b"}, _Backend()) == [
"/backend/a",
"/backend/b",
]
assert maybe_add_specific_paths({"svc"}, _Backend(fail=True)) == [
"/etc/default/svc",
"/etc/init.d/svc",
"/etc/sysctl.d/svc.conf",
]

View file

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

View file

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

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import io
import tarfile
import warnings
from pathlib import Path
import pytest
@ -165,6 +166,13 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
return (None, _Stdout(b"alice\n"), _Stderr())
if cmd == "mktemp -d":
return (None, _Stdout(b"/tmp/enroll-remote-123\n"), _Stderr())
if cmd.startswith("sudo -n") and " mktemp -d" in cmd:
return (None, _Stdout(b"/tmp/enroll-root-123\n"), _Stderr())
if (
cmd.startswith("sudo -n")
and " chmod 700 -- /tmp/enroll-root-123" in cmd
):
return (None, _Stdout(b""), _Stderr())
if cmd.startswith("chmod 700"):
return (None, _Stdout(b""), _Stderr())
if cmd.startswith("sudo -n") and " harvest " in cmd:
@ -181,6 +189,8 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
msg = b"sudo: sorry, you must have a tty to run sudo\n"
return (None, _Stdout(b"", rc=1, err=msg), _Stderr(msg))
return (None, _Stdout(b"", rc=0), _Stderr(b""))
if cmd.startswith("sudo -n") and " rm -rf -- /tmp/enroll-root-123" in cmd:
return (None, _Stdout(b""), _Stderr())
if cmd.startswith("rm -rf"):
return (None, _Stdout(b""), _Stderr())
@ -222,6 +232,11 @@ def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
assert "--dangerous" in joined
assert "--include-path" in joined
assert "--exclude-path" in joined
assert "sudo -n -p '' -- mktemp -d" in joined
assert "--out /tmp/enroll-root-123/bundle" in joined
assert "--out /tmp/enroll-remote-123/bundle" not in joined
assert "chown -R -- alice /tmp/enroll-root-123" in joined
assert "tar -cz -C /tmp/enroll-root-123/bundle ." in joined
# Ensure we fall back to PTY only when sudo reports it is required.
assert any(c == "id -un" and pty is False for c, pty in calls)
@ -507,6 +522,13 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
if cmd == "mktemp -d":
return (_Stdin(cmd), _Stdout(b"/tmp/enroll-remote-789\n"), _Stderr())
if cmd.startswith("sudo -n") and " mktemp -d" in cmd:
return (_Stdin(cmd), _Stdout(b"/tmp/enroll-root-789\n"), _Stderr())
if (
cmd.startswith("sudo -n")
and " chmod 700 -- /tmp/enroll-root-789" in cmd
):
return (_Stdin(cmd), _Stdout(b""), _Stderr())
if cmd.startswith("chmod 700"):
return (_Stdin(cmd), _Stdout(b""), _Stderr())
@ -526,6 +548,8 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
if cmd.startswith("sudo -n") and " chown -R" in cmd:
return (_Stdin(cmd), _Stdout(b"", rc=0), _Stderr(b""))
if cmd.startswith("sudo -n") and " rm -rf -- /tmp/enroll-root-789" in cmd:
return (_Stdin(cmd), _Stdout(b"", rc=0), _Stderr(b""))
if cmd.startswith("rm -rf"):
return (_Stdin(cmd), _Stdout(b"", rc=0), _Stderr(b""))
@ -562,6 +586,465 @@ def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
sudo_s = [c for c, _pty in calls if c.startswith("sudo -S") and " harvest " in c]
assert len(sudo_n) == 1
assert len(sudo_s) == 1
joined = "\n".join([c for c, _pty in calls])
assert "sudo -n -p '' -- mktemp -d" in joined
assert "--out /tmp/enroll-root-789/bundle" in joined
assert "--out /tmp/enroll-remote-789/bundle" not in joined
# 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:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", DeprecationWarning)
_safe_extract_tar(tf, tmp_path)
assert not any(
"Python 3.14" in str(w.message) and issubclass(w.category, DeprecationWarning)
for w in caught
)
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(),
)

View file

@ -1,4 +1,5 @@
from __future__ import annotations
import pytest
import enroll.rpm as rpm
@ -148,9 +149,9 @@ def test_list_manual_packages_uses_yum_fallback(monkeypatch):
def test_list_installed_packages_parses_epoch_and_sorts(monkeypatch):
out = (
"bash\t0\t5.2.26\t1.el9\tx86_64\n"
"bash\t1\t5.2.26\t1.el9\taarch64\n"
"coreutils\t(none)\t9.1\t2.el9\tx86_64\n"
"bash\t0\t5.2.26\t1.el9\tx86_64\tSystem Environment/Shells\n"
"bash\t1\t5.2.26\t1.el9\taarch64\tSystem Environment/Shells\n"
"coreutils\t(none)\t9.1\t2.el9\tx86_64\tSystem Environment/Base\n"
)
monkeypatch.setattr(
rpm, "_run", lambda cmd, allow_fail=False, merge_err=False: (0, out)
@ -158,6 +159,7 @@ def test_list_installed_packages_parses_epoch_and_sorts(monkeypatch):
pkgs = rpm.list_installed_packages()
assert pkgs["bash"][0]["arch"] == "aarch64" # sorted by arch then version
assert pkgs["bash"][0]["version"].startswith("1:")
assert pkgs["bash"][0]["group"] == "System Environment/Shells"
assert pkgs["coreutils"][0]["version"] == "9.1-2.el9"
@ -176,3 +178,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
View 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()

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from enroll.state import StateSafetyError, load_state, open_state_file
def test_load_state_reads_regular_state_json(tmp_path: Path):
(tmp_path / "state.json").write_text(
json.dumps({"host": {"hostname": "test-host"}}), encoding="utf-8"
)
assert load_state(tmp_path)["host"]["hostname"] == "test-host"
def test_load_state_rejects_state_json_symlink(tmp_path: Path):
target = tmp_path / "target.json"
target.write_text("{}", encoding="utf-8")
(tmp_path / "state.json").symlink_to(target)
with pytest.raises(StateSafetyError, match="state.json is a symlink"):
load_state(tmp_path)
def test_load_state_rejects_non_regular_state_json(tmp_path: Path):
(tmp_path / "state.json").mkdir()
with pytest.raises(StateSafetyError, match="state.json is not a regular file"):
load_state(tmp_path)
def test_load_state_rejects_hardlinked_state_json(tmp_path: Path):
state_file = tmp_path / "state.json"
state_file.write_text("{}", encoding="utf-8")
os.link(state_file, tmp_path / "state-copy.json")
with pytest.raises(StateSafetyError, match="state.json is hardlinked"):
load_state(tmp_path)
def test_open_state_file_rejects_oversized_state_json(tmp_path: Path):
(tmp_path / "state.json").write_text("{}", encoding="utf-8")
with pytest.raises(StateSafetyError, match="state.json is too large"):
open_state_file(tmp_path, max_bytes=1)

View file

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

View file

@ -180,3 +180,308 @@ 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)
def test_validate_harvest_rejects_artifact_symlink(tmp_path: Path):
bundle_dir = tmp_path / "bundle"
artifact = bundle_dir / "artifacts" / "users" / "etc" / "shadow"
artifact.parent.mkdir(parents=True)
artifact.symlink_to("/etc/shadow")
(bundle_dir / "state.json").write_text(
json.dumps(
{
"roles": {
"users": {
"managed_files": [
{"path": "/etc/shadow", "src_rel": "etc/shadow"}
]
}
}
}
),
encoding="utf-8",
)
result = validate_harvest(str(bundle_dir), no_schema=True)
assert result.ok is False
assert any("symlink" in e for e in result.errors)
def test_validate_harvest_rejects_unreferenced_artifact_symlink(tmp_path: Path):
bundle_dir = tmp_path / "bundle"
artifact = bundle_dir / "artifacts" / "users" / "etc" / "shadow"
artifact.parent.mkdir(parents=True)
artifact.symlink_to("/etc/shadow")
(bundle_dir / "state.json").write_text(
json.dumps({"roles": {"users": {"managed_files": []}}}),
encoding="utf-8",
)
result = validate_harvest(str(bundle_dir), no_schema=True)
assert result.ok is False
assert any("symlink" in e for e in result.errors)
def test_validate_harvest_rejects_top_level_artifacts_symlink(tmp_path: Path):
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
target = tmp_path / "artifact-target"
target.mkdir()
(bundle_dir / "artifacts").symlink_to(target, target_is_directory=True)
(bundle_dir / "state.json").write_text(
json.dumps({"roles": {"users": {"managed_files": []}}}),
encoding="utf-8",
)
result = validate_harvest(str(bundle_dir), no_schema=True)
assert result.ok is False
assert any("artifacts directory is a symlink" in e for e in result.errors)
def test_validate_harvest_rejects_top_level_artifacts_file(tmp_path: Path):
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
(bundle_dir / "artifacts").write_text("not a directory", encoding="utf-8")
(bundle_dir / "state.json").write_text(
json.dumps({"roles": {"users": {"managed_files": []}}}),
encoding="utf-8",
)
result = validate_harvest(str(bundle_dir), no_schema=True)
assert result.ok is False
assert any("artifacts path is not a directory" in e for e in result.errors)

View file

@ -1,36 +1,59 @@
from __future__ import annotations
import sys
import types
import importlib.metadata as metadata
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"
result = get_enroll_version()
assert isinstance(result, str)
assert len(result) > 0
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
def test_get_enroll_version_falls_back_after_bad_mapped_dist(monkeypatch):
from enroll.version import get_enroll_version
monkeypatch.setattr(
importlib.metadata,
metadata,
"packages_distributions",
lambda: {"enroll": ["enroll-dist"]},
lambda: {"enroll": ["wrong-dist", "enroll"]},
)
monkeypatch.setattr(importlib.metadata, "version", lambda dist: "9.9.9")
assert get_enroll_version() == "9.9.9"
def fake_version(name):
if name == "enroll":
return "0.7.0"
raise metadata.PackageNotFoundError(name)
monkeypatch.setattr(metadata, "version", fake_version)
assert get_enroll_version() == "0.7.0"
def test_get_enroll_version_uses_default_when_mapping_fails(monkeypatch):
from enroll.version import get_enroll_version
def broken_mapping():
raise RuntimeError("metadata unavailable")
monkeypatch.setattr(metadata, "packages_distributions", broken_mapping)
monkeypatch.setattr(
metadata,
"version",
lambda name: "0.7.0" if name == "enroll" else "bad",
)
assert get_enroll_version() == "0.7.0"
def test_get_enroll_version_returns_unknown_when_all_lookups_fail(monkeypatch):
from enroll.version import get_enroll_version
monkeypatch.setattr(metadata, "packages_distributions", lambda: {"enroll": ["bad"]})
def missing(_name):
raise metadata.PackageNotFoundError(_name)
monkeypatch.setattr(metadata, "version", missing)
assert get_enroll_version() == "unknown"