Initial server source import

This commit is contained in:
sashatrask
2026-09-30 20:30:56 +03:00
commit 170dd941b9
498 changed files with 261563 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
FROM truf-worker-test:test AS edge-e2e-runtime
USER 0:0
COPY --chown=10001:10001 --chmod=0600 tests/edge_e2e_backend.py tests/edge_e2e_client.py /opt/truf/tests/
USER 10001:10001
+273
View File
@@ -0,0 +1,273 @@
# Runtime Dependency Build
This directory records the public build inputs and pip-tools generator. It is not
an application configuration directory and must never contain credentials.
## Pins
| Input | Pin |
| --- | --- |
| Python image | `python:3.12-slim-bookworm@sha256:782412e85d0f0984994c290652577d4018aff08145c85b262bb63dc0c7522254` |
| Python version | `3.12.14` |
| Linux amd64 manifest | `sha256:9c47360a2a0355e2da18516d0b1c2126ec22c195d2185e97347c9d98398c5bef` |
| Linux arm64/v8 manifest | `sha256:d04f49f5882f49a3b91f874e75e19f0c265f7222da8659741a9d7eab148f22a9` |
| Debian and Debian security snapshots | `20260914T000000Z` |
| Git and git-man | `1:2.39.5-0+deb12u3` |
| tini | `0.19.0-1+b3` |
| CA certificates (already present in the pinned base) | `20250419~deb12u1` |
| PGDG server, client, libpq | `16.15-1.pgdg12+2` |
| PGDG common and client-common | `293.pgdg12+1` |
| PGDG signing-key fingerprint | `B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8` |
| PGDG signing-key SHA-256 | `0144068502a1eddd2a0280ede10ef607d1ec592ce819940991203941564e8e76` |
| TruffleHog | `3.97.4` |
| TruffleHog Linux amd64 archive SHA-256 | `dc24007c2f233bd61c05beabeb44aa27ea9b43288166279209abe0458c5ce76b` |
| TruffleHog Linux amd64 archive size | `34970205` bytes |
| TruffleHog Linux arm64 archive SHA-256 | `7e65e771d2a247964056aa5edba0f8ae3945895e5dce867fe0ffbc7b0128239a` |
| TruffleHog Windows amd64 archive SHA-256 | `6ce9a957ac62bfb19463048333d9e8481327dbbf5bdc0c43f5ab5327b9631fb9` |
| Windows embeddable Python | `3.12.10`, SHA-256 `4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3` |
| Windows MinGit | `2.47.1.windows.1`, SHA-256 `50b04b55425b5c465d076cdb184f63a0cd0f86f6ec8bb4d5860114a713d2c29a` |
| pip-tools | `7.6.1` |
| Generator pip | `26.2.1` |
| pytest | `8.4.2` |
| httpx (test target only) | `0.28.1` |
| zstandard | `0.23.0` |
| pandas | `3.0.5` |
| plotly | `7.0.0` |
| streamlit | `1.63.0` |
| psycopg and psycopg-binary | `3.3.5` |
| boto3 and botocore | `1.43.94` |
TruffleHog's expected hashes were checked against the public release's
[`trufflehog_3.97.4_checksums.txt`](https://github.com/trufflesecurity/trufflehog/releases/download/v3.97.4/trufflehog_3.97.4_checksums.txt).
The PGDG key's primary OpenPGP fingerprint was independently calculated from the
hash-pinned public key and matches the fingerprint above.
The Dockerfile pins the base index, download digests, PGDG package versions, and
Debian snapshot. Apt verifies Debian signatures with its shipped archive keyring
and PGDG signatures with the separately hash-pinned, repository-scoped armored
key. Full GnuPG is not installed. Expired `Valid-Until` checks are disabled only
for the immutable Debian snapshots, never signature verification. The official
PGDG archive retains older package versions; apt preferences exclude every PGDG
package except the five exact pins above.
The complete Debian Git package and HTTPS helper are retained. Native executable
symlinks in the Python/Git tool directories, and PG client version-wrapper links,
are replaced with root-owned regular hard links. Dependencies stay in the
interpreter's `/usr/local/lib/python3.12/site-packages`, not a virtual environment
or user site. Installation requires hashes and binary wheels and disables
bytecode generation. The application lock includes both manifests, optional
dashboard packages, and pytest in one environment. The test target layers its
separate hash lock containing Starlette TestClient's `httpx` dependency; the
runtime target does not contain `httpx`. The separate worker lock contains only
Requests, PyYAML, zstandard, and their four transitives; it excludes PostgreSQL,
server, dashboard, test, and detailed-keycheck dependencies.
`docker/worker-package-pins.json` is the canonical public build-input record for
Linux and Windows worker artifacts. Worker manifests hash every application,
dependency, scanner, detector-policy, Python-runtime, and complete Git-runtime
file. The package grants no provider or detailed keycheck authority and contains
no server or database credentials.
PostgreSQL service starts and automatic cluster creation are disabled during
installation. No database is initialized by the build. Generated distribution
snakeoil TLS keys are removed in the same layer. Package pins make dependency
selection repeatable; this is not a claim of byte-for-byte identical OCI images
across BuildKit versions or package-maintainer timestamp generation.
## Generate Locks
All four requirements lock files are generated by pip-tools on Linux with the pinned
Python 3.12.14 interpreter. Do not edit any generated lock by hand. The initial
compiler bootstrap installs only public `pip==26.2.1` and `pip-tools==7.6.1`, then
resolves and hashes the compiler's entire dependency closure. Subsequent compiler
installs use that generated hash lock.
The generator also copies the existing locks, so normal regeneration retains
valid pins. Use pip-compile's `--upgrade` only for an intentional dependency
refresh, then rebuild and revalidate the dependency image.
Run these Docker commands from the isolated source checkout (`D:\truf-workers` for
this change), using WSL's
`sudo -n docker -H unix:///var/run/docker.sock` in place of `docker` on this host.
The generator receives only the three public application manifests and compiler
inputs. There are no host bind mounts.
```sh
docker build --target lock-generator -t truf-lock-generator:py3.12.14 .
docker run --name truf-runtime-lock truf-lock-generator:py3.12.14
docker cp truf-runtime-lock:/src/docker/requirements.lock docker/requirements.lock
docker rm truf-runtime-lock
docker run --name truf-test-lock truf-lock-generator:py3.12.14 \
python3 -m piptools compile --generate-hashes --allow-unsafe \
--resolver=backtracking --strip-extras --no-emit-index-url \
--no-emit-trusted-host --index-url=https://pypi.org/simple \
--pip-args=--only-binary=:all: \
--output-file=docker/requirements-test.lock docker/requirements-test.in
docker cp truf-test-lock:/src/docker/requirements-test.lock docker/requirements-test.lock
docker rm truf-test-lock
docker run --name truf-worker-lock truf-lock-generator:py3.12.14 \
python3 -m piptools compile --generate-hashes --allow-unsafe \
--resolver=backtracking --strip-extras --no-emit-index-url \
--no-emit-trusted-host --index-url=https://pypi.org/simple \
--pip-args=--only-binary=:all: \
--output-file=docker/requirements-worker.lock docker/requirements-worker.in
docker cp truf-worker-lock:/src/docker/requirements-worker.lock docker/requirements-worker.lock
docker rm truf-worker-lock
docker run --name truf-compiler-lock truf-lock-generator:py3.12.14 \
python3 -m piptools compile --generate-hashes --allow-unsafe \
--resolver=backtracking --strip-extras --no-emit-index-url \
--no-emit-trusted-host --index-url=https://pypi.org/simple \
--pip-args=--only-binary=:all: \
--output-file=docker/build-dependencies/requirements.lock \
docker/build-dependencies/requirements.in
docker cp truf-compiler-lock:/src/docker/build-dependencies/requirements.lock docker/build-dependencies/requirements.lock
docker rm truf-compiler-lock
```
## Build Targets
```sh
docker build --target dependencies -t truf-dependencies:py3.12.14-pg16.15-th3.97.4 .
docker build --target runtime -t truf-runtime:local .
docker build --target test -t truf-test:local .
docker build --target worker -t truf-remote-worker:linux-x86_64 .
```
## Remote Worker Artifacts
The `worker` target is independent of the server runtime. It uses the pinned image
Python and includes the worker authority, required shared DB-free modules, worker
dependency lock, detector policy, TruffleHog, CA roots, tini, and a package-local
complete Git helper tree. The final build executes the scanner and Git version
checks and verifies the full schema-3/protocol-2 capability package as unprivileged UID 10001. PostgreSQL
tools, server runtime/control authority, test code, `httpx`, provider and detailed
keycheck authority, server credentials, and database credentials are absent.
Build the Windows amd64 portable directory and deterministic ZIP from the same
isolated checkout:
```powershell
New-Item -ItemType Directory -Path dist -Force | Out-Null
python -B app/worker_package_builder.py windows `
--project-root . `
--output dist/truf-worker-windows-x86_64 `
--archive dist/truf-worker-windows-x86_64.zip `
--cache build/worker-cache
```
The builder downloads the hash-and-size-pinned Python 3.12.10 embeddable runtime,
MinGit 2.47.1, and TruffleHog 3.97.4, installs the cross-platform worker lock with
hash checking, verifies the complete package, and writes adjacent release JSON.
After extracting the ZIP, run `prepare-worker.ps1` once to replace inherited ACLs,
then use `run-worker.cmd`. Local files rely on private OS ACLs rather than
application-layer encryption; the client has no TLS-verification bypass.
Build a complete distributable release (Windows ZIP, Linux image archive,
Docker bundle, manifests, README, Compose file, and SHA-256 lists) with one
PowerShell command:
```powershell
.\build_worker_release.ps1 -ReleaseName release-YYYYMMDD-vN
```
The script uses native Docker when available and otherwise uses the Docker Engine
in `Ubuntu-24.04` through WSL. Use `-WslDistro NAME` for another distribution.
The destination must not already exist; a failed build remains on disk for
inspection and is never published as a partial replacement.
While the main context allowlist is pending, the dependency build can use this
explicit two-file context from WSL. It does not send any application code, secret
files, findings, state, or original checkout directories to the builder:
```sh
tar -C /mnt/d/truf-docker -cf - Dockerfile docker/requirements.lock \
| sudo -n docker -H unix:///var/run/docker.sock build --file Dockerfile \
--target dependencies -t truf-dependencies:py3.12.14-pg16.15-th3.97.4 -
```
`dependencies` stops before copying application code. `runtime-base` holds the
production filesystem and launch configuration; `test` inherits those exact
contents and adds private test sources. The last/default target, `runtime`, is a
direct alias of `runtime-base` and contains no test tree. The test entrypoint uses
the same interpreter isolation flags and the real `child_bootstrap.py` dependency
path loader, without processing `.pth` files or starting the application.
Application and test copies are owned by `10001:10001`; their directories are
`0700` and regular files `0600`. Build-time checks reject symlinks, special files,
and cached bytecode in these controlled copies. No recursive permission changes
are made to host paths or runtime-mounted data. The default user is `10001:10001`;
the image precreates private `/data` and `/data/home` directories.
## Verified Results
Validated on 2026-09-15 using Ubuntu 24.04 under WSL2, stock Docker Engine 29.8.0,
the local Unix socket, and `sudo -n`. Builds and generation were polled without
printing full dependency logs. A temporary WSL keepalive prevented idle shutdown
during detached lock generation; no host configuration changes were made.
| Result | Value |
| --- | --- |
| Dependency image tag | `truf-dependencies:py3.12.14-pg16.15-th3.97.4` |
| Local dependency image ID | `sha256:3fbdc2ea6fd1199aa742f54fb653e433d79ad3f1a5e4bfeed8b413dc9f704eb5` |
| Built and executed platform | `linux/amd64` |
| Runtime lock | 49 exact package pins, 1059 SHA-256 wheel hashes |
| Runtime lock SHA-256 | `82c69394b116fd8a762c3dea481682045af87c013974fcb78ac0529892188537` |
| Compiler lock | 8 exact package pins, 8 SHA-256 wheel hashes |
| Compiler lock SHA-256 | `0172b08004c6f2b0702ea9a472300cc63243492df2d3d782fd4dbaa612497fd2` |
| Lock replay in the hash-locked generator image | Both files byte-for-byte identical |
| `pip check` | No broken requirements |
- The `dependencies` and `lock-generator` targets built successfully with hash-required, binary-wheel-only installs.
- All 49 package import checks passed normally, then with `-I -S -B` through the actual `child_bootstrap.py` loader for all 10 child kinds. Application entrypoints and providers were not executed.
- Isolated `sys.path` contained only the interpreter ZIP path, standard library, `lib-dynload`, and `/usr/local/lib/python3.12/site-packages`. Neither the working directory nor the user site was added.
- 14,202 dependency-tree permission checks found root ownership, no group/world write access, and no symlinks or bytecode files. The unprivileged UID could not write these dependencies.
- Eleven selected native executables were regular root-owned files. Version and `ldd` checks passed for Python, Git, the Git HTTPS helper, tini, TruffleHog, and the six PG16 tools. TruffleHog is static; other checked ELF binaries had no missing shared libraries.
- TruffleHog global, Git, filesystem, Docker, and Hugging Face help flags were checked, including the scanner's legacy `--local-dev` and `--log-level` options. No scans or provider requests were made.
- No PostgreSQL `PG_VERSION` file or initialized cluster was present. No PostgreSQL server, application, or provider was started. Full GnuPG is absent.
- Minimal bootstrap-only runtime and test fixtures exercised the actual copy stages: `10001:10001`, directories `0700`, files `0600`. Generated in-memory contexts containing a symlink or `.pyc` file were rejected by the build.
- The test target's real tini/Python/bootstrap entrypoint reported `pytest 8.4.2` with networking disabled, a read-only root, and a UID-10001 private `/tmp` tmpfs. Pytest capture needs writable temporary storage even for `--version`.
The temporary runtime/test fixtures were deliberately incomplete and were used
only for dependency and filesystem-policy verification. They are not deployable
application images. No application test suite, PostgreSQL initialization test,
provider integration, or arm64 build was run. The arm64 base and TruffleHog assets
are pinned, but that platform still requires a native or emulated build/test.
## Integration Boundary
The main integration owns `.dockerignore`, `app/container_runtime.py`, Compose,
and tests. The context allowlist must include the exact Dockerfile, dependency
input/lock paths, this metadata file, the new runtime entrypoint, and the intended
test files. Do not replace the deny-by-default context rules with directory-wide
or wildcard exceptions. Keep `.env`, credentials, findings, original runtime
directories, and generated caches excluded.
New exact metadata/input exceptions required in the main-owned `.dockerignore`:
```text
!docker/requirements.in
!docker/requirements.lock
!docker/build-dependencies/requirements.in
!docker/build-dependencies/requirements.lock
!docker/build-dependencies/README.md
```
The Dockerfile is already allowlisted. Main must separately allowlist its
`app/container_runtime.py` and intended test source files once they exist. The
test target copies only `app/` and `tests/`; repository tests that read root-level
Compose, Docker, or PowerShell fixtures still need main-owned fixture integration.
Compose must select the runtime target, enforce a read-only root filesystem,
provide newly initialized Linux writable volumes and a temporary filesystem as
needed, and preserve the unprivileged UID/GID. Runtime startup, PostgreSQL
initialization, provider execution, and existing-data integration are explicitly
outside dependency-build validation.
For a read-only test image, provide private temporary storage, for example
`--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m,mode=0700,uid=10001,gid=10001` for
version/import checks. Main must choose temporary-storage size and execution
policy appropriate for its full tests. After the entrypoint, allowlist, fixtures,
and Compose changes are integrated, rebuild complete `runtime` and `test` images
and perform the separately authorized integration checks. No files outside the
Dockerfile and `docker/` build inputs were edited, and nothing was staged,
committed, or pushed.
@@ -0,0 +1,3 @@
# Toolchain only; these packages are not copied from the generator into runtime.
pip==26.2.1
pip-tools==7.6.1
@@ -0,0 +1,40 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# See docker/build-dependencies/README.md for the pinned Python 3.12.14 pip-tools generation command.
#
--only-binary :all:
build==1.6.1 \
--hash=sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7
# via pip-tools
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360
# via pip-tools
packaging==26.3 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# build
# wheel
pip-tools==7.6.1 \
--hash=sha256:6111c8b4b07fd14b7223ca921485b0e96cf66e20bf94da95eeed9845f510cb8f
# via -r docker/build-dependencies/requirements.in
pyproject-hooks==1.2.0 \
--hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
# via
# build
# pip-tools
wheel==0.48.0 \
--hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab
# via pip-tools
# The following packages are considered to be unsafe in a requirements file:
pip==26.2.1 \
--hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e
# via
# -r docker/build-dependencies/requirements.in
# pip-tools
setuptools==84.0.0 \
--hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670
# via pip-tools
+1
View File
@@ -0,0 +1 @@
httpx==0.28.1
+33
View File
@@ -0,0 +1,33 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# See docker/build-dependencies/README.md for the pinned Python 3.12.14 pip-tools generation command.
#
--only-binary :all:
anyio==4.15.1 \
--hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101
# via httpx
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775
# via
# httpcore
# httpx
h11==0.16.0 \
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
# via httpcore
httpcore==1.0.9 \
--hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55
# via httpx
httpx==0.28.1 \
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
# via -r docker/requirements-test.in
idna==3.20 \
--hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
# via
# anyio
# httpx
typing-extensions==4.16.0 \
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
# via anyio
+3
View File
@@ -0,0 +1,3 @@
PyYAML==6.0.3
requests==2.34.2
zstandard==0.23.0
+365
View File
@@ -0,0 +1,365 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# See docker/build-dependencies/README.md for the pinned Python 3.12.14 pip-tools generation command.
#
--only-binary :all:
certifi==2026.7.22 \
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775
# via requests
charset-normalizer==3.5.1 \
--hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \
--hash=sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d \
--hash=sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5 \
--hash=sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b \
--hash=sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f \
--hash=sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f \
--hash=sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5 \
--hash=sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22 \
--hash=sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5 \
--hash=sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac \
--hash=sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838 \
--hash=sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90 \
--hash=sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626 \
--hash=sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4 \
--hash=sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369 \
--hash=sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b \
--hash=sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e \
--hash=sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee \
--hash=sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1 \
--hash=sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102 \
--hash=sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8 \
--hash=sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8 \
--hash=sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9 \
--hash=sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf \
--hash=sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0 \
--hash=sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031 \
--hash=sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e \
--hash=sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235 \
--hash=sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072 \
--hash=sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb \
--hash=sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c \
--hash=sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950 \
--hash=sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2 \
--hash=sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb \
--hash=sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e \
--hash=sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6 \
--hash=sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b \
--hash=sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2 \
--hash=sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d \
--hash=sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa \
--hash=sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2 \
--hash=sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818 \
--hash=sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032 \
--hash=sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71 \
--hash=sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96 \
--hash=sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687 \
--hash=sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8 \
--hash=sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3 \
--hash=sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61 \
--hash=sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9 \
--hash=sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1 \
--hash=sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a \
--hash=sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420 \
--hash=sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4 \
--hash=sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65 \
--hash=sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663 \
--hash=sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f \
--hash=sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591 \
--hash=sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a \
--hash=sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18 \
--hash=sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e \
--hash=sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f \
--hash=sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7 \
--hash=sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c \
--hash=sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3 \
--hash=sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7 \
--hash=sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96 \
--hash=sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486 \
--hash=sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3 \
--hash=sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6 \
--hash=sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b \
--hash=sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731 \
--hash=sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959 \
--hash=sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9 \
--hash=sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf \
--hash=sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8 \
--hash=sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e \
--hash=sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f \
--hash=sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885 \
--hash=sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0 \
--hash=sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506 \
--hash=sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2 \
--hash=sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0 \
--hash=sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e \
--hash=sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f \
--hash=sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e \
--hash=sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491 \
--hash=sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a \
--hash=sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20 \
--hash=sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449 \
--hash=sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af \
--hash=sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c \
--hash=sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712 \
--hash=sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7 \
--hash=sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a \
--hash=sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20 \
--hash=sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f \
--hash=sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3 \
--hash=sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9 \
--hash=sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e \
--hash=sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5 \
--hash=sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2 \
--hash=sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36 \
--hash=sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263 \
--hash=sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4 \
--hash=sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11 \
--hash=sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a \
--hash=sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3 \
--hash=sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375 \
--hash=sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa \
--hash=sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d \
--hash=sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5 \
--hash=sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99 \
--hash=sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d \
--hash=sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c \
--hash=sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488 \
--hash=sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6 \
--hash=sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc \
--hash=sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b \
--hash=sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f \
--hash=sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00 \
--hash=sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10 \
--hash=sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598 \
--hash=sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6 \
--hash=sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962 \
--hash=sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c \
--hash=sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08 \
--hash=sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab \
--hash=sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573 \
--hash=sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90 \
--hash=sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5 \
--hash=sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18 \
--hash=sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d \
--hash=sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af \
--hash=sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea \
--hash=sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c \
--hash=sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b \
--hash=sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6 \
--hash=sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8 \
--hash=sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774 \
--hash=sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004 \
--hash=sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a \
--hash=sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a \
--hash=sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2 \
--hash=sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2 \
--hash=sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa \
--hash=sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe \
--hash=sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3 \
--hash=sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc \
--hash=sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795 \
--hash=sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d \
--hash=sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc \
--hash=sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893 \
--hash=sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef \
--hash=sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d \
--hash=sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda \
--hash=sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17 \
--hash=sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30 \
--hash=sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7 \
--hash=sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5 \
--hash=sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182 \
--hash=sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f \
--hash=sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9 \
--hash=sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada \
--hash=sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876 \
--hash=sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a \
--hash=sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348 \
--hash=sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3 \
--hash=sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f \
--hash=sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0 \
--hash=sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f
# via requests
idna==3.20 \
--hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
# via requests
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via -r docker/requirements-worker.in
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0
# via -r docker/requirements-worker.in
urllib3==2.8.0 \
--hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3
# via requests
zstandard==0.23.0 \
--hash=sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473 \
--hash=sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916 \
--hash=sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15 \
--hash=sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072 \
--hash=sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4 \
--hash=sha256:157e89ceb4054029a289fb504c98c6a9fe8010f1680de0201b3eb5dc20aa6d9e \
--hash=sha256:1bfe8de1da6d104f15a60d4a8a768288f66aa953bbe00d027398b93fb9680b26 \
--hash=sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8 \
--hash=sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5 \
--hash=sha256:203d236f4c94cd8379d1ea61db2fce20730b4c38d7f1c34506a31b34edc87bdd \
--hash=sha256:27d3ef2252d2e62476389ca8f9b0cf2bbafb082a3b6bfe9d90cbcbb5529ecf7c \
--hash=sha256:29a2bc7c1b09b0af938b7a8343174b987ae021705acabcbae560166567f5a8db \
--hash=sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5 \
--hash=sha256:2ef3775758346d9ac6214123887d25c7061c92afe1f2b354f9388e9e4d48acfc \
--hash=sha256:2f146f50723defec2975fb7e388ae3a024eb7151542d1599527ec2aa9cacb152 \
--hash=sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269 \
--hash=sha256:32ba3b5ccde2d581b1e6aa952c836a6291e8435d788f656fe5976445865ae045 \
--hash=sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e \
--hash=sha256:379b378ae694ba78cef921581ebd420c938936a153ded602c4fea612b7eaa90d \
--hash=sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a \
--hash=sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb \
--hash=sha256:4051e406288b8cdbb993798b9a45c59a4896b6ecee2f875424ec10276a895740 \
--hash=sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105 \
--hash=sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274 \
--hash=sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2 \
--hash=sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58 \
--hash=sha256:50a80baba0285386f97ea36239855f6020ce452456605f262b2d33ac35c7770b \
--hash=sha256:519fbf169dfac1222a76ba8861ef4ac7f0530c35dd79ba5727014613f91613d4 \
--hash=sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db \
--hash=sha256:53ea7cdc96c6eb56e76bb06894bcfb5dfa93b7adcf59d61c6b92674e24e2dd5e \
--hash=sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9 \
--hash=sha256:59556bf80a7094d0cfb9f5e50bb2db27fefb75d5138bb16fb052b61b0e0eeeb0 \
--hash=sha256:5d41d5e025f1e0bccae4928981e71b2334c60f580bdc8345f824e7c0a4c2a813 \
--hash=sha256:61062387ad820c654b6a6b5f0b94484fa19515e0c5116faf29f41a6bc91ded6e \
--hash=sha256:61f89436cbfede4bc4e91b4397eaa3e2108ebe96d05e93d6ccc95ab5714be512 \
--hash=sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0 \
--hash=sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b \
--hash=sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48 \
--hash=sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a \
--hash=sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772 \
--hash=sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed \
--hash=sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373 \
--hash=sha256:752bf8a74412b9892f4e5b58f2f890a039f57037f52c89a740757ebd807f33ea \
--hash=sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd \
--hash=sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f \
--hash=sha256:77da4c6bfa20dd5ea25cbf12c76f181a8e8cd7ea231c673828d0386b1740b8dc \
--hash=sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23 \
--hash=sha256:80080816b4f52a9d886e67f1f96912891074903238fe54f2de8b786f86baded2 \
--hash=sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db \
--hash=sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70 \
--hash=sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259 \
--hash=sha256:84433dddea68571a6d6bd4fbf8ff398236031149116a7fff6f777ff95cad3df9 \
--hash=sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700 \
--hash=sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003 \
--hash=sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba \
--hash=sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a \
--hash=sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c \
--hash=sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90 \
--hash=sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690 \
--hash=sha256:a05e6d6218461eb1b4771d973728f0133b2a4613a6779995df557f70794fd60f \
--hash=sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840 \
--hash=sha256:a4ae99c57668ca1e78597d8b06d5af837f377f340f4cce993b551b2d7731778d \
--hash=sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9 \
--hash=sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35 \
--hash=sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd \
--hash=sha256:ab19a2d91963ed9e42b4e8d77cd847ae8381576585bad79dbd0a8837a9f6620a \
--hash=sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea \
--hash=sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1 \
--hash=sha256:b2170c7e0367dde86a2647ed5b6f57394ea7f53545746104c6b09fc1f4223573 \
--hash=sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094 \
--hash=sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78 \
--hash=sha256:b8c0bd73aeac689beacd4e7667d48c299f61b959475cdbb91e7d3d88d27c56b9 \
--hash=sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5 \
--hash=sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9 \
--hash=sha256:c16842b846a8d2a145223f520b7e18b57c8f476924bda92aeee3a88d11cfc391 \
--hash=sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847 \
--hash=sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2 \
--hash=sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c \
--hash=sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2 \
--hash=sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057 \
--hash=sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20 \
--hash=sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d \
--hash=sha256:dc5d1a49d3f8262be192589a4b72f0d03b72dcf46c51ad5852a4fdc67be7b9e4 \
--hash=sha256:e2d1a054f8f0a191004675755448d12be47fa9bebbcffa3cdf01db19f2d30a54 \
--hash=sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171 \
--hash=sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e \
--hash=sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160 \
--hash=sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b \
--hash=sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58 \
--hash=sha256:f83fa6cae3fff8e98691248c9320356971b59678a17f20656a9e59cd32cee6d8 \
--hash=sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33 \
--hash=sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a \
--hash=sha256:fc9ca1c9718cb3b06634c7c8dec57d24e9438b2aa9a0f02b8bb36bf478538880 \
--hash=sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca \
--hash=sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b \
--hash=sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69
# via -r docker/requirements-worker.in
+6
View File
@@ -0,0 +1,6 @@
# Keep the runtime union tied to both application manifests, including the dashboard.
-r ../app/requirements.txt
-r ../app/requirements-keycheckers.txt
# One hash-locked environment is shared by the runtime and test targets.
pytest==8.4.2
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
"""Pure Compose regressions: python -I -S -B docker/test_verify.py -v."""
from copy import deepcopy
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location(
'truf_verify', Path(__file__).with_name('verify.py'))
verify = importlib.util.module_from_spec(spec)
spec.loader.exec_module(verify)
class ValidateComposeTests(unittest.TestCase):
def setUp(self):
# Only project/images are needed; skip all host setup and never run main.
self.verifier = object.__new__(verify.Verifier)
self.verifier.project = 'truf-worker-test-' + 'a' * 32
self.verifier.images = {'runtime': 'sha256:' + '1' * 64,
'test': 'sha256:' + '2' * 64}
common = {
'pull_policy': 'never', 'read_only': True, 'network_mode': 'none',
'environment': dict(verify.PROXY_ENV), 'init': False,
'user': '10001:10001', 'cap_drop': ['ALL'],
'security_opt': ['no-new-privileges:true'], 'restart': 'no',
'cpus': 2, 'mem_limit': 6442450944, 'pids_limit': 512,
'shm_size': 268435456, 'stop_signal': 'SIGTERM',
'stop_grace_period': '10m0s',
'logging': {'driver': 'json-file',
'options': {'max-size': '16m', 'max-file': '4'}},
'tmpfs': [target + ':' + options for target, options in verify.TMPFS.items()],
}
data = {'type': 'volume', 'source': 'data', 'target': '/data', 'volume': {}}
tools = {'type': 'volume', 'source': 'tools', 'target': '/opt/truf/tests',
'read_only': True, 'volume': {'nocopy': True}}
services = {name: deepcopy(common)
for name in ('tools', 'provision', 'prepare', 'runtime', 'stopped')}
for name, service in services.items():
service['image'] = self.verifier.images['test' if name == 'tools' else 'runtime']
service['volumes'] = [deepcopy(data)]
# Compose 5 omits false nocopy/read_only; byte sizes are normalized integers.
services['tools'].update(
volumes=[{'type': 'volume', 'source': 'tools',
'target': '/opt/truf/tests', 'volume': {}}],
entrypoint=[*verify.PYTHON, '-c'], command=['pass'])
services['provision'].update(
user='0:0', cap_add=['CHOWN', 'DAC_OVERRIDE', 'FOWNER'],
entrypoint=None, command=['provision'])
services['prepare'].update(
volumes=[deepcopy(data), deepcopy(tools)],
entrypoint=[*verify.PYTHON, verify.DRIVER],
command=['prepare', '--config', verify.CONFIG])
services['runtime'].update(
volumes=[deepcopy(data), deepcopy(tools)], entrypoint=None,
command=['run', '--config', verify.CONFIG],
healthcheck={'test': list(verify.HEALTH), 'interval': '5s',
'timeout': '15s', 'start_period': '4m0s', 'retries': 3})
services['stopped'].update(
volumes=[dict(data, read_only=True, volume={'nocopy': True})],
entrypoint=[*verify.PYTHON, '-c'], command=['pass'])
self.value = {
'name': self.verifier.project, 'services': services,
'volumes': {name: {'name': self.verifier.project + '_' + name, 'driver': 'local'}
for name in ('data', 'tools')},
}
def test_normalized_fixture(self):
self.verifier.validate_compose(self.value)
def test_rejects_production_project_and_volume_names(self):
value = deepcopy(self.value)
value['name'] = 'truf-docker'
with self.assertRaisesRegex(verify.Failure, '^worker_test_project_guard$'):
self.verifier.validate_compose(value)
value = deepcopy(self.value)
value['volumes']['data']['name'] = 'truf-docker_data'
with self.assertRaisesRegex(verify.Failure, '^production_volume_forbidden$'):
self.verifier.validate_compose(value)
def test_rejects_bind_mounts_and_published_ports(self):
value = deepcopy(self.value)
value['services']['runtime']['volumes'][0] = {
'type': 'bind', 'source': r'D:\truf-docker', 'target': '/data',
}
with self.assertRaisesRegex(verify.Failure, '^bind_mount_forbidden$'):
self.verifier.validate_compose(value)
value = deepcopy(self.value)
value['services']['runtime']['ports'] = [{'target': 5432, 'published': '5432'}]
with self.assertRaisesRegex(verify.Failure, '^published_port_contract$'):
self.verifier.validate_compose(value)
def test_rejects_shared_images_and_nonisolated_networks(self):
value = deepcopy(self.value)
value['services']['runtime']['image'] = 'truf-local:runtime'
with self.assertRaisesRegex(verify.Failure, '^worker_test_image_reference_guard$'):
self.verifier.validate_compose(value)
value = deepcopy(self.value)
value['services']['runtime']['network_mode'] = 'bridge'
with self.assertRaisesRegex(verify.Failure, '^internal_network_contract$'):
self.verifier.validate_compose(value)
def test_seed_accepts_false_or_omitted_nocopy(self):
for volume in (None, {}, {'nocopy': False}):
with self.subTest(volume=volume):
value = deepcopy(self.value)
mount = value['services']['tools']['volumes'][0]
if volume is None:
del mount['volume']
else:
mount['volume'] = volume
self.verifier.validate_compose(value)
def test_seed_rejects_nocopy_other_than_false(self):
for nocopy in (True, None, 0, 'false'):
with self.subTest(nocopy=nocopy):
value = deepcopy(self.value)
value['services']['tools']['volumes'][0]['volume']['nocopy'] = nocopy
with self.assertRaisesRegex(verify.Failure, '^tools_copy_up_contract$'):
self.verifier.validate_compose(value)
def test_consumers_require_explicit_true_nocopy(self):
for name in ('prepare', 'runtime'):
for volume in (None, {}, {'nocopy': False}, {'nocopy': 1}, {'nocopy': 'true'}):
with self.subTest(service=name, volume=volume):
value = deepcopy(self.value)
mount = value['services'][name]['volumes'][1]
if volume is None:
del mount['volume']
else:
mount['volume'] = volume
with self.assertRaisesRegex(verify.Failure, '^tools_copy_up_contract$'):
self.verifier.validate_compose(value)
def test_runtime_and_provision_inherit_entrypoint(self):
for name in ('runtime', 'provision'):
for omitted in (False, True):
with self.subTest(service=name, omitted=omitted):
value = deepcopy(self.value)
if omitted:
del value['services'][name]['entrypoint']
self.verifier.validate_compose(value)
def test_runtime_and_provision_reject_entrypoint_overrides(self):
for name, guard in (('runtime', 'production_entrypoint_contract'),
('provision', 'prepare_command_contract')):
for entrypoint in ([], ['/bin/sh', '-c'], ''):
with self.subTest(service=name, entrypoint=entrypoint):
value = deepcopy(self.value)
value['services'][name]['entrypoint'] = entrypoint
with self.assertRaisesRegex(verify.Failure, '^' + guard + '$'):
self.verifier.validate_compose(value)
def test_foreign_snapshot_excludes_only_revalidated_owned_identities(self):
owned_container = 'a' * 64
foreign_container = 'b' * 64
owned_volume = {'name': 'owned', 'identity': 'captured'}
foreign_volume = {'name': 'foreign', 'identity': 'stable'}
current = {'owned': owned_volume, 'foreign': foreign_volume}
verifier = object.__new__(verify.Verifier)
verifier.owned_containers = {owned_container: {'id': owned_container}}
verifier.owned_volumes = {'owned': owned_volume}
verifier.metadata_names = lambda kind: (
{owned_container, foreign_container} if kind == 'container'
else {'owned', 'foreign'}
)
verifier.container_metadata = lambda identifier: {'id': identifier}
verifier.volume_metadata = lambda name: current[name]
inspected = []
verifier.inspect = lambda identifier, label: inspected.append((identifier, label))
snapshot = verifier.metadata_snapshot(exclude_owned=True)
self.assertEqual(snapshot, {
'containers': {foreign_container: {'id': foreign_container}},
'volumes': {'foreign': foreign_volume},
})
self.assertEqual(inspected, [(owned_container, 'foreign_owned_exclusion_guard')])
current['owned'] = {'name': 'owned', 'identity': 'replaced'}
self.assertIn('owned', verifier.metadata_snapshot(exclude_owned=True)['volumes'])
def test_cleanup_uses_exact_resource_operations_and_foreign_guard(self):
source = Path(verify.__file__).read_text(encoding='ascii')
self.assertNotIn("compose_command('down'", source)
self.assertNotIn("'--force-recreate'", source)
self.assertIn("'cleanup_container_remove_guard'", source)
self.assertIn("'cleanup_volume_identity_changed'", source)
self.assertIn('self.assert_foreign_unchanged()', source)
self.assertIn("'failure_stop_ownership_guard'", source)
self.assertIn("'stage': label, 'class': failure_class", source)
self.assertIn("'exit_code': exit_code", source)
if __name__ == '__main__':
unittest.main()
File diff suppressed because it is too large Load Diff
+909
View File
@@ -0,0 +1,909 @@
#!/usr/bin/env python3
"""Run the offline worker E2E against dedicated, already-built local images.
Run from Linux/WSL: python3 docker/verify.py [--keep]
Requires Docker with Compose v2 (directly or via sudo -n docker), Linux named
volumes, and Git. docker/test-results/latest.json must already be gitignored;
this script never changes ignore files. No image builds, pulls, host data
mounts, application edits, production Compose files, or broad cleanup.
Checks have a 3600-second aggregate budget; failure shutdown has a separate
720-second budget. Each health wait is at most 240 seconds, and each stop uses
600 seconds of grace. A forced/nonzero/OOM exit never counts as success.
Failures retain owned Docker artifacts after a guarded stop attempt. --keep
also retains them on success. Evidence contains only counts, hashes, image
IDs, fixed statuses, and durations; command logs are never printed or saved.
Contract limits: prepare is not repaired or rerun after recreation. A stopped
container loses its /run/truf tmpfs, so its shutdown receipt cannot be read
afterward. Receipt-write success is inferred ONLY from the healthy foreground
runtime's zero-exit contract; private PostgreSQL PID-file absence is separately
checked using the same runtime image and a read-only data-volume mount.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import selectors
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
import time
PYTHON = ['/usr/local/bin/python3', '-I', '-S', '-B']
APP = '/opt/truf/app/container_runtime.py'
DRIVER = '/opt/truf/tests/container_e2e.py'
CONFIG = '/data/config/e2e.yaml'
ENTRYPOINT = ['/usr/bin/tini', '--', '/usr/local/bin/python3', '-u', '-I', '-S', '-B', APP]
HEALTH = ['CMD', *PYTHON, APP, 'health', '--config', CONFIG]
SERVICES = {'tools', 'provision', 'prepare', 'runtime', 'stopped'}
IMAGE_REFERENCES = {
'runtime': 'truf-worker-test:runtime',
'test': 'truf-worker-test:test',
}
TMPFS = {
'/run/truf': 'rw,nosuid,nodev,noexec,size=64m,mode=0700,uid=10001,gid=10001',
'/tmp': 'rw,nosuid,nodev,noexec,size=128m,mode=1777',
}
PROXY_ENV = {
name: '*' if name.lower() == 'no_proxy' else ''
for stem in ('http_proxy', 'https_proxy', 'ftp_proxy', 'all_proxy', 'no_proxy')
for name in (stem, stem.upper())
}
MOUNTS = {
'tools': {'/opt/truf/tests': ('tools', False)},
'provision': {'/data': ('data', False)},
'prepare': {'/data': ('data', False), '/opt/truf/tests': ('tools', True)},
'runtime': {'/data': ('data', False), '/opt/truf/tests': ('tools', True)},
'stopped': {'/data': ('data', True)},
}
LABEL = 'com.docker.compose.'
MAX_OUTPUT = 128 * 1024
MAX_DOCKER_RESOURCES = 1024
MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024
HEX = re.compile(r'[a-f0-9]{64}')
CONTAINER_METADATA_FORMAT = (
'{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Image}},'
'"status":{{json .State.Status}},"running":{{json .State.Running}},'
'"paused":{{json .State.Paused}},"restarting":{{json .State.Restarting}},'
'"dead":{{json .State.Dead}},"mounts":{{json .Mounts}}}'
)
VOLUME_METADATA_FORMAT = (
'{"name":{{json .Name}},"driver":{{json .Driver}},"scope":{{json .Scope}},'
'"created":{{json .CreatedAt}},"mountpoint":{{json .Mountpoint}},'
'"labels":{{json .Labels}},"options":{{json .Options}}}'
)
INSPECT_FIELDS = {
'id': '.Id', 'name': '.Name', 'image': '.Image', 'status': '.State.Status',
'running': '.State.Running', 'pid': '.State.Pid',
'exit_code': '.State.ExitCode', 'oom_killed': '.State.OOMKilled',
'restarts': '.RestartCount', 'user': '.Config.User',
'entrypoint': '.Config.Entrypoint', 'command': '.Config.Cmd',
'stop_timeout': '.Config.StopTimeout',
'stop_signal': '.Config.StopSignal', 'mounts': '.Mounts',
'readonly': '.HostConfig.ReadonlyRootfs', 'network': '.HostConfig.NetworkMode',
'cap_drop': '.HostConfig.CapDrop', 'cap_add': '.HostConfig.CapAdd',
'security_opt': '.HostConfig.SecurityOpt', 'init': '.HostConfig.Init',
'privileged': '.HostConfig.Privileged', 'pid_mode': '.HostConfig.PidMode',
'ports': '.HostConfig.PortBindings', 'tmpfs': '.HostConfig.Tmpfs',
'cpus': '.HostConfig.NanoCpus', 'memory': '.HostConfig.Memory',
'pids_limit': '.HostConfig.PidsLimit', 'restart_policy': '.HostConfig.RestartPolicy',
**{key: '(index .Config.Labels "' + LABEL + suffix + '")' for key, suffix in (
('project', 'project'), ('service', 'service'), ('oneoff', 'oneoff'),
('config_files', 'project.config_files'), ('working_dir', 'project.working_dir'),
)},
}
# Do not inspect .State or .Config wholesale: health logs and environments can
# contain credentials. Only these selected fields enter the host process.
INSPECT_FORMAT = '{' + ','.join(
json.dumps(key) + ':{{json ' + value + '}}' for key, value in INSPECT_FIELDS.items()
) + (',"health_test":{{with index .Config "Healthcheck"}}{{json .Test}}{{else}}null{{end}}'
',"health":{{with index .State "Health"}}{{json .Status}}{{else}}null{{end}}}')
class Failure(Exception):
"""Only fixed check labels, never subprocess output or exception messages."""
def require(condition, label):
if not condition:
raise Failure(label)
class Verifier:
def __init__(self):
self.script = Path(__file__).absolute()
self.root = self.script.parent.parent.resolve(strict=True)
self.file = self.root / 'compose.e2e.yaml'
self.project = 'truf-worker-test-' + secrets.token_hex(16)
self.started = time.monotonic()
self.deadline = self.started + 3600
self.docker = []
self.compose = []
self.images = {}
self.owned_containers = {}
self.owned_volumes = {}
self.foreign_baseline = None
self.mutated = False
self.evidence_ready = False
self.file_hashes = {}
self.report = {
'status': {'result': 'running', 'cleanup': 'not_started'},
'counts': {'schema': 1},
'hashes': {'project_sha256': hashlib.sha256(self.project.encode('ascii')).hexdigest()},
'image_ids': self.images, 'durations': {},
}
allowed_env = (
'PATH', 'HOME', 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR', 'SSH_AUTH_SOCK',
'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG', 'DOCKER_TLS_VERIFY',
'DOCKER_CERT_PATH',
)
self.env = {name: os.environ[name] for name in allowed_env if name in os.environ}
self.env['COMPOSE_DISABLE_ENV_FILE'] = '1'
def execute(self, label, args, *, timeout=30, capture=False, check=True):
started = time.monotonic()
end = min(self.deadline, started + timeout)
require(end > started, 'aggregate_timeout')
process = None
output = bytearray()
selector = selectors.DefaultSelector()
counts = self.report['counts'].setdefault(label, {})
counts['cli_calls'] = counts.get('cli_calls', 0) + 1
self.report['status'][label] = 'running'
try:
process = subprocess.Popen(
args, cwd=self.root, env=self.env, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True,
)
if capture:
selector.register(process.stdout, selectors.EVENT_READ)
while process.poll() is None or selector.get_map():
remaining = end - time.monotonic()
require(remaining > 0, label + '_timeout')
if selector.get_map():
for key, _ in selector.select(min(0.1, remaining)):
chunk = os.read(key.fd, 65536)
if not chunk:
selector.unregister(key.fileobj)
else:
output.extend(chunk)
require(len(output) <= MAX_OUTPUT, label + '_output_limit')
else:
time.sleep(min(0.05, remaining))
code = process.returncode
counts['exit_code' if code >= 0 else 'signal'] = abs(code)
self.report['status'][label] = 'passed' if code == 0 else 'failed'
if check and code != 0:
error = Failure(label + '_command_failed')
error.exit_code = code
raise error
return code, bytes(output)
except OSError:
self.report['status'][label] = 'failed'
raise Failure(label + '_unavailable') from None
except BaseException:
self.report['status'][label] = 'failed'
raise
finally:
selector.close()
if process is not None:
if process.poll() is None:
# Terminate only the local CLI process group, not containers.
# An interrupted Docker API operation can outlive its client;
# failure handling discovers and stops owned containers.
try:
os.killpg(process.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
if process.stdout is not None:
process.stdout.close()
durations = self.report['durations']
durations[label] = round(durations.get(label, 0) + time.monotonic() - started, 3)
def json_command(self, label, args, timeout=30):
_, output = self.execute(label, args, timeout=timeout, capture=True)
try:
return json.loads(output)
except (ValueError, UnicodeError):
raise Failure(label + '_invalid_json') from None
def words(self, label, args):
_, output = self.execute(label, [*self.docker, *args], capture=True)
try:
words = output.decode('ascii').split()
except UnicodeError:
raise Failure(label + '_invalid_inventory') from None
require(len(words) <= 16, label + '_inventory_limit')
return set(words)
def metadata_names(self, kind):
options = (['--all', '--no-trunc', '--format', '{{.ID}}']
if kind == 'container' else ['--format', '{{.Name}}'])
_, output = self.execute(
'foreign_' + kind + '_list', [*self.docker, kind, 'ls', *options], capture=True,
)
try:
values = {line for line in output.decode('ascii').splitlines() if line}
except UnicodeError:
raise Failure('foreign_' + kind + '_inventory_encoding') from None
require(len(values) <= MAX_DOCKER_RESOURCES, 'foreign_' + kind + '_inventory_bound')
return values
def container_metadata(self, identifier):
require(HEX.fullmatch(identifier), 'foreign_container_id_guard')
value = self.json_command('foreign_container_inspect', [
*self.docker, 'container', 'inspect', '--format', CONTAINER_METADATA_FORMAT,
identifier,
])
require(
value.get('id') == identifier and isinstance(value.get('name'), str)
and HEX.fullmatch(str(value.get('image') or '').removeprefix('sha256:'))
and value.get('status') in (
'created', 'running', 'paused', 'restarting', 'removing', 'exited', 'dead',
)
and all(type(value.get(key)) is bool for key in (
'running', 'paused', 'restarting', 'dead',
))
and isinstance(value.get('mounts'), list) and len(value['mounts']) <= 128,
'foreign_container_metadata_guard',
)
mounts = [{
key: mount.get(key) for key in (
'Type', 'Name', 'Source', 'Destination', 'Driver', 'Mode', 'RW', 'Propagation',
)
} for mount in value['mounts']]
return {
'id': value['id'], 'name': value['name'], 'image': value['image'],
'status': value['status'], 'running': value['running'], 'paused': value['paused'],
'restarting': value['restarting'], 'dead': value['dead'],
'mounts_sha256': hashlib.sha256(json.dumps(
mounts, ensure_ascii=True, sort_keys=True, separators=(',', ':'),
).encode('ascii')).hexdigest(),
}
def volume_metadata(self, name):
value = self.json_command('foreign_volume_inspect', [
*self.docker, 'volume', 'inspect', '--format', VOLUME_METADATA_FORMAT, name,
])
require(
value.get('name') == name and isinstance(value.get('driver'), str)
and isinstance(value.get('scope'), str) and isinstance(value.get('mountpoint'), str)
and (value.get('created') is None or isinstance(value['created'], str))
and (value.get('labels') is None or isinstance(value['labels'], dict))
and (value.get('options') is None or isinstance(value['options'], dict)),
'foreign_volume_metadata_guard',
)
return {
'name': name, 'driver': value['driver'], 'scope': value['scope'],
'created': value['created'],
'mountpoint_sha256': hashlib.sha256(value['mountpoint'].encode('utf-8')).hexdigest(),
'labels_sha256': hashlib.sha256(json.dumps(
value['labels'], ensure_ascii=True, sort_keys=True, separators=(',', ':'),
).encode('ascii')).hexdigest(),
'options_sha256': hashlib.sha256(json.dumps(
value['options'], ensure_ascii=True, sort_keys=True, separators=(',', ':'),
).encode('ascii')).hexdigest(),
}
def metadata_snapshot(self, *, exclude_owned=False):
containers = {}
for identifier in sorted(self.metadata_names('container')):
if exclude_owned and identifier in self.owned_containers:
self.inspect(identifier, 'foreign_owned_exclusion_guard')
continue
containers[identifier] = self.container_metadata(identifier)
volumes = {}
for name in sorted(self.metadata_names('volume')):
value = self.volume_metadata(name)
if exclude_owned and self.owned_volumes.get(name) == value:
continue
volumes[name] = value
snapshot = {'containers': containers, 'volumes': volumes}
require(len(json.dumps(snapshot, ensure_ascii=True, sort_keys=True)) <= MAX_SNAPSHOT_BYTES,
'foreign_metadata_snapshot_bound')
return snapshot
def snapshot_foreign(self):
require(self.foreign_baseline is None, 'foreign_snapshot_already_taken')
self.foreign_baseline = self.metadata_snapshot()
def assert_foreign_unchanged(self):
require(self.foreign_baseline is not None, 'foreign_snapshot_missing')
require(self.metadata_snapshot(exclude_owned=True) == self.foreign_baseline,
'foreign_docker_state_changed')
self.report['status']['foreign_docker_state'] = 'unchanged'
def guard_files(self):
require(re.fullmatch(r'truf-worker-test-[a-f0-9]{32}', self.project),
'worker_test_project_guard')
require(self.script == self.root / 'docker' / 'verify.py', 'verifier_path_guard')
require(self.file == self.root / 'compose.e2e.yaml', 'compose_path_guard')
for path in (self.script, self.file):
require(path.resolve(strict=True) == path and path.is_file() and not path.is_symlink(),
'canonical_file_required')
require(path.stat().st_size <= 256 * 1024, 'source_file_size_bound')
digest = hashlib.sha256(path.read_bytes()).hexdigest()
require(path not in self.file_hashes or self.file_hashes[path] == digest,
'source_changed_during_verification')
self.file_hashes[path] = digest
def compose_command(self, label, args, **kwargs):
self.guard_files()
return self.execute(label, [*self.compose, *args], **kwargs)
def inspect(self, container, label='inspect', timeout=30):
require(HEX.fullmatch(container), 'container_id_guard')
value = self.json_command(label, [
*self.docker, 'container', 'inspect', '--format', INSPECT_FORMAT, container,
], timeout=timeout)
require(value['id'] == container and value['project'] == self.project
and isinstance(value['name'], str)
and value['name'].startswith('/' + self.project + '-')
and value['service'] in SERVICES
and value['config_files'] == str(self.file)
and value['working_dir'] == str(self.root), 'container_ownership_guard')
service = value['service']
require(value['oneoff'] == ('False' if service == 'runtime' else 'True'),
'container_role_guard')
require(value['image'] == self.images['test' if service == 'tools' else 'runtime'],
'container_image_guard')
require(value['status'] in ('created', 'running', 'paused', 'restarting', 'removing', 'exited', 'dead'),
'container_state_guard')
require(all(type(value[key]) is int and value[key] >= 0 for key in ('exit_code', 'restarts', 'pid'))
and all(type(value[key]) is bool for key in ('oom_killed', 'running')),
'container_state_types_guard')
identity = {key: value[key] for key in (
'id', 'name', 'image', 'project', 'service', 'config_files', 'working_dir',
)}
require(self.owned_containers.setdefault(container, identity) == identity,
'container_identity_changed')
self.report['status']['container_' + service] = value['status']
self.report['counts']['container_' + service] = {
'exit_code': value['exit_code'], 'oom_killed': int(value['oom_killed']),
'restarts': value['restarts'], 'running': int(value['running']),
}
return value
def inventory(self, *, complete=False):
self.guard_files()
found = {}
for kind in ('container', 'volume', 'network'):
options = ['--all', '--quiet', '--no-trunc'] if kind == 'container' else ['--format', '{{.Name}}']
named = self.words('inventory_' + kind, [
kind, 'ls', *options, '--filter', 'name=' + self.project,
])
labeled = self.words('ownership_' + kind, [
kind, 'ls', *options, '--filter', 'label=' + LABEL + 'project=' + self.project,
])
require(named == labeled, 'resource_ownership_guard')
found[kind] = named
require(not found['network'], 'unexpected_project_network')
expected = {self.project + '_data', self.project + '_tools'}
require(found['volume'] <= expected, 'volume_name_guard')
if complete:
require(found['volume'] == expected, 'required_volumes_missing')
for volume in sorted(found['volume']):
value = self.json_command('inspect_volume', [
*self.docker, 'volume', 'inspect', '--format',
'{"name":{{json .Name}},"driver":{{json .Driver}},"options":{{json .Options}},'
'"scope":{{json .Scope}},"project":{{json (index .Labels "com.docker.compose.project")}},'
'"volume":{{json (index .Labels "com.docker.compose.volume")}}}', volume,
])
require(value['name'] == volume and value['project'] == self.project
and value['volume'] in ('data', 'tools')
and volume == self.project + '_' + value['volume']
and value['driver'] == 'local' and not value['options']
and value['scope'] == 'local', 'native_named_volume_guard')
metadata = self.volume_metadata(volume)
require(self.owned_volumes.setdefault(volume, metadata) == metadata,
'volume_identity_changed')
users = self.words('volume_users', [
'container', 'ls', '--all', '--quiet', '--no-trunc', '--filter', 'volume=' + volume,
])
require(users <= found['container'], 'foreign_volume_user_guard')
self.report['counts']['owned_resources'] = {
kind + 's': len(names) for kind, names in found.items()
}
return [self.inspect(container) for container in sorted(found['container'])]
def validate_compose(self, value):
require(value.get('name') == self.project, 'worker_test_project_guard')
require(set(value['services']) == SERVICES and set(value['volumes']) == {'data', 'tools'}
and not any(value.get(key) for key in ('networks', 'secrets', 'configs')),
'compose_project_contract')
for name, volume in value['volumes'].items():
require(volume.get('name') == self.project + '_' + name,
'production_volume_forbidden')
require(
volume.get('name') not in {'truf-docker_data', 'truf-docker_tools'}
and volume.get('driver') == 'local'
and set(volume) <= {'name', 'driver'}, 'compose_volume_contract')
allowed = {
'image', 'pull_policy', 'read_only', 'user', 'cap_drop', 'cap_add', 'security_opt',
'network_mode', 'volumes', 'tmpfs', 'cpus', 'mem_limit', 'pids_limit', 'shm_size',
'stop_signal', 'stop_grace_period', 'logging', 'restart', 'entrypoint', 'command',
'healthcheck', 'environment', 'init', 'ports',
}
for name, service in value['services'].items():
require(set(service) <= allowed, 'compose_service_options_guard')
require(service['image'] == self.images['test' if name == 'tools' else 'runtime'],
'worker_test_image_reference_guard')
require(not service.get('ports'), 'published_port_contract')
require(
service.get('network_mode') == 'none', 'internal_network_contract')
require(
service['image'] == self.images['test' if name == 'tools' else 'runtime']
and service.get('pull_policy') == 'never'
and service.get('read_only') is True
and service.get('environment') == PROXY_ENV
and service.get('init') is False
and service.get('user') == ('0:0' if name == 'provision' else '10001:10001')
and set(service.get('cap_drop', ())) == {'ALL'}
and set(service.get('cap_add', ())) == (
{'CHOWN', 'DAC_OVERRIDE', 'FOWNER'} if name == 'provision' else set())
and service.get('security_opt') == ['no-new-privileges:true']
and service.get('restart') == 'no'
and float(service['cpus']) == 2
and int(service['mem_limit']) == 6 * 1024 ** 3
and int(service['pids_limit']) == 512
and int(service['shm_size']) == 256 * 1024 ** 2
and service['stop_signal'] == 'SIGTERM'
and service['logging'] == {
'driver': 'json-file', 'options': {'max-size': '16m', 'max-file': '4'},
}
and set(service['tmpfs']) == {key + ':' + val for key, val in TMPFS.items()},
'compose_isolation_contract')
mounts = {}
for mount in service['volumes']:
require(mount.get('type') == 'volume', 'bind_mount_forbidden')
require(set(mount) <= {'type', 'source', 'target', 'read_only', 'volume'}
and set(mount.get('volume', {})) <= {'nocopy'}, 'compose_mount_guard')
require(mount['target'] not in mounts, 'duplicate_mount_guard')
mounts[mount['target']] = (mount['source'], mount.get('read_only', False))
if mount['source'] == 'tools':
require(mount.get('volume', {}).get('nocopy', False) is (name != 'tools'),
'tools_copy_up_contract')
require(mounts == MOUNTS[name], 'compose_mount_contract')
runtime = value['services']['runtime']
require(runtime.get('entrypoint') is None and runtime['command'] == ['run', '--config', CONFIG]
and runtime['healthcheck']['test'] == HEALTH, 'production_entrypoint_contract')
require(value['services']['provision'].get('entrypoint') is None
and value['services']['provision']['command'] == ['provision']
and value['services']['prepare']['entrypoint'] == [*PYTHON, DRIVER]
and value['services']['prepare']['command'] == ['prepare', '--config', CONFIG],
'prepare_command_contract')
def preflight(self, env_file):
self.guard_files()
for path, digest in self.file_hashes.items():
self.report['hashes']['compose_sha256' if path == self.file else 'verifier_sha256'] = digest
docker = shutil.which('docker')
require(docker, 'docker_cli_required')
candidates = [[docker]]
sudo = shutil.which('sudo')
if sudo:
candidates.append([sudo, '-n', docker])
for index, candidate in enumerate(candidates):
try:
code, output = self.execute('docker_probe_' + str(index), [
*candidate, 'info', '--format', '{{json .OSType}}',
], timeout=15, capture=True, check=False)
except Failure:
continue
if code == 0:
require(json.loads(output) == 'linux', 'linux_docker_daemon_required')
self.docker = candidate
break
require(self.docker, 'docker_or_passwordless_sudo_required')
self.execute('compose_available', [*self.docker, 'compose', 'version', '--short'])
self.snapshot_foreign()
for name in ('runtime', 'test'):
value = self.json_command('image_' + name, [
*self.docker, 'image', 'inspect', '--format',
'{"id":{{json .Id}},"os":{{json .Os}},"user":{{json .Config.User}},'
'"entrypoint":{{json .Config.Entrypoint}},"volumes":{{json (index .Config "Volumes")}}}',
IMAGE_REFERENCES[name],
])
require(re.fullmatch(r'sha256:[a-f0-9]{64}', value['id'])
and value['os'] == 'linux' and value['user'] == '10001:10001'
and not value['volumes'], 'prebuilt_image_contract')
if name == 'runtime':
require(value['entrypoint'] == ENTRYPOINT, 'runtime_image_entrypoint_contract')
self.images[name] = value['id']
require(self.images['runtime'] != self.images['test'], 'distinct_image_targets_required')
env_file.write('TRUF_WORKER_TEST_PROJECT=' + self.project + '\n'
'TRUF_WORKER_TEST_RUNTIME_IMAGE=' + self.images['runtime'] + '\n'
'TRUF_WORKER_TEST_TEST_IMAGE=' + self.images['test'] + '\n')
env_file.flush()
# An explicit env file works with sudo's environment reset, too. Never
# load the checkout's .env or accept COMPOSE_FILE/PROJECT_NAME overrides.
self.compose = [
*self.docker, 'compose', '--ansi', 'never', '--project-name', self.project,
'--project-directory', str(self.root), '--env-file', env_file.name,
'--file', str(self.file),
]
self.validate_compose(self.json_command('compose_contract', [
*self.compose, 'config', '--format', 'json',
]))
git = shutil.which('git')
require(git, 'git_required_for_evidence_ignore_guard')
code, _ = self.execute('evidence_ignore_guard', [
git, 'check-ignore', '--quiet', '--no-index', '--', 'docker/test-results/latest.json',
], check=False)
require(code == 0, 'evidence_path_must_be_gitignored')
require(not self.inventory(), 'fresh_project_required')
require(not self.words('fresh_volumes', [
'volume', 'ls', '--format', '{{.Name}}', '--filter', 'name=' + self.project,
]), 'fresh_volumes_required')
directory = self.root / 'docker' / 'test-results'
require(not directory.is_symlink(), 'evidence_directory_guard')
directory.mkdir(mode=0o700, exist_ok=True)
require(directory.resolve(strict=True) == directory and directory.is_dir(),
'evidence_directory_guard')
self.evidence_ready = True
def summary(self, label, args, timeout):
print(label + ': running', flush=True)
code, output = self.execute(label, args, timeout=timeout, capture=True, check=False)
try:
value = json.loads(output)
except (ValueError, UnicodeError):
raise Failure(label + '_invalid_summary') from None
require(isinstance(value, dict) and set(value) == {'counts', 'hashes'}
and all(isinstance(value[key], dict) and len(value[key]) <= 128 for key in value),
label + '_invalid_summary')
require(all(re.fullmatch(r'[a-z][a-z0-9_:]{0,120}', key)
and type(count) is int and 0 <= count <= 2 ** 63 - 1
for key, count in value['counts'].items())
and all(re.fullmatch(r'[a-z][a-z0-9_:]{0,120}_sha256', key)
and isinstance(digest, str) and HEX.fullmatch(digest)
for key, digest in value['hashes'].items()), label + '_invalid_summary')
self.report['counts'][label].update(value['counts'])
self.report['hashes'][label] = value['hashes']
require(code == 0 and value['counts'].get('ok') == 1, label + '_check_failed')
return value
def oneoff(self, service, label=None, timeout=180):
self.guard_files()
args = [*self.compose, 'run', '--rm', '--no-deps', '--pull', 'never', '-T', service]
if service == 'provision':
print('provision: running', flush=True)
self.execute('provision', args, timeout=timeout)
return None
return self.summary(label or service, args, timeout)
def start(self, label, previous=None):
if previous:
value = self.inspect(previous, label + '_previous_ownership')
require(value['status'] == 'exited' and not value['running'],
label + '_previous_not_stopped')
self.inspect(previous, label + '_previous_remove_guard')
self.execute(label + '_remove_previous', [
*self.docker, 'container', 'rm', previous,
])
self.compose_command(label, [
'up', '--detach', '--no-deps', '--no-build', '--pull', 'never',
'runtime',
], timeout=120)
containers = self.inventory(complete=True)
runtimes = [value for value in containers if value['service'] == 'runtime']
require(len(runtimes) == 1, 'single_runtime_required')
value = runtimes[0]
container = value['id']
require(container != previous, 'new_runtime_container_required')
require(value['entrypoint'] == ENTRYPOINT and value['command'] == ['run', '--config', CONFIG]
and value['health_test'] == HEALTH and value['user'] == '10001:10001'
and value['readonly'] is True and value['network'] == 'none'
and set(value['cap_drop'] or ()) == {'ALL'} and not value['cap_add']
and value['security_opt'] == ['no-new-privileges:true']
and not value['init'] and not value['privileged'] and not value['pid_mode']
and not value['ports'] and value['tmpfs'] == TMPFS
and value['cpus'] == 2_000_000_000 and value['memory'] == 6 * 1024 ** 3
and value['pids_limit'] == 512 and value['stop_timeout'] == 600
and value['stop_signal'] == 'SIGTERM'
and value['restart_policy'] == {'Name': 'no', 'MaximumRetryCount': 0},
'actual_runtime_isolation_contract')
mounts = {}
for mount in value['mounts']:
if mount['Type'] == 'tmpfs':
require(mount['Destination'] in TMPFS, 'unexpected_tmpfs')
continue
require(mount['Type'] == 'volume' and mount['Destination'] not in mounts,
'actual_named_mount_required')
mounts[mount['Destination']] = (mount['Name'], not mount['RW'])
require(mounts == {target: (self.project + '_' + name, ro)
for target, (name, ro) in MOUNTS['runtime'].items()},
'actual_runtime_mount_contract')
self.report['hashes'][label + '_container_sha256'] = hashlib.sha256(container.encode('ascii')).hexdigest()
health_started = time.monotonic()
health_end = min(self.deadline, health_started + 240)
while time.monotonic() < health_end:
value = self.inspect(container, label + '_health', timeout=min(10, health_end - time.monotonic()))
require(value['running'] and value['status'] == 'running'
and not value['oom_killed'] and value['restarts'] == 0,
label + '_runtime_exited_or_restarted')
if value['health'] == 'healthy':
self.report['durations'][label + '_ready'] = round(time.monotonic() - health_started, 3)
return container
time.sleep(min(2, max(0, health_end - time.monotonic())))
raise Failure(label + '_health_timeout')
def driver(self, container, mode, label, timeout=240):
self.inspect(container)
return self.summary(label, [
*self.docker, 'exec', '--user', '10001:10001', container, *PYTHON,
DRIVER, mode, '--config', CONFIG, '--timeout', '180',
], timeout)
def health(self, container, label):
self.inspect(container)
value = self.json_command(label, [
*self.docker, 'exec', '--user', '10001:10001', container, *PYTHON,
APP, 'health', '--config', CONFIG,
], timeout=30)
require(value == {
'healthy': True, 'activation_state': 'ACTIVE', 'postgres': 'READY',
'workers': ['gitlab', 'janitor', 'jsonl-projector', 'result-ingester'],
}, label + '_authenticated_health_failed')
self.report['counts'][label]['authenticated_health'] = 1
def stop(self, container, label):
containers = self.inventory(complete=True)
require(any(value['id'] == container and value['running'] for value in containers),
label + '_runtime_not_running')
print(label + ': stopping (600-second grace)', flush=True)
self.inspect(container, label + '_ownership_guard')
self.execute(label, [
*self.docker, 'container', 'stop', '--time', '600', container,
], timeout=660)
value = self.inspect(container, label + '_inspect')
self.report['counts'][label].update({
'container_exit_code': value['exit_code'], 'oom_killed': int(value['oom_killed']),
'restarts': value['restarts'],
})
require(value['status'] == 'exited' and not value['running'] and value['pid'] == 0
and value['exit_code'] == 0 and value['oom_killed'] is False
and value['restarts'] == 0, label + '_unclean_exit')
self.oneoff('stopped', label + '_data')
# Foreground supervisor.main returns zero only after receipt publication.
# Do not claim to have read that receipt from a destroyed tmpfs.
self.report['status'][label + '_receipt'] = 'exit_contract_only_tmpfs_removed'
def cleanup(self, keep):
containers = self.inventory(complete=True)
require(len(containers) == 1 and containers[0]['service'] == 'runtime'
and containers[0]['status'] == 'exited' and containers[0]['exit_code'] == 0
and not containers[0]['oom_killed'], 'cleanup_stopped_runtime_guard')
if keep:
self.assert_foreign_unchanged()
self.report['status']['cleanup'] = 'kept'
return
self.report['status']['cleanup'] = 'running'
container = containers[0]['id']
self.inspect(container, 'cleanup_container_remove_guard')
self.execute('remove_owned_container', [*self.docker, 'container', 'rm', container])
for volume in sorted(self.owned_volumes):
require(self.volume_metadata(volume) == self.owned_volumes[volume],
'cleanup_volume_identity_changed')
self.execute('remove_owned_volume', [*self.docker, 'volume', 'rm', volume])
require(not self.inventory(), 'cleanup_containers_remaining')
require(not self.words('cleanup_volumes', [
'volume', 'ls', '--format', '{{.Name}}', '--filter', 'name=' + self.project,
]), 'cleanup_volumes_remaining')
self.assert_foreign_unchanged()
self.report['status']['cleanup'] = 'removed'
def failure_stop(self):
self.deadline = time.monotonic() + 720
self.report['status']['cleanup'] = 'retained_after_failure'
try:
containers = self.inventory()
active = [value for value in containers
if value['running'] or value['status'] in ('restarting', 'paused')]
if active:
print('failure_stop: stopping owned containers (600-second grace)', flush=True)
for value in active:
container = value['id']
self.inspect(container, 'failure_stop_ownership_guard')
self.execute('failure_stop', [
*self.docker, 'container', 'stop', '--time', '600', container,
], timeout=660)
remaining = self.inventory()
self.report['counts']['failure_retained'] = {
'containers': len(remaining), 'running': sum(int(value['running']) for value in remaining),
}
self.report['status']['failure_stop'] = (
'incomplete' if any(value['running'] for value in remaining) else 'observed_stopped'
)
except (Exception, KeyboardInterrupt):
# Loss of the daemon, changed files, or unproven ownership must never
# lead to an unguarded down/prune/kill attempt or a false success.
self.report['status']['failure_stop'] = 'failed_or_ownership_unproven'
def write_evidence(self):
self.report['durations']['total'] = round(time.monotonic() - self.started, 3)
directory = self.root / 'docker' / 'test-results'
require(directory.resolve(strict=True) == directory, 'evidence_directory_guard')
descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
temporary = '.' + self.project + '.json'
try:
try:
details = os.stat('latest.json', dir_fd=descriptor, follow_symlinks=False)
require(stat.S_ISREG(details.st_mode), 'evidence_file_guard')
except FileNotFoundError:
pass
output = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600, dir_fd=descriptor)
with os.fdopen(output, 'w', encoding='ascii') as handle:
json.dump(self.report, handle, sort_keys=True, indent=2, allow_nan=False)
handle.write('\n')
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, 'latest.json', src_dir_fd=descriptor, dst_dir_fd=descriptor)
os.fsync(descriptor)
finally:
try:
os.unlink(temporary, dir_fd=descriptor)
except FileNotFoundError:
pass
os.close(descriptor)
def main(argv=None):
class Parser(argparse.ArgumentParser):
def error(self, message):
raise Failure('invalid_arguments')
parser = Parser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, allow_abbrev=False)
parser.add_argument('--keep', action='store_true', help='retain stopped, owned Docker artifacts on success too')
args = parser.parse_args(argv)
require(sys.platform == 'linux', 'linux_host_required_use_wsl_python3')
verifier = Verifier()
print('E2E project: ' + verifier.project, flush=True)
def interrupted(_signum, _frame):
raise KeyboardInterrupt
previous = signal.signal(signal.SIGTERM, interrupted)
# This private file contains only the project nonce and image IDs.
with tempfile.NamedTemporaryFile(mode='w', encoding='ascii', prefix=verifier.project + '-', suffix='.env') as env_file:
try:
verifier.preflight(env_file)
verifier.write_evidence()
verifier.mutated = True
verifier.oneoff('tools')
verifier.oneoff('provision')
verifier.oneoff('prepare')
first = verifier.start('first_start')
verifier.driver(first, 'run-local-pipeline', 'local_pipeline', timeout=240)
verifier.driver(first, 'assert-pipeline', 'pipeline')
baseline = verifier.driver(first, 'keycheck-fixture', 'first_keycheck', timeout=510)
require(baseline['counts'].pop('http_requests', None) == 1, 'first_provider_request_count')
verifier.health(first, 'first_authenticated_health')
verifier.stop(first, 'first_stop')
second = verifier.start('second_start', previous=first)
persisted = verifier.driver(second, 'assert-persisted', 'persisted')
require(persisted == baseline, 'persisted_public_summary_changed')
checked = verifier.driver(second, 'keycheck-fixture', 'second_keycheck', timeout=510)
require(checked['counts'].pop('http_requests', None) == 0, 'repeated_provider_made_http_requests')
require(checked == baseline, 'repeated_provider_changed_public_summary')
verifier.health(second, 'second_authenticated_health')
verifier.driver(second, 'assert-remote-recovery', 'remote_recovery')
verifier.driver(second, 'prepare-remote-transport', 'remote_transport')
dockerhub_canary_prepare = verifier.driver(
second, 'prepare-dockerhub-canary',
'dockerhub_canary_prepare', timeout=510,
)
require(
dockerhub_canary_prepare['counts'].get('search_pages') == 1
and dockerhub_canary_prepare['counts'].get('cohort_targets') == 4
and dockerhub_canary_prepare['counts'].get('digest_resolutions') == 4
and dockerhub_canary_prepare['counts'].get('worker_provider_failures') == 2
and dockerhub_canary_prepare['counts'].get('accepted_uploads') == 3
and dockerhub_canary_prepare['counts'].get('expired_assignments') == 1
and dockerhub_canary_prepare['counts'].get('drain_cycles') == 2
and dockerhub_canary_prepare['counts'].get('pending_targets') == 1,
'dockerhub_canary_prepare_evidence',
)
remote_full_prepare = verifier.driver(
second, 'prepare-remote-full-race', 'remote_full_prepare', timeout=510,
)
require(remote_full_prepare['counts'].get('real_claims') == 2
and remote_full_prepare['counts'].get('native_scans') == 3
and remote_full_prepare['counts'].get('exact_expiries') == 1
and remote_full_prepare['counts'].get('pending_restart') == 1,
'remote_full_prepare_evidence')
verifier.stop(second, 'second_stop')
third = verifier.start('third_start', previous=second)
remote_full_finish = verifier.driver(
third, 'finish-remote-full-race', 'remote_full_finish', timeout=510,
)
require(remote_full_finish['counts'].get('concurrent_uploads') == 2
and remote_full_finish['counts'].get('authoritative_receipts') == 1
and remote_full_finish['counts'].get('authoritative_scans') == 1
and remote_full_finish['counts'].get('expired_losers') == 1
and remote_full_finish['counts'].get('completed_winners') == 1
and remote_full_finish['counts'].get('cached_keychecks') == 1
and remote_full_finish['counts'].get('provider_http_requests') == 0
and remote_full_finish['counts'].get('projection_streams') == 3,
'remote_full_finish_evidence')
verifier.driver(
third, 'assert-remote-transport-replay', 'remote_transport_replay',
)
dockerhub_canary_finish = verifier.driver(
third, 'finish-dockerhub-canary',
'dockerhub_canary_finish', timeout=510,
)
require(
dockerhub_canary_finish['counts'].get('runtime_restarts') == 1
and dockerhub_canary_finish['counts'].get('lost_claim_receipts') == 1
and dockerhub_canary_finish['counts'].get('receipt_replays') == 1
and dockerhub_canary_finish['counts'].get('conflicts_rejected') == 1
and dockerhub_canary_finish['counts'].get('exactly_once') == 1
and dockerhub_canary_finish['counts'].get('former_expiry_replays') == 1,
'dockerhub_canary_finish_evidence',
)
verifier.health(third, 'third_authenticated_health')
verifier.stop(third, 'third_stop')
verifier.report['status']['checks'] = 'passed'
# Persist the check results before deleting their Docker artifacts.
verifier.write_evidence()
verifier.cleanup(args.keep)
verifier.report['status']['result'] = 'passed'
except (Exception, KeyboardInterrupt) as exc:
verifier.report['status']['result'] = 'failed'
label = str(exc) if isinstance(exc, Failure) else (
'interrupted' if isinstance(exc, KeyboardInterrupt) else 'verifier_exception'
)
failure_class = type(exc).__name__
if not re.fullmatch(r'[a-z0-9_]{1,160}', label):
label = 'verifier_failure'
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_]{0,79}', failure_class):
failure_class = 'Exception'
exit_code = getattr(exc, 'exit_code', None)
if type(exit_code) is not int or not -(2 ** 31) <= exit_code < 2 ** 31:
exit_code = None
verifier.report['failure'] = {
'stage': label, 'class': failure_class,
'exit_code': exit_code,
}
print('E2E failed: ' + label, flush=True)
if verifier.mutated:
verifier.failure_stop()
finally:
signal.signal(signal.SIGTERM, previous)
if verifier.evidence_ready:
try:
verifier.write_evidence()
except (Exception, KeyboardInterrupt):
verifier.report['status']['result'] = 'failed'
print('E2E failed: evidence_write_failed', flush=True)
else:
print('Evidence: docker/test-results/latest.json', flush=True)
print('E2E ' + verifier.report['status']['result'] + '; project ' + verifier.project
+ '; artifacts ' + verifier.report['status']['cleanup'], flush=True)
return 0 if verifier.report['status']['result'] == 'passed' else 1
if __name__ == '__main__':
try:
raise SystemExit(main())
except (Exception, KeyboardInterrupt) as exc:
print('E2E failed: ' + (str(exc) if isinstance(exc, Failure) else 'verifier_exception'), flush=True)
raise SystemExit(1) from None
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+880
View File
@@ -0,0 +1,880 @@
"""Offline Windows export, never a supervisor launcher or a source migrator.
Run only after the canonical Windows supervisor stop:
python -I -S -B docker/windows_snapshot.py capture --output D:\\truf-docker\\docker\\imports\\NAME
Output is numeric: phase, count, bytes; failures are phase, exit code. Phases:
1 arguments, 2 source loading, 3 private output, 4 offline inventory, 5 maintenance
start, 6 database, 7 confirmed stop (count = retries), 8 tar, 9 revalidation,
10 publication. Exit 1 is a guarded failure; 124 is a client timeout.
An unconfirmed maintenance stop deliberately RETAINS authority and retries. Do
not terminate this process to bypass that guard. There is no valid manifest
until cleanup confirms STOPPED. Killing Windows/processes can defeat any lock.
SIGINT/SIGBREAK only request cancellation while capture owns the source. They
cannot unwind the original backend's spawned-but-not-yet-bookkept launch window.
Assumes intact original helper APIs/layout and existing credentials permitted to
dump all data, read pg_control_system(), and observe all client sessions.
"""
import argparse
import contextlib
import ctypes
from dataclasses import dataclass
from datetime import datetime, timezone
import fnmatch
import hashlib
import importlib
import importlib.util
import json
import ntpath
import os
from pathlib import Path, PureWindowsPath
import re
import shutil
import signal
import stat
import subprocess
import sys
import tarfile
import threading
import time
from types import SimpleNamespace
from urllib.parse import unquote, urlsplit
SOURCE_ROOT = Path(r'D:\truf')
POSTGRES_DATA = Path(r'S:\postgres-data')
BUNDLE_ROOT = Path(r'S:\scanner-result-bundles')
IMPORTS_ROOT = Path(r'D:\truf-docker\docker\imports')
KEYCHECK_COPY = 'keychecks \u2014 \u043a\u043e\u043f\u0438\u044f'
BLOCK = 1024 * 1024
QUERY_TIMEOUT = 30
COUNT_TIMEOUT = 1800
DUMP_TIMEOUT = 6 * 3600
SESSION_TIMEOUT = 12 * 3600
MAX_METADATA = 4 * BLOCK
ACTIVE_DIRS = ('queues', 'state', 'keychecks', 'results', 'postman_cache', 'result_spool')
EXCLUSION_POLICY = [
'Only explicitly reviewed source roots and mappings are selected.',
'No physical PGDATA/WAL, PostgreSQL binaries/logs, or active control authority.',
'No runtime/downloads, git, traces, freeze-diagnostics, or S: scanner-work.',
'No gharchive_cache, .git, .opencode, tests, code caches, *.lock*, or *.pid.',
'Ordinary *.log* excluded outside result/keycheck projections; scan_errors.log* retained.',
'Windows scratch databases, temporary state and janitor cursor are archival only.',
]
class Failure(Exception):
def __init__(self, code=1):
self.code = code
super().__init__(code)
@contextlib.contextmanager
def _defer_signals():
pending = False
previous = {}
def request(_number, _frame):
nonlocal pending
pending = True
def checkpoint():
if pending:
raise Failure()
try:
for number in (signal.SIGINT, getattr(signal, 'SIGBREAK', None)):
if number is not None:
previous[number] = signal.signal(number, request)
yield checkpoint
finally:
for number, handler in previous.items():
signal.signal(number, handler)
@dataclass(frozen=True)
class File:
source: Path
size: int
fingerprint: tuple
@dataclass
class Inventory:
files: dict
directories: dict
exclusions: dict
def _fingerprint(info):
return (info.st_dev, info.st_ino, info.st_mode, info.st_nlink, info.st_size,
info.st_mtime_ns, info.st_ctime_ns, getattr(info, 'st_file_attributes', 0))
def _check_type(info, directory=False):
if (getattr(info, 'st_file_attributes', 0) & 0x400
or stat.S_ISLNK(info.st_mode)
or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode))
or (not directory and info.st_nlink != 1)):
raise Failure()
def _check_chain(path):
# Inspect ancestors first: even lstat(child) otherwise traverses a junction.
path = Path(path).absolute()
for part in (*reversed(path.parents), path):
info = part.lstat()
if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400:
raise Failure()
def _file_info(path):
_check_chain(path)
info = path.lstat()
_check_type(info)
return info
def _same_windows_path(left, right):
return ntpath.normcase(ntpath.normpath(str(left))) == ntpath.normcase(ntpath.normpath(str(right)))
def _output_path(value):
path = PureWindowsPath(value)
name = path.name
if (not path.is_absolute() or not _same_windows_path(path.parent, IMPORTS_ROOT)
or any(part in ('.', '..') for part in re.split(r'[\\/]', value))
or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,95}', name)
or name.endswith('.')
or re.fullmatch(r'CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]', name.split('.')[0], re.I)):
raise Failure()
return Path(str(path))
def _verify_private_acl(path, security, directory):
security.reject_reparse_components(str(path))
sddl = security._windows_private_sddl(str(path)).upper()
alias = lambda sid: 'SY' if sid == 'S-1-5-18' else sid
sid = alias(security._windows_current_user_sid().upper())
owner = re.search(r'O:([^:()]+?)(?=[GDS]:|$)', sddl)
aces = [ace.split(';') for ace in re.findall(r'\(([^()]*)\)', sddl)]
expected = {sid, 'SY'}
if (not owner or alias(owner.group(1)) != sid or 'D:P' not in sddl
or len(aces) != len(expected)):
raise Failure()
trustees = set()
for ace in aces:
if (len(ace) != 6 or ace[:5] != ['A', 'OICI' if directory else '', 'FA', '', '']):
raise Failure()
trustees.add(alias(ace[5]))
if trustees != expected:
raise Failure()
def _secure_path(path, security, directory=False):
# The original public helper includes BA. Narrow its verified, empty path
# using the same no-reparse Win32 primitives, before any sensitive write.
harden = security.harden_private_directory if directory else security.harden_private_file
harden(str(path))
sid = security._windows_current_user_sid()
trustees = ('SY',) if sid.upper() == 'S-1-5-18' else (sid, 'SY')
flags = 'OICI' if directory else ''
sddl = 'D:P' + ''.join(f'(A;{flags};FA;;;{trustee})' for trustee in trustees)
descriptor = ctypes.c_void_p()
if not security._CONVERT_SDDL(sddl, 1, ctypes.byref(descriptor), None):
raise Failure()
handle = None
try:
handle = security._CREATE_FILE(str(path), 0x60000, 7, None, 3, 0x2200000, None)
if handle == ctypes.c_void_p(-1).value:
raise Failure()
info = security._BY_HANDLE_FILE_INFORMATION()
if (not security._GET_FILE_INFORMATION(handle, ctypes.byref(info))
or info.dwFileAttributes & 0x400
or not security._SET_KERNEL_OBJECT_SECURITY(handle, 0x80000004, descriptor)):
raise Failure()
finally:
if handle is not None and handle != ctypes.c_void_p(-1).value:
security._CLOSE_HANDLE(handle)
security._LOCAL_FREE(descriptor)
_verify_private_acl(path, security, directory)
def _prepare_output(output, security):
if output.parent != IMPORTS_ROOT:
raise Failure()
_check_chain(IMPORTS_ROOT.parent)
_check_type(IMPORTS_ROOT.parent.lstat(), directory=True)
try:
IMPORTS_ROOT.mkdir()
except FileExistsError:
_check_chain(IMPORTS_ROOT)
_check_type(IMPORTS_ROOT.lstat(), directory=True)
else:
_secure_path(IMPORTS_ROOT, security, directory=True)
output.mkdir() # Never reuse, overwrite or repair an existing snapshot.
_secure_path(output, security, directory=True)
@contextlib.contextmanager
def _output_file(path, security):
_check_chain(path.parent)
with path.open('xb', buffering=0) as handle:
_secure_path(path, security)
info = _file_info(path)
opened = os.fstat(handle.fileno())
if (info.st_dev, info.st_ino) != (opened.st_dev, opened.st_ino):
raise Failure()
yield handle
handle.flush()
os.fsync(handle.fileno())
def _destination(path, used, directories):
parts = path.split('/')
if (not parts or any(not p or p in ('.', '..') for p in parts)
or any(ord(c) < 32 or ord(c) == 127 for c in path)
or '\\' in path or ':' in path):
raise Failure()
folded = path.casefold()
parents = {'/'.join(parts[:i]).casefold() for i in range(1, len(parts))}
if folded in used or folded in directories or parents.intersection(used):
raise Failure()
used.add(folded)
directories.update(parents)
def _excluded(relative, control=False):
parts = relative.casefold().split('/')
name = parts[-1]
if any(p in {'.git', '.opencode', 'tests', '__pycache__', '.pytest_cache',
'.mypy_cache', '.ruff_cache', 'node_modules'} for p in parts):
return 'code_cache_or_unreviewed_code'
if 'gharchive_cache' in parts:
return 'downloaded_archives'
if any(fnmatch.fnmatchcase(p, '*.lock*') or p.endswith('.pid') for p in parts):
return 'locks_or_pids'
if name.endswith(('.pyc', '.pyo')):
return 'code_cache_or_unreviewed_code'
if control and any(re.search(
r'supervisor|instance|token|authority|manifest|handshake|capability|(?:^|[._-])(?:pid|lock)(?:[._-]|$)',
p) for p in parts[2:]):
return 'control_authority'
projection = any(p in ('results', 'keychecks', KEYCHECK_COPY.casefold())
or p.startswith(('found_secrets.jsonl', 'scan_results.jsonl', 'scan_errors.log')) for p in parts)
config_backup = len(parts) == 2 and parts[0] == 'app' and name.startswith(('config.yaml.', 'secrets.yaml.'))
if (fnmatch.fnmatchcase(name, '*.log*') and not projection
and not config_backup
and not fnmatch.fnmatchcase(name, 'scan_errors.log*')
and 'publication-ledger.sqlite3' not in name):
return 'ordinary_logs'
return None
def _inventory():
files, watched, excluded, used, parents = {}, {}, {}, set(), set()
def visit(path, relative, target, control=False, expect_directory=None):
reason = _excluded(relative, control)
if reason:
excluded[reason] = excluded.get(reason, 0) + 1
return
try:
_check_chain(path)
info = path.lstat()
except FileNotFoundError:
watched[str(path)] = None
return
directory = stat.S_ISDIR(info.st_mode)
_check_type(info, directory=directory)
if expect_directory is not None and directory != expect_directory:
raise Failure()
if directory:
watched[str(path)] = _fingerprint(info)
with os.scandir(path) as entries:
names = sorted(entry.name for entry in entries)
for name in names:
visit(path / name, relative + '/' + name, target + '/' + name, control)
if _fingerprint(path.lstat()) != watched[str(path)]:
raise Failure()
return
if control and not path.name.casefold().endswith('.json'):
excluded['non_report_control'] = excluded.get('non_report_control', 0) + 1
return
if relative.casefold().startswith('runtime/state/'):
scratch = relative.casefold().split('/')[2:]
if any(fnmatch.fnmatchcase(p, 'scan_limiter*.db*')
or fnmatch.fnmatchcase(p, '*.tmp*') or p == 'janitor.cursor.json' for p in scratch):
target = 'windows-archive/' + relative
_destination(target, used, parents)
files[target] = File(path, info.st_size, _fingerprint(info))
for name in ACTIVE_DIRS:
visit(SOURCE_ROOT / 'runtime' / name, 'runtime/' + name, 'runtime-linux/' + name,
expect_directory=True)
visit(SOURCE_ROOT / 'runtime/proxy.txt', 'runtime/proxy.txt', 'runtime-linux/proxy.txt',
expect_directory=False)
for name in ('secrets.yaml', 'trufflehog-custom-detectors.yaml'):
visit(SOURCE_ROOT / 'app' / name, 'app/' + name, 'config/' + name, expect_directory=False)
for relative in ('state', 'runtime/backups', 'runtime/imports', 'runtime/' + KEYCHECK_COPY):
visit(SOURCE_ROOT / relative, relative, 'windows-archive/' + relative, expect_directory=True)
for relative in (
'app/config.yaml', 'app/.streamlit/config.toml', '.env.postgres', 'docker-compose.postgres.yml',
'runner_state.json',
'runtime/keychecks.7z', 'runtime/orkey.txt', 'runtime/check-openrouter-keys.ps1',
):
visit(SOURCE_ROOT / relative, relative, 'windows-archive/' + relative, expect_directory=False)
for folder, patterns in (
('', ('checked_*.txt', 'todo_*.txt', 'scanner.db*', 'found_secrets.jsonl*',
'scan_results.jsonl*', 'scan_errors.log*', '*.publication-ledger.sqlite3*')),
('app', ('config.yaml.*', 'secrets.yaml.*', 'scanner.db*')),
('runtime', ('*.md',)),
):
path = SOURCE_ROOT / folder
_check_chain(path)
info = path.lstat()
_check_type(info, directory=True)
watched[str(path)] = _fingerprint(info)
with os.scandir(path) as entries:
names = sorted(entry.name for entry in entries
if any(fnmatch.fnmatchcase(entry.name.casefold(), p) for p in patterns))
for name in names:
relative = folder + '/' + name if folder else name
projection_family = not folder and name.casefold().startswith(
('found_secrets.jsonl', 'scan_results.jsonl', 'scan_errors.log'))
visit(path / name, relative, 'windows-archive/' + relative,
expect_directory=None if projection_family else False)
visit(SOURCE_ROOT / 'runtime/control', 'runtime/control', 'windows-archive/runtime/control',
control=True, expect_directory=True)
_check_chain(BUNDLE_ROOT)
visit(BUNDLE_ROOT, 'scanner-result-bundles', 'scanner-result-bundles', expect_directory=True)
return Inventory(files, watched, excluded)
class HashWriter:
def __init__(self, handle):
self.handle = handle
self.digest = hashlib.sha256()
self.size = 0
def write(self, block):
if self.handle.write(block) != len(block):
raise Failure()
self.digest.update(block)
self.size += len(block)
return len(block)
def metadata(self):
return {'bytes': self.size, 'sha256': self.digest.hexdigest()}
class HashReader:
def __init__(self, handle):
self.handle = handle
self.digest = hashlib.sha256()
self.size = 0
def read(self, size):
block = self.handle.read(size)
self.digest.update(block)
self.size += len(block)
return block
def _write_tar(output, security, inventory, report):
manifest_files = []
with _output_file(output / 'files.tar', security) as handle:
writer = HashWriter(handle)
with tarfile.open(fileobj=writer, mode='w|', format=tarfile.PAX_FORMAT, copybufsize=BLOCK) as archive:
for name, entry in sorted(inventory.files.items()):
if _fingerprint(_file_info(entry.source)) != entry.fingerprint:
raise Failure()
with entry.source.open('rb', buffering=0) as source:
before = _fingerprint(os.fstat(source.fileno()))
# Windows Python 3.12 stat/fstat use different ctime bases.
# Compare ctime within each API, not across the two APIs.
if before[:6] + before[7:] != entry.fingerprint[:6] + entry.fingerprint[7:]:
raise Failure()
reader = HashReader(source)
info = tarfile.TarInfo(name)
info.size, info.mode, info.mtime = entry.size, 0o600, 0
archive.addfile(info, reader)
if (reader.size != entry.size
or _fingerprint(os.fstat(source.fileno())) != before):
raise Failure()
if _fingerprint(_file_info(entry.source)) != entry.fingerprint:
raise Failure()
manifest_files.append({'path': name, 'size': entry.size, 'sha256': reader.digest.hexdigest()})
report(8, len(manifest_files), writer.size)
metadata = writer.metadata()
return manifest_files, metadata
@contextlib.contextmanager
def _silence():
with open(os.devnull, 'w', encoding='utf-8') as sink:
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
yield
def _load_source():
app = SOURCE_ROOT / 'app'
for name in ('child_bootstrap.py', 'postgres_runtime.py', 'runtime_security.py'):
_file_info(app / name)
spec = importlib.util.spec_from_file_location('_snapshot_child_bootstrap', app / 'child_bootstrap.py')
bootstrap = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bootstrap)
bootstrap._enable_dependency_paths('postgres-runtime')
sys.path.insert(0, str(app))
pg = importlib.import_module('postgres_runtime')
security = importlib.import_module('runtime_security')
for module in (pg, security):
if not _same_windows_path(module.__file__, app / (module.__name__ + '.py')):
raise Failure()
# The original loader does not overwrite inherited credentials. Remove all
# connection/path overrides first, so only the original .env can choose them.
for key in list(os.environ):
if key.upper().startswith(('PG', 'TRUF_', 'SCANNER_', 'TRUFFLEHOG_')) or key.upper() == 'DATABASE_URL':
os.environ.pop(key, None)
config_path, env_path = app / 'config.yaml', SOURCE_ROOT / '.env.postgres'
inputs = {p: _fingerprint(_file_info(p)) for p in (config_path, env_path)}
config = pg._load_config(str(config_path))
expected = {'root_dir': SOURCE_ROOT, 'project_dir': app, 'runtime_dir': SOURCE_ROOT / 'runtime',
'postgres_data_dir': POSTGRES_DATA, 'result_bundle_dir': BUNDLE_ROOT}
for key, path in expected.items():
if not _same_windows_path(config.get('global', {}).get(key, ''), path):
raise Failure()
for key, name in (('queue_dir', 'queues'), ('state_dir', 'state'), ('keycheck_dir', 'keychecks'),
('results_dir', 'results'), ('postman_cache_dir', 'postman_cache'),
('result_spool_dir', 'result_spool'), ('control_dir', 'control'), ('log_dir', 'logs')):
value = config.get('global', {}).get(key)
if value and not _same_windows_path(value, SOURCE_ROOT / 'runtime' / name):
raise Failure()
paths = pg.postgres_runtime_paths(config)
if (not _same_windows_path(paths['data_dir'], POSTGRES_DATA)
or not _same_windows_path(paths['postgres_dir'], SOURCE_ROOT / 'runtime/postgres')):
raise Failure()
security.preflight_lifecycle_paths(str(config_path), config)
loaded = pg.load_postgres_environment(str(config_path), config)
if not _same_windows_path(loaded or '', env_path):
raise Failure()
dsn = pg.canonical_database_url()
if not dsn:
raise Failure()
identity_path = SOURCE_ROOT / 'runtime/postgres/cluster_identity.json'
inputs[identity_path] = _fingerprint(_file_info(identity_path))
return SimpleNamespace(pg=pg, security=security, config=config, dsn=dsn, inputs=inputs)
def _supervisor_absent(source):
supervisor = source.config.get('supervisor', {})
paths = {SOURCE_ROOT / 'runtime/control/supervisor.instance.json',
SOURCE_ROOT / 'runtime/logs/supervisor.instance.json',
SOURCE_ROOT / 'runtime/logs/supervisor.pid'}
for key in ('instance_file',):
if supervisor.get(key):
paths.add(Path(supervisor[key]))
for key in ('control_dir', 'log_dir'):
if supervisor.get(key):
paths.add(Path(supervisor[key]) / 'supervisor.instance.json')
if any(os.path.lexists(path) for path in paths):
raise Failure()
def _validate_identity(source, identity):
values = source.pg.configured_cluster_values()
parsed = urlsplit(source.dsn)
if (identity['pg_major'] != 16 or not str(identity['system_identifier']).isdigit()
or not _same_windows_path(identity['data_directory'], POSTGRES_DATA)
or any(identity[k] != values[k] for k in ('database', 'user', 'port'))
or parsed.scheme not in ('postgresql', 'postgres') or parsed.hostname != '127.0.0.1'
or parsed.port != identity['port'] or unquote(parsed.username or '') != identity['user']
or unquote(parsed.path[1:]) != identity['database'] or parsed.password is None
or parsed.query or parsed.fragment):
raise Failure()
def _client_environment(dsn):
parsed = urlsplit(dsn)
env = {key: value for key, value in os.environ.items()
if key.upper() in {'SYSTEMROOT', 'WINDIR', 'SYSTEMDRIVE', 'TEMP', 'TMP'}}
env.update(PGHOST='127.0.0.1', PGHOSTADDR='127.0.0.1', PGPORT=str(parsed.port),
PGDATABASE=unquote(parsed.path[1:]), PGUSER=unquote(parsed.username or ''),
PGPASSWORD=unquote(parsed.password or ''), PGPASSFILE=os.devnull,
PGSERVICEFILE=os.devnull, PGSSLMODE='disable', PGGSSENCMODE='disable',
PGCONNECT_TIMEOUT='5', PGCLIENTENCODING='UTF8', PGAPPNAME='truf-windows-snapshot',
PGOPTIONS=f'-c default_transaction_read_only=on -c statement_timeout={COUNT_TIMEOUT * 1000} '
'-c lock_timeout=10000 -c idle_in_transaction_session_timeout=0 '
'-c search_path=pg_catalog -c row_security=off',
LC_ALL='C', LANG='C')
return env
@contextlib.contextmanager
def _deadline(process, seconds):
expired = threading.Event()
def expire():
expired.set()
try:
process.kill()
except OSError:
pass
timer = threading.Timer(seconds, expire)
timer.daemon = True
timer.start()
try:
yield
except BaseException:
if expired.is_set():
raise Failure(124) from None
raise
else:
if expired.is_set():
raise Failure(124)
finally:
timer.cancel()
timer.join()
@contextlib.contextmanager
def _client(command, env, timeout, interactive=False):
process = subprocess.Popen(command, stdin=subprocess.PIPE if interactive else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env,
creationflags=0x08000000, close_fds=True, bufsize=0)
try:
with _deadline(process, timeout):
yield process
if process.stdin is not None:
process.stdin.close()
if process.wait(timeout=10) != 0:
raise Failure()
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=10)
if process.stdin is not None:
process.stdin.close()
process.stdout.close()
def _query(process, sql, timeout=QUERY_TIMEOUT):
with _deadline(process, timeout):
payload = (sql + '\n').encode('utf-8')
if process.stdin.write(payload) != len(payload):
raise Failure()
process.stdin.flush()
line = process.stdout.readline(MAX_METADATA + 1)
if not line.endswith(b'\n') or len(line) > MAX_METADATA:
raise Failure()
try:
return json.loads(line)
except (ValueError, UnicodeError):
raise Failure() from None
OTHER_CLIENTS = """(SELECT count(*) FROM pg_catalog.pg_stat_activity
WHERE backend_type = 'client backend' AND pid <> pg_catalog.pg_backend_pid())"""
DATABASE_METADATA = """BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT pg_catalog.json_build_object(
'version_num', current_setting('server_version_num')::integer,
'system_identifier', (SELECT system_identifier::text FROM pg_catalog.pg_control_system()),
'database_name', current_database(), 'user_name', current_user,
'port', current_setting('port')::integer, 'data_directory', current_setting('data_directory'),
'in_recovery', pg_is_in_recovery(), 'read_only', current_setting('transaction_read_only'),
'all_sessions_visible', (SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user)
OR pg_has_role(current_user, 'pg_read_all_stats', 'MEMBER'),
'snapshot', pg_export_snapshot(), 'database_bytes', pg_database_size(current_database()),
'tables', (SELECT COALESCE(json_agg(c.relname ORDER BY c.relname), '[]'::json)
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'),
'sequences', (SELECT COALESCE(jsonb_agg(jsonb_build_array(n.nspname, c.relname)
ORDER BY n.nspname, c.relname), '[]'::jsonb)
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'S' AND n.nspname <> 'information_schema' AND n.nspname !~ '^pg_'),
'other_clients', """ + OTHER_CLIENTS + ');'
def _validate_database(metadata, identity):
if (type(metadata.get('version_num')) is not int or metadata['version_num'] // 10000 != 16
or metadata.get('system_identifier') != identity['system_identifier']
or metadata.get('database_name') != identity['database']
or metadata.get('user_name') != identity['user'] or type(metadata.get('port')) is not int
or metadata['port'] != identity['port']
or not _same_windows_path(metadata.get('data_directory', ''), POSTGRES_DATA)
or metadata.get('in_recovery') is not False or metadata.get('read_only') != 'on'
or metadata.get('all_sessions_visible') is not True
or type(metadata.get('other_clients')) is not int or metadata['other_clients'] != 0
or not re.fullmatch(r'[0-9A-Fa-f]+-[0-9A-Fa-f]+-[0-9]+', metadata.get('snapshot', ''))
or type(metadata.get('database_bytes')) is not int or metadata['database_bytes'] < 0):
raise Failure()
tables = metadata.get('tables')
if (not isinstance(tables, list) or any(not isinstance(t, str) or not t or '\0' in t for t in tables)
or len(tables) != len(set(tables))):
raise Failure()
sequences = metadata.get('sequences')
if (not isinstance(sequences, list)
or any(not isinstance(pair, list) or len(pair) != 2
or any(not isinstance(name, str) or not name or '\0' in name for name in pair)
for pair in sequences)
or len(sequences) != len({tuple(pair) for pair in sequences})):
raise Failure()
def _sequence_states(process, sequences):
states = {}
for schema, name in sequences:
quoted = '.'.join('"' + part.replace('"', '""') + '"' for part in (schema, name))
value = _query(process, "SELECT pg_catalog.json_build_object('last_value', last_value, "
"'is_called', is_called) FROM " + quoted + ';')
if (not isinstance(value, dict) or set(value) != {'last_value', 'is_called'}
or type(value['last_value']) is not int or type(value['is_called']) is not bool):
raise Failure()
states.setdefault(schema, {})[name] = value
return states
def _no_other_clients(process):
# Activity views cache within a transaction; explicitly refresh before checking.
_query(process, "SELECT json_build_object('cleared', pg_stat_clear_snapshot() IS NULL);")
count = _query(process, 'SELECT ' + OTHER_CLIENTS + ';')
if type(count) is not int or count != 0:
raise Failure()
def _capture_database(source, identity, output, inventory, report):
report(6, 0, 0)
env = _client_environment(source.dsn)
binaries = SOURCE_ROOT / 'runtime/postgres/pgsql/bin'
for name in ('psql', 'pg_dump'):
path = binaries / (name + '.exe')
before = _fingerprint(_file_info(path))
result = subprocess.run([str(path), '--version'], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, timeout=15, env=env, creationflags=0x08000000,
close_fds=True)
if (result.returncode != 0 or not re.fullmatch(
rb'(?:psql|pg_dump) \(PostgreSQL\) 16(?:\.[0-9]+)*(?: \([^\r\n]*\))?\s*', result.stdout)
or _fingerprint(_file_info(path)) != before):
raise Failure()
source.inputs[path] = before
command = [str(binaries / 'psql.exe'), '-X', '-q', '-A', '-t', '-w', '-v', 'ON_ERROR_STOP=1', '-f', '-']
with _client(command, env, SESSION_TIMEOUT, interactive=True) as process:
metadata = _query(process, DATABASE_METADATA)
_validate_database(metadata, identity)
file_bytes = sum(entry.size for entry in inventory.files.values())
if shutil.disk_usage(output).free < file_bytes + metadata['database_bytes'] + 512 * BLOCK:
raise Failure()
counts = {}
for name in metadata['tables']:
quoted = '"' + name.replace('"', '""') + '"'
count = _query(process, 'SELECT count(*) FROM ONLY "public".' + quoted + ';', COUNT_TIMEOUT)
if type(count) is not int or count < 0:
raise Failure()
counts[name] = count
report(6, len(counts), 0)
_no_other_clients(process)
# Sequences are not MVCC-isolated, even in this exported-snapshot session.
# With the source stopped, require their values to stay fixed across dump.
sequences = _sequence_states(process, metadata['sequences'])
dump = [str(binaries / 'pg_dump.exe'), '--format=custom', '--no-owner', '--no-acl',
'--no-tablespaces', '--compress=1', '--no-password', '--lock-wait-timeout=10s',
'--snapshot=' + metadata['snapshot']]
dump_env = dict(env, PGOPTIONS=env['PGOPTIONS'].replace(
f'statement_timeout={COUNT_TIMEOUT * 1000}', f'statement_timeout={DUMP_TIMEOUT * 1000}'))
with _output_file(output / 'database.dump', source.security) as handle:
writer = HashWriter(handle)
prefix = b''
with _client(dump, dump_env, DUMP_TIMEOUT) as dumping:
while True:
block = dumping.stdout.read(BLOCK)
if not block:
break
if len(prefix) < 5:
prefix = (prefix + block)[:5]
writer.write(block)
if prefix != b'PGDMP' or writer.size <= 5:
raise Failure()
dump_metadata = writer.metadata()
_no_other_clients(process)
if _sequence_states(process, metadata['sequences']) != sequences:
raise Failure()
report(6, len(counts), dump_metadata['bytes'])
return {key: metadata[key] for key in ('version_num', 'system_identifier', 'database_name',
'user_name', 'port', 'data_directory', 'database_bytes')} | {
'table_counts': counts, 'table_count_mode': 'ONLY public ordinary tables; shared exported snapshot',
'sequence_states': sequences, 'sequence_count': len(metadata['sequences']),
'sequence_state_mode': 'Non-system schemas; non-MVCC values checked unchanged across dump in export session',
'schema_migration_counts': {name: counts[name] for name in ('runtime_schema_migrations', 'schema_migrations')
if name in counts}, **dump_metadata}
def _stop_confirmed(source, backend, report):
retries = 0
while True:
try:
with _silence():
result = source.pg.maintenance_stop(source.config, backend=backend)
if not result.completed or not result.stopped or backend.probe().kind != source.pg.ProbeKind.STOPPED:
raise Failure()
return
except BaseException:
# Even Ctrl-C must not release authority over a possibly live source.
retries += 1
try:
report(7, retries, 0)
time.sleep(2)
except BaseException:
pass
def _unchanged_inputs(source):
for path, fingerprint in source.inputs.items():
if _fingerprint(_file_info(path)) != fingerprint:
raise Failure()
def _publish_manifest(output, security, manifest):
temporary = output / 'manifest.json.partial'
with _output_file(temporary, security) as handle:
writer = HashWriter(handle)
for chunk in json.JSONEncoder(ensure_ascii=True, sort_keys=True, indent=2).iterencode(manifest):
writer.write(chunk.encode('utf-8'))
writer.write(b'\n')
# Windows rename refuses an existing destination. A partial JSON is not valid
# snapshot authority, even when all preceding large files were completed.
os.rename(temporary, output / 'manifest.json')
def capture(output, report):
with _defer_signals() as checkpoint:
def progress(number, count, size):
if number != 7:
checkpoint()
report(number, count, size)
_capture(output, progress, checkpoint)
def _capture(output, report, checkpoint):
report(2, 0, 0)
with _silence():
source = _load_source()
report(3, 0, 0)
with _silence():
_prepare_output(output, source.security)
authority = source.security.ClusterAuthorityLock(source.config, endpoint_dsn=source.dsn)
authority.acquire()
backend, attempted, manifest, published = None, False, None, False
try:
report(4, 0, 0)
with _silence():
_supervisor_absent(source)
identity = source.pg.verify_cluster_identity(source.config)
_validate_identity(source, identity)
backend = source.pg.PostgresBackend(source.config)
if backend.probe().kind != source.pg.ProbeKind.STOPPED:
raise Failure()
inventory = _inventory()
_unchanged_inputs(source)
report(4, len(inventory.files), sum(entry.size for entry in inventory.files.values()))
try:
report(5, 0, 0)
with _silence():
_supervisor_absent(source)
if backend.probe().kind != source.pg.ProbeKind.STOPPED:
raise Failure()
checkpoint()
attempted = True
# Never check cancellation inside the original start helper:
# Popen precedes _accepted_start_at_monotonic. Its non-raising
# signal fence lets that bookkeeping and close() finish first.
if source.pg.maintenance_start(source.config, backend=backend).kind != source.pg.ProbeKind.READY:
raise Failure()
checkpoint()
database = _capture_database(source, identity, output, inventory, report)
finally:
if attempted:
report(7, 0, 0)
_stop_confirmed(source, backend, report)
checkpoint()
files, archive = _write_tar(output, source.security, inventory, report)
report(9, len(files), archive['bytes'])
manifest = {
'format': 'truf-windows-snapshot-v1', 'created_at': datetime.now(timezone.utc).isoformat(),
'database': database, 'files': files, 'archive': archive,
'source': {'root': str(SOURCE_ROOT), 'postgres_data_dir': str(POSTGRES_DATA),
'supervisor_stopped': True, 'postgres_stopped': True},
'exclusions': {'policy': EXCLUSION_POLICY, 'observed_entries': inventory.exclusions},
'counts': {'files': len(files), 'file_bytes': sum(entry['size'] for entry in files),
'active_files': sum(not entry['path'].startswith('windows-archive/') for entry in files),
'archival_files': sum(entry['path'].startswith('windows-archive/') for entry in files),
'public_tables': len(database['table_counts']), 'sequences': database['sequence_count']},
}
finally:
# Do not use the lock's __exit__: an unconfirmed stop must retain it.
if attempted:
_stop_confirmed(source, backend, report)
try:
try:
if manifest is not None:
checkpoint()
with _silence():
_supervisor_absent(source)
_unchanged_inputs(source)
_verify_private_acl(output, source.security, directory=True)
if _inventory() != inventory:
raise Failure()
report(10, len(manifest['files']), manifest['archive']['bytes'])
_publish_manifest(output, source.security, manifest)
published = True
finally:
with _silence():
try:
if backend is not None:
backend.close()
finally:
authority.release()
checkpoint()
except BaseException:
if published:
(output / 'manifest.json').unlink()
raise
def main(argv=None):
phase = 1
def report(number, count=0, size=0):
nonlocal phase
phase = number
print(number, count, size, flush=True)
environment, paths = os.environ.copy(), sys.path[:]
try:
if os.name != 'nt' or not (sys.flags.isolated and sys.flags.no_site and sys.dont_write_bytecode):
raise Failure()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=('capture',))
parser.add_argument('--output', required=True)
with _silence():
args = parser.parse_args(argv)
output = _output_path(args.output)
capture(output, report)
return 0
except BaseException as exc:
timed_out = isinstance(exc, subprocess.TimeoutExpired) or isinstance(exc, Failure) and exc.code == 124
code = 124 if timed_out else 1
print(phase, code, file=sys.stderr, flush=True)
return code
finally:
os.environ.clear()
os.environ.update(environment)
sys.path[:] = paths
if __name__ == '__main__':
raise SystemExit(main())
+43
View File
@@ -0,0 +1,43 @@
{
"schema": 1,
"linux": {
"aarch64": {
"python_image_manifest": "sha256:d04f49f5882f49a3b91f874e75e19f0c265f7222da8659741a9d7eab148f22a9",
"trufflehog_archive_sha256": "7e65e771d2a247964056aa5edba0f8ae3945895e5dce867fe0ffbc7b0128239a"
},
"ca_certificates": "20250419~deb12u1",
"debian_snapshot": "20260914T000000Z",
"git": "1:2.39.5-0+deb12u3",
"tini": "0.19.0-1+b3",
"trufflehog_version": "3.97.4",
"x86_64": {
"python_image_manifest": "sha256:9c47360a2a0355e2da18516d0b1c2126ec22c195d2185e97347c9d98398c5bef",
"trufflehog_archive_bytes": 34970205,
"trufflehog_archive_sha256": "dc24007c2f233bd61c05beabeb44aa27ea9b43288166279209abe0458c5ce76b"
}
},
"python_image": "python:3.12-slim-bookworm@sha256:782412e85d0f0984994c290652577d4018aff08145c85b262bb63dc0c7522254",
"python_version": "3.12.14",
"windows": {
"x86_64": {
"git": {
"archive_bytes": 47241394,
"archive_sha256": "50b04b55425b5c465d076cdb184f63a0cd0f86f6ec8bb4d5860114a713d2c29a",
"url": "https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/MinGit-2.47.1-64-bit.zip",
"version": "2.47.1.windows.1"
},
"python": {
"archive_bytes": 11133606,
"archive_sha256": "4acbed6dd1c744b0376e3b1cf57ce906f9dc9e95e68824584c8099a63025a3c3",
"url": "https://www.python.org/ftp/python/3.12.10/python-3.12.10-embed-amd64.zip",
"version": "3.12.10"
},
"trufflehog": {
"archive_bytes": 73316636,
"archive_sha256": "6ce9a957ac62bfb19463048333d9e8481327dbbf5bdc0c43f5ab5327b9631fb9",
"url": "https://github.com/trufflesecurity/trufflehog/releases/download/v3.97.4/trufflehog_3.97.4_windows_amd64.tar.gz",
"version": "3.97.4"
}
}
}
}