diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..250ba38 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include MANIFEST.in PACKAGE.md pyproject.toml +recursive-include src/password_policy_lab *.css *.html *.py *.typed +prune tests diff --git a/Makefile b/Makefile index 99665e0..b8eaf2d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ PYTHON ?= python3 EVIDENCE_WORK ?= $(CURDIR)/.evidence-work +DISTRIBUTION_WORK ?= .evidence-work/distribution PLAYWRIGHT_BROWSERS_PATH ?= $(CURDIR)/.playwright-browsers EVIDENCE_ENV = \ PLAYWRIGHT_BROWSERS_PATH="$(PLAYWRIGHT_BROWSERS_PATH)" \ @@ -15,8 +16,8 @@ EVIDENCE_ENV = \ TEMP="$(EVIDENCE_WORK)/tmp" .PHONY: \ - build check dependencies evidence evidence-browser evidence-check lint test \ - typecheck + build check dependencies distribution-check evidence evidence-browser \ + evidence-check lint test typecheck lint: @$(PYTHON) -m ruff check app.py scripts src tests @@ -38,6 +39,11 @@ build: dependencies: @$(PYTHON) -m pip check +distribution-check: + @PYTHONDONTWRITEBYTECODE=1 $(PYTHON) scripts/attest_distribution.py \ + --root "$(CURDIR)" \ + --work-root "$(DISTRIBUTION_WORK)" + evidence-browser: @mkdir -p "$(EVIDENCE_WORK)/tmp" "$(EVIDENCE_WORK)/xdg" @$(EVIDENCE_ENV) $(PYTHON) -m playwright install chromium @@ -52,4 +58,4 @@ evidence-check: "$(EVIDENCE_WORK)/xdg" @$(EVIDENCE_ENV) PYTHONPATH=src $(PYTHON) scripts/check_evidence.py -check: lint typecheck test dependencies evidence-check +check: lint typecheck test dependencies distribution-check evidence-check diff --git a/PACKAGE.md b/PACKAGE.md new file mode 100644 index 0000000..fc53dde --- /dev/null +++ b/PACKAGE.md @@ -0,0 +1,24 @@ +# Password Policy State-Space + +`password-policy-state-space` counts constrained visible-ASCII password spaces +exactly and exposes deterministic inspection, rank, and unrank operations. Its +production sampler selects one uniform integer rank with `secrets.randbelow` +and maps that rank to a candidate without retrying invalid strings. + +The installed command can inspect a policy without sampling: + +```bash +password-policy-lab inspect --length 20 --format json +``` + +`rank` and `unrank` are reversible and are intended only for explicitly public +test vectors. Both require `--acknowledge-reversible-output`; never use them +with a credential. + +The wheel contains the typed Python package, server-rendered template and +stylesheet, and the `password-policy-lab` entry point. Repository-only tests, +portfolio evidence, and browser captures are deliberately excluded from the +installable distribution and remain available in the source repository. + +No project license is currently declared. Public source availability alone +does not grant permission to copy, modify, or redistribute the package. diff --git a/README.md b/README.md index ddee87a..d5c0914 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ make check The package supports Python 3.11 or newer. `make check` runs Ruff, formatting, strict mypy, exhaustive and independent mathematical oracles, real Flask -request tests, 100% combined line/branch coverage, and the committed-evidence -integrity check. +request tests, 100% combined line/branch coverage, the distribution +attestation, and the committed-evidence integrity check. Start the pinned production WSGI server on loopback: @@ -56,6 +56,48 @@ waitress-serve \ Then open `http://127.0.0.1:5000`. +## Reproducible distribution contract + +![Measured distribution build, rebuild, and installed-smoke flow](docs/assets/distribution-contract.svg) + +`make distribution-check` does not trust the migrated worktree's file modes or +an implicit setuptools file list. It snapshots exactly 15 stage-zero Git blobs, +materializes two normalized source trees, builds both with the pinned +`setuptools==83.0.0`, and validates exact archive inventories: + +- the wheel has 17 regular members with fixed ZIP metadata and a complete, + canonical `RECORD`; +- the sdist has 23 regular files and six directories, with fixed modes, epoch, + owner fields, member order, and gzip header; +- a wheel rebuilt from the safely materialized canonical sdist is byte-for-byte + equal to the canonicalized wheel produced by each primary build; +- an offline `pip --target` install from an external working directory imports + package code and metadata from that target, finds both package resources, and + runs deterministic `inspect --length 20 --format json` twice without sampling. + +![Real distribution attestation terminal output](docs/assets/distribution-check.png) + +Run the attested path with: + +```bash +make distribution-check +``` + +The raw [`distribution-check.txt`](docs/evidence/distribution-check.txt) and +canonical +[`distribution-attestation.json`](docs/evidence/distribution-attestation.json) +record the input digest, complete member-level SHA-256 inventory, canonical +archive hashes, rebuild equality, toolchain, smoke result, and negative claim +boundaries. `make build` remains a conventional backend build for local +inspection; its raw sdist contains environment-dependent metadata and is not +presented as the canonical release artifact. + +The attestation is deliberately unofficial. It does not claim a license, +artifact signature, dependency integrity, cross-platform reproducibility, or +safety for arbitrary archives. The install smoke uses the current pinned +checker dependencies without resolving them; it is not a fresh, hash-locked +dependency environment. + ## Architecture ![Source-derived package architecture](docs/assets/architecture.svg) @@ -170,6 +212,11 @@ temporary files under this repository. On a minimal Linux image, Chromium's OS runtime libraries still need to be supplied by that environment; the target never invokes a privileged system-package install. +The capture contract waits for fonts and settled layout, preserves the declared +viewport during full-page screenshots, and pins Chromium to one raster thread. +That removes subpixel shadow races without replacing the real server-rendered +interface with a mockup; the exact launch argument is recorded in the manifest. + ![Real project quality gate](docs/assets/quality-gate.png) Evidence provenance stays next to the visuals: diff --git a/docs/assets/distribution-check.png b/docs/assets/distribution-check.png new file mode 100644 index 0000000..eb46a5c Binary files /dev/null and b/docs/assets/distribution-check.png differ diff --git a/docs/assets/distribution-contract.svg b/docs/assets/distribution-contract.svg new file mode 100644 index 0000000..977f2e9 --- /dev/null +++ b/docs/assets/distribution-contract.svg @@ -0,0 +1,111 @@ + + Reproducible distribution attestation flow + Fifteen immutable Git inputs feed two builds, canonical wheel and source archives, a source-archive rebuild, and an installed deterministic inspection smoke test. + + + + + + + + + + + + + + + The release archive is measured, normalized, rebuilt, then installed + Every value comes from the real project-specific attestation; raw backend artifacts are never presented as canonical releases. + + + + + 1 + Immutable inputs + + 15 stage-zero Git blobs + sha256 82281d790d473675… + normalized modes + mtime + + + + + + 2 + Build A + B + + setuptools 83.0.0 + fixed epoch · umask 0022 + package index disabled + + + + + + 3W + Canonical wheel + + 17 exact members · 0644 + sha256 2cd15168c3c93b99… + two raw wheels byte-equal + + + + + + 3S + Canonical sdist + + 23 files + 6 directories + sha256 ac510b1ed90ec2ac… + raw sdist equality unclaimed + + + + + + 4 + Sdist rebuild + + safe manual materialization + canonical wheel byte-equal + no extractall + + + + + + 5 + Installed smoke + + offline pip --target + external cwd + exact origin + inspect only · no sample + + + + + + + + + + + + + + Claim boundary + No license, signature, dependency-integrity, cross-platform, or arbitrary-archive guarantee. + The smoke uses current pinned checker dependencies; it is not a fresh dependency environment. + official: false + diff --git a/docs/assets/quality-gate.png b/docs/assets/quality-gate.png index 2347692..66e3e9e 100644 Binary files a/docs/assets/quality-gate.png and b/docs/assets/quality-gate.png differ diff --git a/docs/assets/setup-workflow.svg b/docs/assets/setup-workflow.svg index b536961..af5289d 100644 --- a/docs/assets/setup-workflow.svg +++ b/docs/assets/setup-workflow.svg @@ -57,6 +57,7 @@ make check lint · types · tests + distribution · evidence diff --git a/docs/assets/web-home.png b/docs/assets/web-home.png index 61ee5b7..fcce7fc 100644 Binary files a/docs/assets/web-home.png and b/docs/assets/web-home.png differ diff --git a/docs/evidence/distribution-attestation.json b/docs/evidence/distribution-attestation.json new file mode 100644 index 0000000..be66658 --- /dev/null +++ b/docs/evidence/distribution-attestation.json @@ -0,0 +1,441 @@ +{ + "artifacts": { + "sdist": { + "canonical_builds_byte_equal": true, + "filename": "password_policy_state_space-0.1.0.tar.gz", + "inventory": [ + { + "compressed_size": null, + "mode": "0755", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/MANIFEST.in", + "sha256": "90a343738ccd474b81735cc3fd3e3a1cec947e47bc2c8cb5c8a9ff30c9eca679", + "size": 126 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/PACKAGE.md", + "sha256": "0c58f616bdd464d40315197b7f5679cbd63f709a31925c3b887f12c5e1ae4dad", + "size": 1088 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/PKG-INFO", + "sha256": "f04478b56bacfad7878c29668019c33bbc3bbff2f39a6cc753f5aaa07148bab2", + "size": 2023 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/pyproject.toml", + "sha256": "1ca9cca3822404be961fae73a1cbecf5e9a7b2c112878d808b79b7c97466e7c9", + "size": 1554 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/setup.cfg", + "sha256": "1c473cbaee8da5fc46e7f0158794af5cea4414c34a3cf3f180c2001f5e38bd3e", + "size": 38 + }, + { + "compressed_size": null, + "mode": "0755", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0 + }, + { + "compressed_size": null, + "mode": "0755", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/__init__.py", + "sha256": "23cb122fab42f49e6843c7ae6ea7bbcba7d9c0971e8f93e451fb9b6d0dc73efa", + "size": 1079 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/__main__.py", + "sha256": "529f004dc17fc33f0df9a0fd37338afa2269b073975c4e2d0d8df03b7c7e1067", + "size": 257 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/cli.py", + "sha256": "2c417d5a5f941b8e1898e3d14626dcd9fc2277712367841f709a194ebd7e04fd", + "size": 16641 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/errors.py", + "sha256": "21613d041edaea44c38503eb5b543ecf1d406c8c92bab6fbcb40f5a06a6e1dcc", + "size": 645 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/inspection.py", + "sha256": "8af1f3703184acaf0524f2f57a8494128fb56ba8a6c48835f240565a5ce785b0", + "size": 5388 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/policy.py", + "sha256": "636112fe3d636c2e87529774ade6c30f9c488ff51f705a6d8663711749ef98be", + "size": 4160 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/profiles.py", + "sha256": "1a75d0972e8c93ee2c6a353ed8c328425aa3e6ff33b4bce17097f91809380f6f", + "size": 1190 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/py.typed", + "sha256": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", + "size": 1 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/space.py", + "sha256": "76832b2870aced4d9d2fe8aac67bf21226ab12a9d396d7bc7ec04628ee1c8783", + "size": 7189 + }, + { + "compressed_size": null, + "mode": "0755", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/static", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/static/styles.css", + "sha256": "e97db9cbbbbfd1483b0a5db254667ec56675f2e28e945d0a067f0ac3a6e0dc08", + "size": 12963 + }, + { + "compressed_size": null, + "mode": "0755", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/templates", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/templates/index.html", + "sha256": "7dae09a227330eb774810a9d861ea925110e95ac1d683fe38170a17052d594c5", + "size": 9039 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_lab/web.py", + "sha256": "36d08dee59f7f7d361f7d80b0a3ef7c172bccf8032f827b95d8ef89a2289fba0", + "size": 6485 + }, + { + "compressed_size": null, + "mode": "0755", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_state_space.egg-info", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_state_space.egg-info/PKG-INFO", + "sha256": "f04478b56bacfad7878c29668019c33bbc3bbff2f39a6cc753f5aaa07148bab2", + "size": 2023 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_state_space.egg-info/SOURCES.txt", + "sha256": "1f0d1244441fcb70010b44d4cddcd3636b8ebedff618d6e3b1fe7c102c67b27e", + "size": 798 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_state_space.egg-info/dependency_links.txt", + "sha256": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", + "size": 1 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_state_space.egg-info/entry_points.txt", + "sha256": "3eb99b6f09e5d0707a4d5852651147b116e34c325bf327559505700d762bee0d", + "size": 69 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_state_space.egg-info/requires.txt", + "sha256": "ef709edf5a884e07c9caa7a4ca33e883b5d800b6d4b4700a3be6eae726a0b4ae", + "size": 191 + }, + { + "compressed_size": null, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0/src/password_policy_state_space.egg-info/top_level.txt", + "sha256": "8feadb3d9d3c69d031c67aaf43f6933b0a8a78461c47d8ab7d186c707644d86a", + "size": 20 + } + ], + "member_count": 29, + "raw_build_count": 2, + "raw_builds_byte_equality_claimed": false, + "sha256": "ac510b1ed90ec2ac12d0cbe9b7d8da4b3369e00b25a43dc2a7a65a4b6462ba39", + "size": 19463 + }, + "wheel": { + "canonical_builds_byte_equal": true, + "filename": "password_policy_state_space-0.1.0-py3-none-any.whl", + "inventory": [ + { + "compressed_size": 369, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/__init__.py", + "sha256": "23cb122fab42f49e6843c7ae6ea7bbcba7d9c0971e8f93e451fb9b6d0dc73efa", + "size": 1079 + }, + { + "compressed_size": 171, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/__main__.py", + "sha256": "529f004dc17fc33f0df9a0fd37338afa2269b073975c4e2d0d8df03b7c7e1067", + "size": 257 + }, + { + "compressed_size": 4247, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/cli.py", + "sha256": "2c417d5a5f941b8e1898e3d14626dcd9fc2277712367841f709a194ebd7e04fd", + "size": 16641 + }, + { + "compressed_size": 274, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/errors.py", + "sha256": "21613d041edaea44c38503eb5b543ecf1d406c8c92bab6fbcb40f5a06a6e1dcc", + "size": 645 + }, + { + "compressed_size": 1567, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/inspection.py", + "sha256": "8af1f3703184acaf0524f2f57a8494128fb56ba8a6c48835f240565a5ce785b0", + "size": 5388 + }, + { + "compressed_size": 1134, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/policy.py", + "sha256": "636112fe3d636c2e87529774ade6c30f9c488ff51f705a6d8663711749ef98be", + "size": 4160 + }, + { + "compressed_size": 528, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/profiles.py", + "sha256": "1a75d0972e8c93ee2c6a353ed8c328425aa3e6ff33b4bce17097f91809380f6f", + "size": 1190 + }, + { + "compressed_size": 3, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/py.typed", + "sha256": "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b", + "size": 1 + }, + { + "compressed_size": 2101, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/space.py", + "sha256": "76832b2870aced4d9d2fe8aac67bf21226ab12a9d396d7bc7ec04628ee1c8783", + "size": 7189 + }, + { + "compressed_size": 2099, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/web.py", + "sha256": "36d08dee59f7f7d361f7d80b0a3ef7c172bccf8032f827b95d8ef89a2289fba0", + "size": 6485 + }, + { + "compressed_size": 3177, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/static/styles.css", + "sha256": "e97db9cbbbbfd1483b0a5db254667ec56675f2e28e945d0a067f0ac3a6e0dc08", + "size": 12963 + }, + { + "compressed_size": 2638, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_lab/templates/index.html", + "sha256": "7dae09a227330eb774810a9d861ea925110e95ac1d683fe38170a17052d594c5", + "size": 9039 + }, + { + "compressed_size": 958, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0.dist-info/METADATA", + "sha256": "f04478b56bacfad7878c29668019c33bbc3bbff2f39a6cc753f5aaa07148bab2", + "size": 2023 + }, + { + "compressed_size": 91, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0.dist-info/WHEEL", + "sha256": "2b6eb4118ce7cd7b09601406aa623c553c4476265836f0d9c16f5c061f7efcc0", + "size": 91 + }, + { + "compressed_size": 59, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0.dist-info/entry_points.txt", + "sha256": "3eb99b6f09e5d0707a4d5852651147b116e34c325bf327559505700d762bee0d", + "size": 69 + }, + { + "compressed_size": 22, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0.dist-info/top_level.txt", + "sha256": "8feadb3d9d3c69d031c67aaf43f6933b0a8a78461c47d8ab7d186c707644d86a", + "size": 20 + }, + { + "compressed_size": 836, + "mode": "0644", + "mtime": 1704067200, + "name": "password_policy_state_space-0.1.0.dist-info/RECORD", + "sha256": "2685b61285c89ae42afd2c7a17a6a11ea14ccbd87a9a5872851b9d57e7ab58ff", + "size": 1543 + } + ], + "member_count": 17, + "raw_build_count": 2, + "raw_builds_byte_equal": true, + "sdist_rebuild_byte_equal": true, + "sha256": "2cd15168c3c93b9969b32e28be07802709b071548bd2666f47ac30efe418b484", + "size": 22862 + } + }, + "build": { + "build_count": 2, + "build_isolation": false, + "fixed_source_date_epoch": 1704067200, + "locale": "C.UTF-8", + "network_package_index_enabled": false, + "timezone": "UTC", + "umask": "0022" + }, + "claim_boundaries": { + "arbitrary_archive_safety": false, + "artifact_signature_verified": false, + "cross_platform_reproducibility": false, + "dependency_integrity_verified": false, + "fresh_dependency_environment": false, + "license_declared": false + }, + "official": false, + "schema_version": 1, + "smoke": { + "command": "inspect --length 20 --format json", + "current_checker_dependencies": { + "Flask": "3.1.3", + "waitress": "3.0.2" + }, + "dependency_install_mode": "current-pinned-checker-environment", + "deterministic": true, + "inspect_sha256": "68965b4cf54d1ee9d2b8a52f77193d767d963693fa37a06ee6789de9f7dcc078", + "metadata_origin_in_target": true, + "package_origin_in_target": true, + "pip_compile_bytecode": false, + "pip_dependency_resolution": false, + "pip_index_enabled": false, + "resources_present": true, + "sampled_password": false + }, + "source": { + "distribution_input_count": 15, + "distribution_input_sha256": "82281d790d4736759f776f0a43e0b6207731fafee4be89c9df14cc61596860cd", + "git_index_stage": 0 + }, + "toolchain": { + "build": "1.5.0", + "python": "3.12.3", + "setuptools": "83.0.0" + } +} diff --git a/docs/evidence/distribution-check.txt b/docs/evidence/distribution-check.txt new file mode 100644 index 0000000..023d6b0 --- /dev/null +++ b/docs/evidence/distribution-check.txt @@ -0,0 +1,7 @@ +$ python scripts/attest_distribution.py +distribution attestation: PASS (unofficial) +source: 15 indexed inputs; sha256=82281d790d4736759f776f0a43e0b6207731fafee4be89c9df14cc61596860cd +wheel: sha256=2cd15168c3c93b9969b32e28be07802709b071548bd2666f47ac30efe418b484; two builds and sdist rebuild match +sdist: sha256=ac510b1ed90ec2ac12d0cbe9b7d8da4b3369e00b25a43dc2a7a65a4b6462ba39; two canonical builds match (raw equality unclaimed) +smoke: deterministic inspect passed; no password sampled +boundaries: no license, signature, dependency-integrity, cross-platform, or arbitrary-archive claim diff --git a/docs/evidence/manifest.json b/docs/evidence/manifest.json index 98c45ba..bedea62 100644 --- a/docs/evidence/manifest.json +++ b/docs/evidence/manifest.json @@ -24,16 +24,40 @@ "sha256": "9d0aadb8cc7338e9adaf9b6796167d9c0862ab2631a8fd274cecd4e2cd727aad", "width": 1600 }, + { + "assertions": [ + "rendered from the real distribution attestation transcript", + "canonical wheel, sdist rebuild, and installed smoke passed" + ], + "bytes": 64519, + "height": 466, + "media_type": "image/png", + "path": "docs/assets/distribution-check.png", + "sha256": "df09560f9baa4b2da030597859d174d5f297a4b79afe1032392434c72e9a0ebb", + "width": 1600 + }, + { + "assertions": [ + "rendered from measured distribution hashes and member counts", + "claim boundaries remain explicit" + ], + "bytes": 6991, + "height": 830, + "media_type": "image/svg+xml", + "path": "docs/assets/distribution-contract.svg", + "sha256": "37eeac7f88fd0d3dd844c1984a8d69cdaf707f7ae5f3229889cb6dafe0d6656d", + "width": 1960 + }, { "assertions": [ "rendered from normalized real gate transcript", "all commands exited zero" ], - "bytes": 91467, - "height": 976, + "bytes": 146672, + "height": 1276, "media_type": "image/png", "path": "docs/assets/quality-gate.png", - "sha256": "66e179b7c8b06cf579ec5e29a168ec1c1422c9cc8724523666506003d12f68a5", + "sha256": "9c9e04e71175d5d90aaa8ffd242a02611b9f44a20dea3884f0756656a9080985", "width": 1600 }, { @@ -41,11 +65,11 @@ "commands verified against package metadata", "repository-local evidence workflow" ], - "bytes": 6122, + "bytes": 6185, "height": 650, "media_type": "image/svg+xml", "path": "docs/assets/setup-workflow.svg", - "sha256": "88693e52fb683a5263c70342245d779f9be31f8ab7aa504878c35bb91c6cbb82", + "sha256": "ac10bb0b52f6351e3165bd8a54ff3922e311fe3cda853d86f57d03643ae90301", "width": 1600 }, { @@ -89,11 +113,11 @@ "real Waitress GET response", "exact policy details expanded" ], - "bytes": 470160, - "height": 2419, + "bytes": 474461, + "height": 2417, "media_type": "image/png", "path": "docs/assets/web-home.png", - "sha256": "fffa52b478ba62665c346bda982a8576ee67364e3b882e1e29ebe4a05a445019", + "sha256": "3043a36c8fe548a2ff9da4bb3a814988b263f021e8f97c00485ff7aba939843d", "width": 1440 }, { @@ -133,15 +157,35 @@ "path": "docs/evidence/cli-inspect.txt", "sha256": "4cdabfef6d4fd10c9a41867fbfaea943be095b0c77652f9ff1aa68d03554bb15" }, + { + "assertions": [ + "canonical path-free report from two real builds", + "exact archive inventories and honest claim boundaries" + ], + "bytes": 16006, + "media_type": "application/json", + "path": "docs/evidence/distribution-attestation.json", + "sha256": "611ed7883b93a62d8c977342271519be13614263aa343b495f340e126b2e5b18" + }, + { + "assertions": [ + "real normalized attestation output", + "no password sampled" + ], + "bytes": 587, + "media_type": "text/plain", + "path": "docs/evidence/distribution-check.txt", + "sha256": "94b61d45db43b334efe8f574c94d69931bdb35aa709bec1205e112913285a92e" + }, { "assertions": [ "real normalized command output", "timing and absolute path absent" ], - "bytes": 1166, + "bytes": 1874, "media_type": "text/plain", "path": "docs/evidence/quality-gate.txt", - "sha256": "18eaf060d51fbaeb8cedec36c18bf2196290d36b66bef1ad2926603cd255e5d7" + "sha256": "aec144f46e7385f6dc217ad9b6ac4ac877bb688cf0fcce7c557b74a3847b98b3" }, { "assertions": [ @@ -168,6 +212,9 @@ } ], "capture": { + "chromium_launch_args": [ + "--num-raster-threads=1" + ], "requests": [ { "artifact": "docs/assets/web-home.png", @@ -265,6 +312,7 @@ "python -m ruff format --check app.py scripts src tests", "MYPYPATH=src python -m mypy --strict app.py scripts src tests", "PYTHONPATH=src python -m pytest --cov=password_policy_lab --cov-branch --cov-report=term-missing -q", + "python scripts/attest_distribution.py", "python -m pip check" ] }, @@ -298,13 +346,21 @@ }, "schema_version": 1, "source_files": [ + { + "path": "MANIFEST.in", + "sha256": "90a343738ccd474b81735cc3fd3e3a1cec947e47bc2c8cb5c8a9ff30c9eca679" + }, { "path": "Makefile", - "sha256": "dcafae5783472f29ecea4f3029db4f647ef7793a2b4f58a3d0eb9766f937ddd6" + "sha256": "70f4fe061b50a4548ddd13f47247c2110fff7eccec33252d47dfde69468be2ea" + }, + { + "path": "PACKAGE.md", + "sha256": "0c58f616bdd464d40315197b7f5679cbd63f709a31925c3b887f12c5e1ae4dad" }, { "path": "README.md", - "sha256": "29de5e6ee9f4f4f869202381d1c62f84dbe706d50503ea05ac65dfe962b13bdc" + "sha256": "405ede3a7f253df40b64180f804e4a228872132aaaf6ad5c282fcb65f4a2c42b" }, { "path": "app.py", @@ -312,19 +368,27 @@ }, { "path": "pyproject.toml", - "sha256": "f528181eae25e424dd82fc5dbf7c9065c886ef1ab979c97ecf716d9c8e9ca052" + "sha256": "1ca9cca3822404be961fae73a1cbecf5e9a7b2c112878d808b79b7c97466e7c9" + }, + { + "path": "scripts/attest_distribution.py", + "sha256": "ffac30dec76abdded0320d6106942ab0096a30caa05ae05a201f23d814962e5a" }, { "path": "scripts/check_evidence.py", - "sha256": "96770a1376ee5e2494abbb0b86ecc028392fd771548f12da302c7385474a648f" + "sha256": "eb72f02fd3fc50536aee670f0b83badffd1d9b13df5fa16d87c38a78be8cf881" + }, + { + "path": "scripts/distribution_contract.py", + "sha256": "68c68447528202bc83303ce593b6d2a1c46090aadccfd16a5b1f8e469d3b6a97" }, { "path": "scripts/evidence_rendering.py", - "sha256": "2ee59d600f2880682d9a7763172a16e4f48bd3327cebd2c615c89b4b17644a43" + "sha256": "b313270e49c26a9eb8b966805df21b5c46ed30823794cb5272014efb9f100a92" }, { "path": "scripts/generate_evidence.py", - "sha256": "4574b4a3dc00ce142e677134368962340c3a981f9549736d3892a3d74609915c" + "sha256": "96e00dd2f4e48c6ffb20d7543ef02b687d3f94df2d259bc064d2b6f48ef3c534" }, { "path": "src/password_policy_lab/__init__.py", @@ -386,9 +450,17 @@ "path": "tests/test_counting.py", "sha256": "5f72e9acf6c22d35317549fd13eaf87a6a864d5ad88c1441bfa50850eefa6718" }, + { + "path": "tests/test_distribution_attestation.py", + "sha256": "222d913d2ccc1afb65992acc29c8b0586699d0234fa483f1cd877ef4911e5c77" + }, + { + "path": "tests/test_distribution_contract.py", + "sha256": "d6f8761a03c6f48962aaef97e4a9955ffc8967488deb9ec11f76bc5263773854" + }, { "path": "tests/test_evidence.py", - "sha256": "db99a5c7678e0d2ec2e2bf8c9c3cab539987e1fdc36161b77f44440a35104049" + "sha256": "8bc201dce3424e6456090e803fa0da4e83f625cb0ce2fc186f33ed255f12121c" }, { "path": "tests/test_inspection.py", diff --git a/docs/evidence/quality-gate.txt b/docs/evidence/quality-gate.txt index a299603..99f8a56 100644 --- a/docs/evidence/quality-gate.txt +++ b/docs/evidence/quality-gate.txt @@ -2,14 +2,15 @@ $ python -m ruff check app.py scripts src tests All checks passed! $ python -m ruff format --check app.py scripts src tests -22 files already formatted +26 files already formatted $ MYPYPATH=src python -m mypy --strict app.py scripts src tests -Success: no issues found in 22 source files +Success: no issues found in 26 source files $ PYTHONPATH=src python -m pytest --cov=password_policy_lab --cov-branch --cov-report=term-missing -q -................................................................................................................ [ 69%] -................................................. [100%] +................................................................................................................ [ 42%] +................................................................................................................ [ 84%] +........................................ [100%] ==================================================== tests coverage ==================================================== ___________________________________ coverage: platform linux, python 3.12.3-final-0 ____________________________________ @@ -19,7 +20,15 @@ TOTAL 526 0 130 0 100% 9 files skipped due to complete coverage. Required test coverage of 100.0% reached. Total coverage: 100.00% -161 passed +264 passed + +$ python scripts/attest_distribution.py +distribution attestation: PASS (unofficial) +source: 15 indexed inputs; sha256=82281d790d4736759f776f0a43e0b6207731fafee4be89c9df14cc61596860cd +wheel: sha256=2cd15168c3c93b9969b32e28be07802709b071548bd2666f47ac30efe418b484; two builds and sdist rebuild match +sdist: sha256=ac510b1ed90ec2ac12d0cbe9b7d8da4b3369e00b25a43dc2a7a65a4b6462ba39; two canonical builds match (raw equality unclaimed) +smoke: deterministic inspect passed; no password sampled +boundaries: no license, signature, dependency-integrity, cross-platform, or arbitrary-archive claim $ python -m pip check No broken requirements found. diff --git a/pyproject.toml b/pyproject.toml index 82abc01..ec9263c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=83.0.0"] +requires = ["setuptools==83.0.0"] build-backend = "setuptools.build_meta" [project] @@ -7,7 +7,7 @@ name = "password-policy-state-space" version = "0.1.0" description = "Exact counting and uniform sampling for constrained password policies" authors = [{ name = "Omar Ibrahim" }] -readme = "README.md" +readme = { file = "PACKAGE.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = ["Flask==3.1.3", "waitress==3.0.2"] @@ -29,6 +29,7 @@ dev = [ "pytest==9.1.1", "pytest-cov==7.1.0", "ruff==0.16.0", + "setuptools==83.0.0", ] [tool.setuptools.packages.find] diff --git a/scripts/attest_distribution.py b/scripts/attest_distribution.py new file mode 100644 index 0000000..1a38b9c --- /dev/null +++ b/scripts/attest_distribution.py @@ -0,0 +1,1471 @@ +#!/usr/bin/env python3 +"""Produce a bounded, source-bound distribution attestation. + +The attestation is intentionally project-specific. It builds only the exact +release inputs stored in Git's stage-zero index, normalizes archive container +metadata, proves the two normalized builds equal, rebuilds the wheel from the +canonical source distribution, and performs an offline target install. It is +not a package signature or a general-purpose archive scanner. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import os +import re +import selectors +import signal +import stat +import subprocess +import sys +import tempfile +import time +import tomllib +from collections.abc import Mapping, Sequence +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Literal, Never, NoReturn, TextIO, cast + +from distribution_contract import ( + DIST_INFO, + FIXED_MTIME, + SDIST_ROOT, + ArtifactRecord, + DistributionContractError, + MemberRecord, + ProjectPayloads, + build_expected_metadata, + canonicalize_sdist, + canonicalize_wheel, + inspect_sdist, + inspect_wheel, + materialize_canonical_sdist, +) + +SCHEMA_VERSION = 1 +PROJECT_NAME = "password-policy-state-space" +PROJECT_VERSION = "0.1.0" +NORMALIZED_NAME = "password_policy_state_space" +WHEEL_FILENAME = f"{NORMALIZED_NAME}-{PROJECT_VERSION}-py3-none-any.whl" +SDIST_FILENAME = f"{NORMALIZED_NAME}-{PROJECT_VERSION}.tar.gz" +DEFAULT_WORK_ROOT = ".evidence-work/distribution" + +BUILD_VERSION = "1.5.0" +SETUPTOOLS_VERSION = "83.0.0" +FLASK_VERSION = "3.1.3" +WAITRESS_VERSION = "3.0.2" + +COMMAND_TIMEOUT_SECONDS = 240.0 +MAX_COMMAND_OUTPUT = 2 * 1024 * 1024 +MAX_SOURCE_FILE = 2 * 1024 * 1024 +MAX_PACKAGE_WORKTREE_ENTRIES = 256 +_READ_CHUNK = 64 * 1024 +_OBJECT_ID = re.compile(r"[0-9a-f]{40}(?:[0-9a-f]{24})?\Z") + +PACKAGE_INPUTS = ( + "src/password_policy_lab/__init__.py", + "src/password_policy_lab/__main__.py", + "src/password_policy_lab/cli.py", + "src/password_policy_lab/errors.py", + "src/password_policy_lab/inspection.py", + "src/password_policy_lab/policy.py", + "src/password_policy_lab/profiles.py", + "src/password_policy_lab/py.typed", + "src/password_policy_lab/space.py", + "src/password_policy_lab/static/styles.css", + "src/password_policy_lab/templates/index.html", + "src/password_policy_lab/web.py", +) +DISTRIBUTION_INPUTS = tuple( + sorted(("MANIFEST.in", "PACKAGE.md", "pyproject.toml", *PACKAGE_INPUTS)) +) + +# Setuptools emits Python modules before package-data files in wheels. +WHEEL_MEMBERS = ( + "password_policy_lab/__init__.py", + "password_policy_lab/__main__.py", + "password_policy_lab/cli.py", + "password_policy_lab/errors.py", + "password_policy_lab/inspection.py", + "password_policy_lab/policy.py", + "password_policy_lab/profiles.py", + "password_policy_lab/py.typed", + "password_policy_lab/space.py", + "password_policy_lab/web.py", + "password_policy_lab/static/styles.css", + "password_policy_lab/templates/index.html", + f"{DIST_INFO}/METADATA", + f"{DIST_INFO}/WHEEL", + f"{DIST_INFO}/entry_points.txt", + f"{DIST_INFO}/top_level.txt", + f"{DIST_INFO}/RECORD", +) + +_EGG_INFO = "src/password_policy_state_space.egg-info" +SDIST_FILES = tuple( + sorted( + { + "MANIFEST.in", + "PACKAGE.md", + "PKG-INFO", + "pyproject.toml", + "setup.cfg", + *PACKAGE_INPUTS, + f"{_EGG_INFO}/PKG-INFO", + f"{_EGG_INFO}/SOURCES.txt", + f"{_EGG_INFO}/dependency_links.txt", + f"{_EGG_INFO}/entry_points.txt", + f"{_EGG_INFO}/requires.txt", + f"{_EGG_INFO}/top_level.txt", + } + ) +) + +_DEV_DEPENDENCIES = ( + "build==1.5.0", + "matplotlib==3.11.1", + "mypy==2.3.0", + "numpy==2.3.5", + "Pillow==12.3.0", + "playwright==1.61.0", + "pytest==9.1.1", + "pytest-cov==7.1.0", + "ruff==0.16.0", + "setuptools==83.0.0", +) +_SETUP_CFG = b"[egg_info]\ntag_build = \ntag_date = 0\n\n" +_DEPENDENCY_LINKS = b"\n" +_REQUIRES = ( + b"Flask==3.1.3\n" + b"waitress==3.0.2\n" + b"\n" + b"[dev]\n" + "\n".join(_DEV_DEPENDENCIES).encode("ascii") + b"\n" +) +_SOURCES = "\n".join( + ( + "MANIFEST.in", + "PACKAGE.md", + "pyproject.toml", + "src/password_policy_lab/__init__.py", + "src/password_policy_lab/__main__.py", + "src/password_policy_lab/cli.py", + "src/password_policy_lab/errors.py", + "src/password_policy_lab/inspection.py", + "src/password_policy_lab/policy.py", + "src/password_policy_lab/profiles.py", + "src/password_policy_lab/py.typed", + "src/password_policy_lab/space.py", + "src/password_policy_lab/web.py", + "src/password_policy_lab/static/styles.css", + "src/password_policy_lab/templates/index.html", + f"{_EGG_INFO}/PKG-INFO", + f"{_EGG_INFO}/SOURCES.txt", + f"{_EGG_INFO}/dependency_links.txt", + f"{_EGG_INFO}/entry_points.txt", + f"{_EGG_INFO}/requires.txt", + f"{_EGG_INFO}/top_level.txt", + ) +).encode("ascii") + +AttestationCode = Literal[ + "arguments-invalid", + "archive-contract", + "artifact-mismatch", + "build-failed", + "build-output-invalid", + "index-invalid", + "internal-io", + "repository-invalid", + "smoke-failed", + "source-dirty", + "source-payload-mismatch", + "subprocess-output", + "subprocess-timeout", + "toolchain-invalid", + "work-root-invalid", +] + + +class AttestationError(ValueError): + """A value-free, stable attestation failure.""" + + __slots__ = ("code",) + + def __init__(self, code: AttestationCode) -> None: + self.code = code + super().__init__(f"distribution attestation rejected: {code}") + + +@dataclass(frozen=True, slots=True) +class IndexEntry: + """One regular stage-zero Git index entry.""" + + path: str + object_id: str + + +@dataclass(frozen=True, slots=True) +class SourceFile: + """Trusted content read from a stage-zero Git blob.""" + + path: str + data: bytes + sha256: str + + +@dataclass(frozen=True, slots=True) +class SourceState: + """The exact source inputs and their Git provenance.""" + + files: tuple[SourceFile, ...] + input_sha256: str + tree: str + + def payload(self, path: str) -> bytes: + for source_file in self.files: + if source_file.path == path: + return source_file.data + raise AttestationError("index-invalid") + + +@dataclass(frozen=True, slots=True) +class CommandResult: + """Bounded subprocess output.""" + + stdout: bytes + stderr: bytes + + +@dataclass(frozen=True, slots=True) +class BuiltArtifacts: + """The exact two artifacts accepted from one build invocation.""" + + wheel: Path + sdist: Path + + +@dataclass(frozen=True, slots=True) +class Toolchain: + """Versions explicitly required by the attestation.""" + + python: str + build: str + setuptools: str + flask: str + waitress: str + + +class _SafeArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> Never: + del message + raise AttestationError("arguments-invalid") + + +class _StoreOnce(argparse.Action): + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: object, + option_string: str | None = None, + ) -> None: + marker = f"_seen_{self.dest}" + if getattr(namespace, marker, False): + parser.error(f"{option_string} may be specified only once") + setattr(namespace, marker, True) + setattr(namespace, self.dest, values) + + +def _reject(code: AttestationCode) -> NoReturn: + raise AttestationError(code) + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _parse_index_entries(raw: bytes) -> tuple[IndexEntry, ...]: + """Parse ``git ls-files --stage -z`` without accepting quoted paths.""" + + if not raw: + return () + records = raw.split(b"\0") + if records[-1] != b"": + _reject("index-invalid") + result: list[IndexEntry] = [] + observed: set[str] = set() + for raw_record in records[:-1]: + try: + header, raw_path = raw_record.split(b"\t", 1) + mode, object_id, stage = header.decode("ascii").split(" ") + path = raw_path.decode("ascii") + except (UnicodeError, ValueError): + _reject("index-invalid") + if ( + mode != "100644" + or stage != "0" + or _OBJECT_ID.fullmatch(object_id) is None + or path in observed + or path != PurePosixPath(path).as_posix() + or PurePosixPath(path).is_absolute() + or any(part in {"", ".", ".."} for part in PurePosixPath(path).parts) + ): + _reject("index-invalid") + observed.add(path) + result.append(IndexEntry(path, object_id)) + return tuple(result) + + +def _parse_tree_entries(raw: bytes) -> tuple[IndexEntry, ...]: + """Parse regular blobs from ``git ls-tree -r -z``.""" + + if not raw: + return () + records = raw.split(b"\0") + if records[-1] != b"": + _reject("repository-invalid") + result: list[IndexEntry] = [] + observed: set[str] = set() + for raw_record in records[:-1]: + try: + header, raw_path = raw_record.split(b"\t", 1) + mode, object_type, object_id = header.decode("ascii").split(" ") + path = raw_path.decode("ascii") + except (UnicodeError, ValueError): + _reject("repository-invalid") + if ( + mode != "100644" + or object_type != "blob" + or _OBJECT_ID.fullmatch(object_id) is None + or path in observed + or path != PurePosixPath(path).as_posix() + or PurePosixPath(path).is_absolute() + or any(part in {"", ".", ".."} for part in PurePosixPath(path).parts) + ): + _reject("repository-invalid") + observed.add(path) + result.append(IndexEntry(path, object_id)) + return tuple(result) + + +def _canonical_input_digest(files: Sequence[tuple[str, bytes]]) -> str: + """Hash a length-framed, lexicographically ordered source payload.""" + + digest = hashlib.sha256() + previous: str | None = None + for path, data in sorted(files): + if previous == path or path not in DISTRIBUTION_INPUTS: + _reject("index-invalid") + encoded_path = path.encode("ascii") + digest.update(len(encoded_path).to_bytes(4, "big")) + digest.update(encoded_path) + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + previous = path + if previous is None or len(files) != len(DISTRIBUTION_INPUTS): + _reject("index-invalid") + return digest.hexdigest() + + +def _process_environment( + temporary_root: Path, + *, + python_path: Path | None = None, +) -> dict[str, str]: + """Return a fixed allowlisted environment, never an inherited copy.""" + + executable_directory = str(Path(sys.executable).resolve().parent) + environment = { + "HOME": str(temporary_root / "home"), + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PATH": os.pathsep.join((executable_directory, "/usr/bin", "/bin")), + "PIP_CONFIG_FILE": os.devnull, + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "PIP_NO_INDEX": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONHASHSEED": "0", + "PYTHONNOUSERSITE": "1", + "SOURCE_DATE_EPOCH": str(FIXED_MTIME), + "TMPDIR": str(temporary_root / "tmp"), + "TZ": "UTC", + } + if python_path is not None: + environment["PYTHONPATH"] = str(python_path) + return environment + + +def _prepare_environment_directories(environment: Mapping[str, str]) -> None: + for key in ("HOME", "TMPDIR"): + directory = Path(environment[key]) + directory.mkdir(parents=True, mode=0o700, exist_ok=False) + + +def _child_setup() -> None: + os.umask(0o022) + os.setsid() + + +def _terminate(process: subprocess.Popen[bytes]) -> None: + with suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + if process.poll() is None: + process.wait() + + +def _run_command( + command: Sequence[str], + *, + cwd: Path, + environment: Mapping[str, str], + failure_code: AttestationCode, + timeout: float = COMMAND_TIMEOUT_SECONDS, + maximum_output: int = MAX_COMMAND_OUTPUT, +) -> CommandResult: + """Run one process with bounded time, combined output, and a fixed umask.""" + + if not command or timeout <= 0 or maximum_output <= 0: + _reject(failure_code) + try: + process = subprocess.Popen( + tuple(command), + cwd=cwd, + env=dict(environment), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + preexec_fn=_child_setup, + ) + except OSError: + _reject(failure_code) + + if process.stdout is None or process.stderr is None: + _terminate(process) + _reject(failure_code) + output = {"stdout": bytearray(), "stderr": bytearray()} + selector = selectors.DefaultSelector() + selector.register(process.stdout, selectors.EVENT_READ, "stdout") + selector.register(process.stderr, selectors.EVENT_READ, "stderr") + deadline = time.monotonic() + timeout + try: + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + _terminate(process) + _reject("subprocess-timeout") + for key, _ in selector.select(min(remaining, 0.1)): + chunk = os.read(key.fd, _READ_CHUNK) + if not chunk: + selector.unregister(key.fileobj) + continue + channel = cast(Literal["stdout", "stderr"], key.data) + output[channel].extend(chunk) + if len(output["stdout"]) + len(output["stderr"]) > maximum_output: + _terminate(process) + _reject("subprocess-output") + return_code = process.wait(timeout=max(0.0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + _terminate(process) + _reject("subprocess-timeout") + finally: + selector.close() + process.stdout.close() + process.stderr.close() + if return_code != 0: + _reject(failure_code) + return CommandResult(bytes(output["stdout"]), bytes(output["stderr"])) + + +def _git( + repository: Path, + arguments: Sequence[str], + *, + maximum_output: int = MAX_COMMAND_OUTPUT, +) -> bytes: + environment = { + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8", + "PATH": "/usr/bin:/bin", + "TZ": "UTC", + } + result = _run_command( + ("git", "-c", "core.quotepath=false", *arguments), + cwd=repository, + environment=environment, + failure_code="repository-invalid", + timeout=30.0, + maximum_output=maximum_output, + ) + if result.stderr: + _reject("repository-invalid") + return result.stdout + + +def _validate_project_configuration(raw: bytes) -> None: + try: + document = tomllib.loads(raw.decode("utf-8")) + except (UnicodeError, tomllib.TOMLDecodeError): + _reject("source-payload-mismatch") + build_system = document.get("build-system") + project = document.get("project") + if not isinstance(build_system, dict) or not isinstance(project, dict): + _reject("source-payload-mismatch") + if build_system != { + "requires": [f"setuptools=={SETUPTOOLS_VERSION}"], + "build-backend": "setuptools.build_meta", + }: + _reject("source-payload-mismatch") + if ( + project.get("name") != PROJECT_NAME + or project.get("version") != PROJECT_VERSION + or project.get("requires-python") != ">=3.11" + or project.get("dependencies") + != [f"Flask=={FLASK_VERSION}", f"waitress=={WAITRESS_VERSION}"] + or project.get("readme") + != {"file": "PACKAGE.md", "content-type": "text/markdown"} + or project.get("license") is not None + or project.get("scripts") + != {"password-policy-lab": "password_policy_lab.cli:main"} + ): + _reject("source-payload-mismatch") + optional = project.get("optional-dependencies") + if not isinstance(optional, dict) or optional != {"dev": list(_DEV_DEPENDENCIES)}: + _reject("source-payload-mismatch") + + +def _validate_working_package_inventory(repository: Path) -> None: + """Reject ignored as well as visible unexpected package-tree files.""" + + package_root = repository / "src/password_policy_lab" + expected_files = set(PACKAGE_INPUTS) + expected_directories = { + str(PurePosixPath(path).parent) + for path in PACKAGE_INPUTS + if str(PurePosixPath(path).parent) != "src/password_policy_lab" + } + entry_count = 0 + try: + for item in package_root.rglob("*"): + entry_count += 1 + if entry_count > MAX_PACKAGE_WORKTREE_ENTRIES: + _reject("index-invalid") + metadata = item.lstat() + relative = item.relative_to(repository).as_posix() + package_relative = item.relative_to(package_root) + cache_parts = package_relative.parts + if "__pycache__" in cache_parts: + if item.is_symlink() or ( + stat.S_ISREG(metadata.st_mode) and item.suffix != ".pyc" + ): + _reject("index-invalid") + if not ( + stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode) + ): + _reject("index-invalid") + continue + if item.is_symlink(): + _reject("source-dirty") + if stat.S_ISDIR(metadata.st_mode): + if relative not in expected_directories: + _reject("index-invalid") + elif not stat.S_ISREG(metadata.st_mode) or relative not in expected_files: + _reject("index-invalid") + except OSError: + _reject("source-dirty") + + +def _validate_worktree_payloads( + repository: Path, + source_files: Sequence[SourceFile], +) -> None: + if {item.path for item in source_files} != set(DISTRIBUTION_INPUTS): + _reject("index-invalid") + untracked_package = _git( + repository, + ( + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + "src/password_policy_lab", + ), + ) + if untracked_package: + _reject("index-invalid") + _validate_working_package_inventory(repository) + for source_file in source_files: + destination = repository.joinpath(*source_file.path.split("/")) + try: + metadata = destination.lstat() + if not stat.S_ISREG(metadata.st_mode): + _reject("source-dirty") + working_data = destination.read_bytes() + except OSError: + _reject("source-dirty") + if working_data != source_file.data: + _reject("source-dirty") + + +def _collect_source(repository: Path) -> SourceState: + """Read the exact distribution payload from Git's stage-zero index.""" + + try: + root = repository.resolve(strict=True) + metadata = root.stat() + except OSError: + _reject("repository-invalid") + if not stat.S_ISDIR(metadata.st_mode) or (root / ".git").is_symlink(): + _reject("repository-invalid") + top_level = _git(root, ("rev-parse", "--show-toplevel")).decode("utf-8").strip() + try: + if Path(top_level).resolve(strict=True) != root: + _reject("repository-invalid") + except OSError: + _reject("repository-invalid") + + source_tree = _git(root, ("write-tree",)).decode("ascii").strip() + if _OBJECT_ID.fullmatch(source_tree) is None: + _reject("repository-invalid") + entries = _parse_tree_entries( + _git( + root, + ( + "ls-tree", + "-r", + "-z", + source_tree, + "--", + "MANIFEST.in", + "PACKAGE.md", + "pyproject.toml", + "src/password_policy_lab", + ), + ) + ) + by_path = {entry.path: entry for entry in entries} + if set(by_path) != set(DISTRIBUTION_INPUTS): + _reject("index-invalid") + + source_files: list[SourceFile] = [] + for path in DISTRIBUTION_INPUTS: + entry = by_path[path] + indexed_data = _git( + root, + ("cat-file", "blob", entry.object_id), + maximum_output=MAX_SOURCE_FILE + 1, + ) + if len(indexed_data) > MAX_SOURCE_FILE: + _reject("index-invalid") + source_files.append(SourceFile(path, indexed_data, _sha256(indexed_data))) + + _validate_worktree_payloads(root, source_files) + _validate_project_configuration( + next(item.data for item in source_files if item.path == "pyproject.toml") + ) + final_tree = _git(root, ("write-tree",)).decode("ascii").strip() + if final_tree != source_tree: + _reject("source-dirty") + _validate_worktree_payloads(root, source_files) + pairs = tuple((item.path, item.data) for item in source_files) + return SourceState( + tuple(source_files), + _canonical_input_digest(pairs), + source_tree, + ) + + +def _validate_work_root(repository: Path, raw_path: str) -> Path: + pure = PurePosixPath(raw_path) + if ( + not raw_path + or raw_path != pure.as_posix() + or any(part in {"", ".", ".."} for part in pure.parts) + ): + _reject("work-root-invalid") + candidate = ( + Path(raw_path) if pure.is_absolute() else repository.joinpath(*pure.parts) + ) + try: + resolved_repository = repository.resolve(strict=True) + lexical_relative = candidate.relative_to(resolved_repository) + if not lexical_relative.parts: + _reject("work-root-invalid") + candidate.resolve(strict=False).relative_to(resolved_repository) + current = repository + for component in lexical_relative.parts: + current /= component + if current.exists() and current.is_symlink(): + _reject("work-root-invalid") + candidate.mkdir(parents=True, mode=0o700, exist_ok=True) + if not candidate.is_dir() or candidate.is_symlink(): + _reject("work-root-invalid") + except (OSError, ValueError): + _reject("work-root-invalid") + return candidate + + +def _repository_argument(raw_path: str) -> Path: + pure = PurePosixPath(raw_path) + if ( + not raw_path + or raw_path != pure.as_posix() + or not pure.is_absolute() + or any(part in {"", ".", ".."} for part in pure.parts) + ): + _reject("arguments-invalid") + candidate = Path(raw_path) + try: + if candidate.is_symlink() or candidate.resolve(strict=True) != candidate: + _reject("arguments-invalid") + except OSError: + _reject("arguments-invalid") + return candidate + + +def _materialize_snapshot(destination: Path, source: SourceState) -> None: + try: + destination.mkdir(mode=0o755, exist_ok=False) + for source_file in source.files: + output = destination.joinpath(*source_file.path.split("/")) + output.parent.mkdir(parents=True, mode=0o755, exist_ok=True) + descriptor = os.open( + output, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), + 0o644, + ) + with os.fdopen(descriptor, "wb", closefd=True) as stream: + stream.write(source_file.data) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(output, 0o644, follow_symlinks=False) + os.utime( + output, + (FIXED_MTIME, FIXED_MTIME), + follow_symlinks=False, + ) + directories = [ + destination, + *[item for item in destination.rglob("*") if item.is_dir()], + ] + for directory in sorted( + directories, + key=lambda item: len(item.parts), + reverse=True, + ): + os.chmod(directory, 0o755, follow_symlinks=False) + os.utime( + directory, + (FIXED_MTIME, FIXED_MTIME), + follow_symlinks=False, + ) + except OSError: + _reject("internal-io") + + +def _check_toolchain() -> Toolchain: + running_version = (sys.version_info.major, sys.version_info.minor) + if running_version < (3, 11): + _reject("toolchain-invalid") + expected = { + "build": BUILD_VERSION, + "setuptools": SETUPTOOLS_VERSION, + "Flask": FLASK_VERSION, + "waitress": WAITRESS_VERSION, + } + observed: dict[str, str] = {} + try: + for package, version in expected.items(): + observed[package] = importlib.metadata.version(package) + if observed[package] != version: + _reject("toolchain-invalid") + except importlib.metadata.PackageNotFoundError: + _reject("toolchain-invalid") + return Toolchain( + python=".".join(str(part) for part in sys.version_info[:3]), + build=observed["build"], + setuptools=observed["setuptools"], + flask=observed["Flask"], + waitress=observed["waitress"], + ) + + +def _build( + snapshot: Path, + output: Path, + environment_root: Path, + *, + wheel_only: bool = False, +) -> BuiltArtifacts | Path: + output.mkdir(mode=0o755, exist_ok=False) + environment = _process_environment(environment_root) + _prepare_environment_directories(environment) + command = [ + sys.executable, + "-m", + "build", + "--no-isolation", + ] + if wheel_only: + command.append("--wheel") + command.extend(("--outdir", str(output), ".")) + _run_command( + command, + cwd=snapshot, + environment=environment, + failure_code="build-failed", + ) + try: + observed = tuple(sorted(item.name for item in output.iterdir())) + if any(item.is_symlink() or not item.is_file() for item in output.iterdir()): + _reject("build-output-invalid") + except OSError: + _reject("build-output-invalid") + if wheel_only: + if observed != (WHEEL_FILENAME,): + _reject("build-output-invalid") + return output / WHEEL_FILENAME + if observed != tuple(sorted((WHEEL_FILENAME, SDIST_FILENAME))): + _reject("build-output-invalid") + return BuiltArtifacts(output / WHEEL_FILENAME, output / SDIST_FILENAME) + + +def _files_equal(first: Path, second: Path) -> bool: + try: + if first.stat().st_size != second.stat().st_size: + return False + with first.open("rb") as left, second.open("rb") as right: + while True: + left_chunk = left.read(_READ_CHUNK) + right_chunk = right.read(_READ_CHUNK) + if left_chunk != right_chunk: + return False + if not left_chunk: + return True + except OSError: + _reject("internal-io") + + +def _member_map(record: ArtifactRecord) -> dict[str, MemberRecord]: + result = {member.name: member for member in record.members} + if len(result) != len(record.members): + _reject("source-payload-mismatch") + return result + + +def _require_member( + members: Mapping[str, MemberRecord], + name: str, + data: bytes, +) -> None: + member = members.get(name) + if member is None or member.size != len(data) or member.sha256 != _sha256(data): + _reject("source-payload-mismatch") + + +def _verify_wheel_payloads( + record: ArtifactRecord, + source: SourceState, + payloads: ProjectPayloads, +) -> None: + members = _member_map(record) + for path in PACKAGE_INPUTS: + _require_member(members, path.removeprefix("src/"), source.payload(path)) + _require_member(members, f"{DIST_INFO}/METADATA", payloads.metadata) + _require_member(members, f"{DIST_INFO}/WHEEL", payloads.wheel) + _require_member( + members, + f"{DIST_INFO}/entry_points.txt", + payloads.entry_points, + ) + _require_member(members, f"{DIST_INFO}/top_level.txt", payloads.top_level) + + +def _verify_sdist_payloads( + record: ArtifactRecord, + source: SourceState, + payloads: ProjectPayloads, +) -> None: + members = _member_map(record) + for path in DISTRIBUTION_INPUTS: + _require_member(members, f"{SDIST_ROOT}/{path}", source.payload(path)) + generated = { + "PKG-INFO": payloads.metadata, + "setup.cfg": _SETUP_CFG, + f"{_EGG_INFO}/PKG-INFO": payloads.metadata, + f"{_EGG_INFO}/SOURCES.txt": _SOURCES, + f"{_EGG_INFO}/dependency_links.txt": _DEPENDENCY_LINKS, + f"{_EGG_INFO}/entry_points.txt": payloads.entry_points, + f"{_EGG_INFO}/requires.txt": _REQUIRES, + f"{_EGG_INFO}/top_level.txt": payloads.top_level, + } + for path, data in generated.items(): + _require_member(members, f"{SDIST_ROOT}/{path}", data) + + +_SMOKE_PROGRAM = r""" +import hashlib +import importlib.metadata +import io +import json +import os +import sys +from pathlib import Path + +target = Path(os.environ["ATTEST_TARGET"]).resolve(strict=True) +sys.path.insert(0, str(target)) +import password_policy_lab +from password_policy_lab.cli import run + +module_path = Path(password_policy_lab.__file__).resolve(strict=True) +if not module_path.is_relative_to(target): + raise SystemExit(21) +distribution = importlib.metadata.distribution("password-policy-state-space") +metadata_root = Path(distribution.locate_file("")).resolve(strict=True) +if not metadata_root.is_relative_to(target): + raise SystemExit(22) +if distribution.version != "0.1.0": + raise SystemExit(23) +if not (target / "password_policy_state_space-0.1.0.dist-info/METADATA").is_file(): + raise SystemExit(29) +entry_points = { + (item.group, item.name, item.value) for item in distribution.entry_points +} +if entry_points != { + ("console_scripts", "password-policy-lab", "password_policy_lab.cli:main") +}: + raise SystemExit(24) +for relative in ( + "password_policy_lab/static/styles.css", + "password_policy_lab/templates/index.html", +): + if not (target / relative).is_file(): + raise SystemExit(25) + +def invoke(): + stdout = io.StringIO() + stderr = io.StringIO() + status = run( + ["inspect", "--length", "20", "--format", "json"], + stdin=io.StringIO(""), + stdout=stdout, + stderr=stderr, + ) + if status != 0 or stderr.getvalue(): + raise SystemExit(26) + return stdout.getvalue() + +first = invoke() +second = invoke() +if first != second: + raise SystemExit(27) +payload = json.loads(first) + +def object_keys(value): + if isinstance(value, dict): + for key, child in value.items(): + yield key + yield from object_keys(child) + elif isinstance(value, list): + for child in value: + yield from object_keys(child) + +if not isinstance(payload, dict) or payload.get("operation") != "inspect": + raise SystemExit(28) +forbidden_keys = { + "candidate", + "generated_password", + "password", + "sample", + "sampled_password", + "secret", +} +if forbidden_keys.intersection(object_keys(payload)): + raise SystemExit(29) +print(json.dumps({ + "inspect_sha256": hashlib.sha256(first.encode("utf-8")).hexdigest(), + "metadata_origin_in_target": True, + "package_origin_in_target": True, + "resources_present": True, +}, ensure_ascii=True, sort_keys=True, separators=(",", ":"))) +""" + + +def _smoke_install( + wheel: Path, + run_root: Path, +) -> dict[str, object]: + target = run_root / "smoke-target" + cwd = run_root / "smoke-cwd" + cwd.mkdir(mode=0o755, exist_ok=False) + install_environment = _process_environment(run_root / "pip-environment") + _prepare_environment_directories(install_environment) + _run_command( + ( + sys.executable, + "-m", + "pip", + "install", + "--isolated", + "--no-index", + "--no-deps", + "--no-compile", + "--target", + str(target), + str(wheel), + ), + cwd=cwd, + environment=install_environment, + failure_code="smoke-failed", + ) + smoke_environment = _process_environment( + run_root / "smoke-environment", + python_path=target, + ) + smoke_environment["ATTEST_TARGET"] = str(target) + _prepare_environment_directories(smoke_environment) + result = _run_command( + (sys.executable, "-I", "-c", _SMOKE_PROGRAM), + cwd=cwd, + environment=smoke_environment, + failure_code="smoke-failed", + timeout=60.0, + maximum_output=16 * 1024, + ) + if result.stderr or not result.stdout.endswith(b"\n"): + _reject("smoke-failed") + try: + document = json.loads(result.stdout) + except (UnicodeError, json.JSONDecodeError): + _reject("smoke-failed") + if not isinstance(document, dict) or set(document) != { + "inspect_sha256", + "metadata_origin_in_target", + "package_origin_in_target", + "resources_present", + }: + _reject("smoke-failed") + digest = document.get("inspect_sha256") + if ( + not isinstance(digest, str) + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + or document.get("metadata_origin_in_target") is not True + or document.get("package_origin_in_target") is not True + or document.get("resources_present") is not True + ): + _reject("smoke-failed") + return cast(dict[str, object], document) + + +def _member_document(member: MemberRecord) -> dict[str, object]: + return { + "compressed_size": member.compressed_size, + "mode": f"{member.mode & 0o7777:04o}", + "mtime": member.mtime, + "name": member.name, + "sha256": member.sha256, + "size": member.size, + } + + +def _artifact_document(record: ArtifactRecord) -> dict[str, object]: + return { + "inventory": [_member_document(member) for member in record.members], + "member_count": len(record.members), + "sha256": record.sha256, + "size": record.size, + } + + +def _validate_claim_guards( + *, + raw_wheels_equal: bool, + canonical_wheels_equal: bool, + canonical_sdists_equal: bool, + rebuilt_wheel_equal: bool, + smoke_passed: bool, +) -> None: + if not ( + raw_wheels_equal + and canonical_wheels_equal + and canonical_sdists_equal + and rebuilt_wheel_equal + ): + _reject("artifact-mismatch") + if not smoke_passed: + _reject("smoke-failed") + + +def _build_report( + *, + source: SourceState, + toolchain: Toolchain, + raw_wheels: tuple[ArtifactRecord, ArtifactRecord], + raw_sdists: tuple[ArtifactRecord, ArtifactRecord], + wheel: ArtifactRecord, + sdist: ArtifactRecord, + smoke: Mapping[str, object], + raw_wheels_equal: bool, + canonical_wheels_equal: bool, + canonical_sdists_equal: bool, + rebuilt_wheel_equal: bool, + smoke_passed: bool, +) -> dict[str, object]: + """Create the path-free claim document from already verified facts.""" + + _validate_claim_guards( + raw_wheels_equal=raw_wheels_equal, + canonical_wheels_equal=canonical_wheels_equal, + canonical_sdists_equal=canonical_sdists_equal, + rebuilt_wheel_equal=rebuilt_wheel_equal, + smoke_passed=smoke_passed, + ) + wheel_document = _artifact_document(wheel) + wheel_document.update( + { + "canonical_builds_byte_equal": canonical_wheels_equal, + "filename": WHEEL_FILENAME, + "raw_build_count": len(raw_wheels), + "raw_builds_byte_equal": raw_wheels_equal, + "sdist_rebuild_byte_equal": rebuilt_wheel_equal, + } + ) + sdist_document = _artifact_document(sdist) + sdist_document.update( + { + "canonical_builds_byte_equal": canonical_sdists_equal, + "filename": SDIST_FILENAME, + "raw_build_count": len(raw_sdists), + "raw_builds_byte_equality_claimed": False, + } + ) + return { + "artifacts": {"sdist": sdist_document, "wheel": wheel_document}, + "build": { + "build_count": 2, + "build_isolation": False, + "fixed_source_date_epoch": FIXED_MTIME, + "locale": "C.UTF-8", + "network_package_index_enabled": False, + "timezone": "UTC", + "umask": "0022", + }, + "claim_boundaries": { + "arbitrary_archive_safety": False, + "artifact_signature_verified": False, + "cross_platform_reproducibility": False, + "dependency_integrity_verified": False, + "fresh_dependency_environment": False, + "license_declared": False, + }, + "official": False, + "schema_version": SCHEMA_VERSION, + "smoke": { + "command": "inspect --length 20 --format json", + "current_checker_dependencies": { + "Flask": toolchain.flask, + "waitress": toolchain.waitress, + }, + "dependency_install_mode": "current-pinned-checker-environment", + "deterministic": smoke_passed, + "inspect_sha256": smoke["inspect_sha256"], + "metadata_origin_in_target": True, + "package_origin_in_target": True, + "pip_compile_bytecode": False, + "pip_dependency_resolution": False, + "pip_index_enabled": False, + "resources_present": True, + "sampled_password": False, + }, + "source": { + "distribution_input_count": len(source.files), + "distribution_input_sha256": source.input_sha256, + "git_index_stage": 0, + }, + "toolchain": { + "build": toolchain.build, + "python": toolchain.python, + "setuptools": toolchain.setuptools, + }, + } + + +def _canonical_json(document: Mapping[str, object]) -> str: + return ( + json.dumps( + document, + ensure_ascii=True, + indent=2, + sort_keys=True, + separators=(",", ": "), + ) + + "\n" + ) + + +def _text_report(document: Mapping[str, object]) -> str: + artifacts = cast(dict[str, dict[str, object]], document["artifacts"]) + source = cast(dict[str, object], document["source"]) + return ( + "distribution attestation: PASS (unofficial)\n" + f"source: {source['distribution_input_count']} indexed inputs; " + f"sha256={source['distribution_input_sha256']}\n" + f"wheel: sha256={artifacts['wheel']['sha256']}; " + "two builds and sdist rebuild match\n" + f"sdist: sha256={artifacts['sdist']['sha256']}; " + "two canonical builds match (raw equality unclaimed)\n" + "smoke: deterministic inspect passed; no password sampled\n" + "boundaries: no license, signature, dependency-integrity, " + "cross-platform, or arbitrary-archive claim\n" + ) + + +def attest( + repository: Path, + *, + work_root: str = DEFAULT_WORK_ROOT, +) -> dict[str, object]: + """Run the complete attestation and return its deterministic document.""" + + toolchain = _check_toolchain() + source = _collect_source(repository) + trusted_payloads = ProjectPayloads( + metadata=build_expected_metadata(source.payload("PACKAGE.md")) + ) + local_work_root = _validate_work_root(repository.resolve(), work_root) + try: + with tempfile.TemporaryDirectory( + prefix="attestation-", + dir=local_work_root, + ) as temporary_name: + run_root = Path(temporary_name) + snapshot_a = run_root / "source-a" + snapshot_b = run_root / "source-b" + _materialize_snapshot(snapshot_a, source) + _materialize_snapshot(snapshot_b, source) + built_a = cast( + BuiltArtifacts, + _build( + snapshot_a, + run_root / "dist-a", + run_root / "environment-a", + ), + ) + built_b = cast( + BuiltArtifacts, + _build( + snapshot_b, + run_root / "dist-b", + run_root / "environment-b", + ), + ) + + raw_wheel_a = inspect_wheel( + built_a.wheel, + WHEEL_MEMBERS, + trusted_payloads, + require_canonical=False, + ) + raw_wheel_b = inspect_wheel( + built_b.wheel, + WHEEL_MEMBERS, + trusted_payloads, + require_canonical=False, + ) + raw_sdist_a = inspect_sdist( + built_a.sdist, + SDIST_FILES, + trusted_payloads, + require_canonical=False, + ) + raw_sdist_b = inspect_sdist( + built_b.sdist, + SDIST_FILES, + trusted_payloads, + require_canonical=False, + ) + for record in (raw_wheel_a, raw_wheel_b): + _verify_wheel_payloads(record, source, trusted_payloads) + for record in (raw_sdist_a, raw_sdist_b): + _verify_sdist_payloads(record, source, trusted_payloads) + raw_wheels_equal = _files_equal(built_a.wheel, built_b.wheel) + if not raw_wheels_equal: + _reject("artifact-mismatch") + + canonical_wheel_a_path = run_root / "canonical-a" / WHEEL_FILENAME + canonical_wheel_b_path = run_root / "canonical-b" / WHEEL_FILENAME + canonical_sdist_a_path = run_root / "canonical-a" / SDIST_FILENAME + canonical_sdist_b_path = run_root / "canonical-b" / SDIST_FILENAME + canonical_wheel_a = canonicalize_wheel( + built_a.wheel, + canonical_wheel_a_path, + WHEEL_MEMBERS, + trusted_payloads, + ) + canonical_wheel_b = canonicalize_wheel( + built_b.wheel, + canonical_wheel_b_path, + WHEEL_MEMBERS, + trusted_payloads, + ) + canonical_sdist_a = canonicalize_sdist( + built_a.sdist, + canonical_sdist_a_path, + SDIST_FILES, + trusted_payloads, + ) + canonical_sdist_b = canonicalize_sdist( + built_b.sdist, + canonical_sdist_b_path, + SDIST_FILES, + trusted_payloads, + ) + canonical_wheels_equal = _files_equal( + canonical_wheel_a_path, + canonical_wheel_b_path, + ) + if not canonical_wheels_equal: + _reject("artifact-mismatch") + canonical_sdists_equal = _files_equal( + canonical_sdist_a_path, + canonical_sdist_b_path, + ) + if not canonical_sdists_equal: + _reject("artifact-mismatch") + _verify_wheel_payloads(canonical_wheel_a, source, trusted_payloads) + _verify_wheel_payloads(canonical_wheel_b, source, trusted_payloads) + _verify_sdist_payloads(canonical_sdist_a, source, trusted_payloads) + _verify_sdist_payloads(canonical_sdist_b, source, trusted_payloads) + + rebuilt_source = run_root / "sdist-source" + materialize_canonical_sdist( + canonical_sdist_a_path, + rebuilt_source, + SDIST_FILES, + trusted_payloads, + ) + rebuilt_raw_wheel = cast( + Path, + _build( + rebuilt_source, + run_root / "sdist-wheel", + run_root / "environment-sdist", + wheel_only=True, + ), + ) + rebuilt_canonical_path = run_root / "sdist-canonical" / WHEEL_FILENAME + rebuilt_canonical = canonicalize_wheel( + rebuilt_raw_wheel, + rebuilt_canonical_path, + WHEEL_MEMBERS, + trusted_payloads, + ) + _verify_wheel_payloads(rebuilt_canonical, source, trusted_payloads) + rebuilt_wheel_equal = _files_equal( + canonical_wheel_a_path, + rebuilt_canonical_path, + ) + if not rebuilt_wheel_equal: + _reject("artifact-mismatch") + + smoke = _smoke_install(canonical_wheel_a_path, run_root) + smoke_passed = True + return _build_report( + source=source, + toolchain=toolchain, + raw_wheels=(raw_wheel_a, raw_wheel_b), + raw_sdists=(raw_sdist_a, raw_sdist_b), + wheel=canonical_wheel_a, + sdist=canonical_sdist_a, + smoke=smoke, + raw_wheels_equal=raw_wheels_equal, + canonical_wheels_equal=canonical_wheels_equal, + canonical_sdists_equal=canonical_sdists_equal, + rebuilt_wheel_equal=rebuilt_wheel_equal, + smoke_passed=smoke_passed, + ) + except DistributionContractError: + _reject("archive-contract") + except OSError: + _reject("internal-io") + + +def _parser() -> argparse.ArgumentParser: + parser = _SafeArgumentParser( + prog="attest-distribution", + description="Build and verify the project distribution without sampling.", + allow_abbrev=False, + ) + parser.add_argument( + "--format", + choices=("json", "text"), + default="text", + action=_StoreOnce, + ) + parser.add_argument( + "--work-root", + default=DEFAULT_WORK_ROOT, + action=_StoreOnce, + ) + parser.add_argument( + "--root", + default=None, + action=_StoreOnce, + ) + return parser + + +def run( + argv: Sequence[str], + *, + stdout: TextIO, + stderr: TextIO, + repository: Path | None = None, +) -> int: + try: + arguments = _parser().parse_args(argv) + supplied_root = cast(str | None, arguments.root) + if repository is not None and supplied_root is not None: + _reject("arguments-invalid") + root = ( + repository + if repository is not None + else ( + Path(__file__).resolve().parents[1] + if supplied_root is None + else _repository_argument(supplied_root) + ) + ) + document = attest(root, work_root=cast(str, arguments.work_root)) + output_format = cast(str, arguments.format) + stdout.write( + _canonical_json(document) + if output_format == "json" + else _text_report(document) + ) + return 0 + except AttestationError as error: + stderr.write(f"error: distribution attestation rejected: {error.code}\n") + return 1 + except DistributionContractError: + stderr.write("error: distribution attestation rejected: archive-contract\n") + return 1 + except OSError: + stderr.write("error: distribution attestation rejected: internal-io\n") + return 1 + + +def main(argv: Sequence[str] | None = None) -> int: + return run( + sys.argv[1:] if argv is None else argv, + stdout=sys.stdout, + stderr=sys.stderr, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_evidence.py b/scripts/check_evidence.py index bdebc42..a91d4c0 100644 --- a/scripts/check_evidence.py +++ b/scripts/check_evidence.py @@ -39,6 +39,8 @@ { "docs/assets/architecture.svg", "docs/assets/cli-inspect.png", + "docs/assets/distribution-check.png", + "docs/assets/distribution-contract.svg", "docs/assets/quality-gate.png", "docs/assets/setup-workflow.svg", "docs/assets/state-space-sweep.png", @@ -52,6 +54,8 @@ EXPECTED_RAW_EVIDENCE = frozenset( { "docs/evidence/cli-inspect.txt", + "docs/evidence/distribution-attestation.json", + "docs/evidence/distribution-check.txt", "docs/evidence/quality-gate.txt", "docs/evidence/state-space-sweep.csv", } @@ -78,6 +82,7 @@ MEDIA_TYPES = { ".csv": "text/csv", ".gif": "image/gif", + ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".txt": "text/plain", @@ -95,8 +100,67 @@ "PYTHONPATH=src python -m pytest --cov=password_policy_lab " "--cov-branch --cov-report=term-missing -q" ), + "python scripts/attest_distribution.py", "python -m pip check", ) +DISTRIBUTION_INPUTS = ( + "MANIFEST.in", + "PACKAGE.md", + "pyproject.toml", + "src/password_policy_lab/__init__.py", + "src/password_policy_lab/__main__.py", + "src/password_policy_lab/cli.py", + "src/password_policy_lab/errors.py", + "src/password_policy_lab/inspection.py", + "src/password_policy_lab/policy.py", + "src/password_policy_lab/profiles.py", + "src/password_policy_lab/py.typed", + "src/password_policy_lab/space.py", + "src/password_policy_lab/static/styles.css", + "src/password_policy_lab/templates/index.html", + "src/password_policy_lab/web.py", +) +_DISTRIBUTION_ROOT = "password_policy_state_space-0.1.0" +_DIST_INFO = "password_policy_state_space-0.1.0.dist-info" +_EGG_INFO = "src/password_policy_state_space.egg-info" +_WHEEL_MEMBERS = ( + "password_policy_lab/__init__.py", + "password_policy_lab/__main__.py", + "password_policy_lab/cli.py", + "password_policy_lab/errors.py", + "password_policy_lab/inspection.py", + "password_policy_lab/policy.py", + "password_policy_lab/profiles.py", + "password_policy_lab/py.typed", + "password_policy_lab/space.py", + "password_policy_lab/web.py", + "password_policy_lab/static/styles.css", + "password_policy_lab/templates/index.html", + f"{_DIST_INFO}/METADATA", + f"{_DIST_INFO}/WHEEL", + f"{_DIST_INFO}/entry_points.txt", + f"{_DIST_INFO}/top_level.txt", + f"{_DIST_INFO}/RECORD", +) +_SDIST_FILES = tuple( + sorted( + { + "MANIFEST.in", + "PACKAGE.md", + "PKG-INFO", + "pyproject.toml", + "setup.cfg", + *DISTRIBUTION_INPUTS[3:], + f"{_EGG_INFO}/PKG-INFO", + f"{_EGG_INFO}/SOURCES.txt", + f"{_EGG_INFO}/dependency_links.txt", + f"{_EGG_INFO}/entry_points.txt", + f"{_EGG_INFO}/requires.txt", + f"{_EGG_INFO}/top_level.txt", + } + ) +) +_FIXED_DISTRIBUTION_MTIME = 1_704_067_200 SWEEP_COLUMNS = ( "length", "policy_sha256", @@ -221,29 +285,35 @@ def _safe_path(root: Path, raw_path: object, label: str) -> tuple[str, Path]: return path, destination -def _load_json(path: Path) -> tuple[dict[str, object], str]: +def _load_json( + path: Path, + *, + label: str = "manifest", +) -> tuple[dict[str, object], str]: try: + if not 0 < path.stat().st_size <= 2_000_000: + _fail(f"{label} has an invalid byte size") raw = path.read_bytes() except OSError: - _fail("manifest is missing or unreadable") + _fail(f"{label} is missing or unreadable") try: text = raw.decode("utf-8") except UnicodeDecodeError: - _fail("manifest must be UTF-8") + _fail(f"{label} must be UTF-8") def reject_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} for key, value in pairs: if key in result: - _fail("manifest contains a duplicate object key") + _fail(f"{label} contains a duplicate object key") result[key] = value return result try: value = json.loads(text, object_pairs_hook=reject_duplicates) except (json.JSONDecodeError, RecursionError): - _fail("manifest is not valid bounded JSON") - document = _mapping(value, "manifest") + _fail(f"{label} is not valid bounded JSON") + document = _mapping(value, label) canonical = ( json.dumps( document, @@ -254,7 +324,7 @@ def reject_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]: + "\n" ) if text != canonical: - _fail("manifest is not canonical sorted JSON") + _fail(f"{label} is not canonical sorted JSON") return document, text @@ -694,7 +764,14 @@ def _validate_source_files( paths.append(path) if paths != sorted(paths) or len(paths) != len(set(paths)): _fail("source_files paths must be unique and sorted") - expected = {"Makefile", "README.md", "app.py", "pyproject.toml"} + expected = { + "MANIFEST.in", + "Makefile", + "PACKAGE.md", + "README.md", + "app.py", + "pyproject.toml", + } for directory_name in ("scripts", "src", "tests"): directory = root / directory_name if not directory.is_dir(): @@ -759,9 +836,21 @@ def _validate_capture(value: object) -> dict[str, tuple[int, int]]: capture = _mapping(value, "capture") _exact_keys( capture, - {"requests", "sampler_calls", "sampling_guard", "server"}, + { + "chromium_launch_args", + "requests", + "sampler_calls", + "sampling_guard", + "server", + }, "capture", ) + launch_arguments = _validate_string_list( + capture["chromium_launch_args"], + "capture.chromium_launch_args", + ) + if launch_arguments != ["--num-raster-threads=1"]: + _fail("capture must pin Chromium to one raster thread") server = _string(capture["server"], "capture.server") _validate_safe_text(server, "capture.server") if server != "waitress": @@ -1089,6 +1178,376 @@ def _validate_raw_evidence(textual: dict[str, str]) -> None: _fail("quality-gate transcript omits ordered command headers") +def _distribution_input_digest(root: Path) -> str: + digest = hashlib.sha256() + for relative in DISTRIBUTION_INPUTS: + data = _read_bounded( + root.joinpath(*relative.split("/")), + relative, + maximum=2 * 1024 * 1024, + ) + encoded_path = relative.encode("ascii") + digest.update(len(encoded_path).to_bytes(4, "big")) + digest.update(encoded_path) + digest.update(len(data).to_bytes(8, "big")) + digest.update(data) + return digest.hexdigest() + + +def _distribution_sha(value: object, label: str) -> str: + digest = _string(value, label) + if _SHA256.fullmatch(digest) is None: + _fail(f"{label} is not a lowercase SHA-256") + return digest + + +def _sdist_inventory_plan() -> tuple[tuple[str, ...], frozenset[str]]: + directories = {_DISTRIBUTION_ROOT} + files: set[str] = set() + for relative in _SDIST_FILES: + full_name = f"{_DISTRIBUTION_ROOT}/{relative}" + files.add(full_name) + components = full_name.split("/") + for length in range(1, len(components)): + directories.add("/".join(components[:length])) + return tuple(sorted(directories | files)), frozenset(directories) + + +def _validate_distribution_inventory( + root: Path, + value: object, + *, + kind: str, +) -> None: + entries = _sequence(value, f"distribution.artifacts.{kind}.inventory") + expected_names: tuple[str, ...] + directories: frozenset[str] + if kind == "wheel": + expected_names = _WHEEL_MEMBERS + directories = frozenset() + source_names = { + path.removeprefix("src/"): path for path in DISTRIBUTION_INPUTS[3:] + } + else: + expected_names, directories = _sdist_inventory_plan() + source_names = { + f"{_DISTRIBUTION_ROOT}/{path}": path for path in DISTRIBUTION_INPUTS + } + observed: list[str] = [] + empty_digest = hashlib.sha256(b"").hexdigest() + for index, raw_entry in enumerate(entries): + label = f"distribution.artifacts.{kind}.inventory[{index}]" + entry = _mapping(raw_entry, label) + _exact_keys( + entry, + {"compressed_size", "mode", "mtime", "name", "sha256", "size"}, + label, + ) + name = _string(entry["name"], f"{label}.name") + observed.append(name) + is_directory = name in directories + expected_mode = "0755" if is_directory else "0644" + if entry["mode"] != expected_mode: + _fail(f"{label}.mode is not canonical") + if entry["mtime"] != _FIXED_DISTRIBUTION_MTIME: + _fail(f"{label}.mtime is not canonical") + size = _integer(entry["size"], f"{label}.size") + digest = _distribution_sha(entry["sha256"], f"{label}.sha256") + if kind == "wheel": + _integer( + entry["compressed_size"], + f"{label}.compressed_size", + minimum=1, + ) + elif entry["compressed_size"] is not None: + _fail(f"{label}.compressed_size must be null for tar members") + if is_directory and (size != 0 or digest != empty_digest): + _fail(f"{label} has invalid canonical directory facts") + source_path = source_names.get(name) + if source_path is not None: + source_data = _read_bounded( + root.joinpath(*source_path.split("/")), + source_path, + maximum=2 * 1024 * 1024, + ) + if size != len(source_data) or digest != _sha256(source_data): + _fail(f"{label} does not match its repository source") + if tuple(observed) != expected_names: + _fail(f"distribution.artifacts.{kind}.inventory is not exact and ordered") + + +def _validate_distribution_artifact( + root: Path, + value: object, + *, + kind: str, +) -> str: + artifact = _mapping(value, f"distribution.artifacts.{kind}") + common = { + "canonical_builds_byte_equal", + "filename", + "inventory", + "member_count", + "raw_build_count", + "sha256", + "size", + } + extra = ( + {"raw_builds_byte_equal", "sdist_rebuild_byte_equal"} + if kind == "wheel" + else {"raw_builds_byte_equality_claimed"} + ) + _exact_keys(artifact, common | extra, f"distribution.artifacts.{kind}") + expected_count = 17 if kind == "wheel" else 29 + expected_filename = ( + "password_policy_state_space-0.1.0-py3-none-any.whl" + if kind == "wheel" + else "password_policy_state_space-0.1.0.tar.gz" + ) + if artifact["filename"] != expected_filename: + _fail(f"distribution.artifacts.{kind}.filename is incorrect") + if artifact["member_count"] != expected_count: + _fail(f"distribution.artifacts.{kind}.member_count is incorrect") + if artifact["raw_build_count"] != 2: + _fail(f"distribution.artifacts.{kind}.raw_build_count is incorrect") + if not _boolean( + artifact["canonical_builds_byte_equal"], + f"distribution.artifacts.{kind}.canonical_builds_byte_equal", + ): + _fail(f"distribution.artifacts.{kind} canonical builds did not match") + if kind == "wheel": + if not _boolean( + artifact["raw_builds_byte_equal"], + "distribution.artifacts.wheel.raw_builds_byte_equal", + ) or not _boolean( + artifact["sdist_rebuild_byte_equal"], + "distribution.artifacts.wheel.sdist_rebuild_byte_equal", + ): + _fail("distribution wheel equality claims are not proven") + elif _boolean( + artifact["raw_builds_byte_equality_claimed"], + "distribution.artifacts.sdist.raw_builds_byte_equality_claimed", + ): + _fail("raw sdist byte equality must remain unclaimed") + size = _integer(artifact["size"], f"distribution.artifacts.{kind}.size", minimum=1) + if size > 5 * 1024 * 1024: + _fail(f"distribution.artifacts.{kind}.size exceeds the contract") + digest = _distribution_sha( + artifact["sha256"], + f"distribution.artifacts.{kind}.sha256", + ) + _validate_distribution_inventory(root, artifact["inventory"], kind=kind) + return digest + + +def _expected_distribution_transcript(document: dict[str, object]) -> str: + artifacts = _mapping(document["artifacts"], "distribution.artifacts") + wheel = _mapping(artifacts["wheel"], "distribution.artifacts.wheel") + sdist = _mapping(artifacts["sdist"], "distribution.artifacts.sdist") + source = _mapping(document["source"], "distribution.source") + return ( + "$ python scripts/attest_distribution.py\n" + "distribution attestation: PASS (unofficial)\n" + f"source: {source['distribution_input_count']} indexed inputs; " + f"sha256={source['distribution_input_sha256']}\n" + f"wheel: sha256={wheel['sha256']}; " + "two builds and sdist rebuild match\n" + f"sdist: sha256={sdist['sha256']}; " + "two canonical builds match (raw equality unclaimed)\n" + "smoke: deterministic inspect passed; no password sampled\n" + "boundaries: no license, signature, dependency-integrity, " + "cross-platform, or arbitrary-archive claim\n" + ) + + +def _validate_distribution_attestation( + root: Path, + document: dict[str, object], + json_text: str, + transcript: str, + diagram: str, +) -> None: + _exact_keys( + document, + { + "artifacts", + "build", + "claim_boundaries", + "official", + "schema_version", + "smoke", + "source", + "toolchain", + }, + "distribution", + ) + if document["schema_version"] != 1: + _fail("distribution.schema_version must be 1") + if _boolean(document["official"], "distribution.official"): + _fail("distribution attestation must remain unofficial") + + artifacts = _mapping(document["artifacts"], "distribution.artifacts") + _exact_keys(artifacts, {"sdist", "wheel"}, "distribution.artifacts") + wheel_sha = _validate_distribution_artifact( + root, + artifacts["wheel"], + kind="wheel", + ) + sdist_sha = _validate_distribution_artifact( + root, + artifacts["sdist"], + kind="sdist", + ) + + build = _mapping(document["build"], "distribution.build") + _exact_keys( + build, + { + "build_count", + "build_isolation", + "fixed_source_date_epoch", + "locale", + "network_package_index_enabled", + "timezone", + "umask", + }, + "distribution.build", + ) + if ( + build["build_count"] != 2 + or build["fixed_source_date_epoch"] != _FIXED_DISTRIBUTION_MTIME + or build["locale"] != "C.UTF-8" + or build["timezone"] != "UTC" + or build["umask"] != "0022" + or _boolean(build["build_isolation"], "distribution.build.build_isolation") + or _boolean( + build["network_package_index_enabled"], + "distribution.build.network_package_index_enabled", + ) + ): + _fail("distribution.build does not match the reproducible build contract") + + boundaries = _mapping( + document["claim_boundaries"], + "distribution.claim_boundaries", + ) + boundary_keys = { + "arbitrary_archive_safety", + "artifact_signature_verified", + "cross_platform_reproducibility", + "dependency_integrity_verified", + "fresh_dependency_environment", + "license_declared", + } + _exact_keys(boundaries, boundary_keys, "distribution.claim_boundaries") + if any( + _boolean(boundaries[key], f"distribution.claim_boundaries.{key}") + for key in boundary_keys + ): + _fail("distribution claim boundaries must all remain false") + + source = _mapping(document["source"], "distribution.source") + _exact_keys( + source, + { + "distribution_input_count", + "distribution_input_sha256", + "git_index_stage", + }, + "distribution.source", + ) + input_sha = _distribution_sha( + source["distribution_input_sha256"], + "distribution.source.distribution_input_sha256", + ) + if source["distribution_input_count"] != 15 or source["git_index_stage"] != 0: + _fail("distribution source cardinality or index stage is incorrect") + if input_sha != _distribution_input_digest(root): + _fail("distribution input digest does not match repository sources") + + toolchain = _mapping(document["toolchain"], "distribution.toolchain") + _exact_keys(toolchain, {"build", "python", "setuptools"}, "distribution.toolchain") + python_version = _string(toolchain["python"], "distribution.toolchain.python") + if ( + toolchain["build"] != "1.5.0" + or toolchain["setuptools"] != "83.0.0" + or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", python_version) is None + ): + _fail("distribution.toolchain is not the pinned checker toolchain") + + smoke = _mapping(document["smoke"], "distribution.smoke") + _exact_keys( + smoke, + { + "command", + "current_checker_dependencies", + "dependency_install_mode", + "deterministic", + "inspect_sha256", + "metadata_origin_in_target", + "package_origin_in_target", + "pip_compile_bytecode", + "pip_dependency_resolution", + "pip_index_enabled", + "resources_present", + "sampled_password", + }, + "distribution.smoke", + ) + dependencies = _mapping( + smoke["current_checker_dependencies"], + "distribution.smoke.current_checker_dependencies", + ) + if dependencies != {"Flask": "3.1.3", "waitress": "3.0.2"}: + _fail("distribution smoke dependencies are not the pinned checker versions") + if ( + smoke["command"] != "inspect --length 20 --format json" + or smoke["dependency_install_mode"] != "current-pinned-checker-environment" + or not _boolean(smoke["deterministic"], "distribution.smoke.deterministic") + or not _boolean( + smoke["metadata_origin_in_target"], + "distribution.smoke.metadata_origin_in_target", + ) + or not _boolean( + smoke["package_origin_in_target"], + "distribution.smoke.package_origin_in_target", + ) + or not _boolean( + smoke["resources_present"], + "distribution.smoke.resources_present", + ) + or _boolean( + smoke["pip_compile_bytecode"], + "distribution.smoke.pip_compile_bytecode", + ) + or _boolean( + smoke["pip_dependency_resolution"], + "distribution.smoke.pip_dependency_resolution", + ) + or _boolean(smoke["pip_index_enabled"], "distribution.smoke.pip_index_enabled") + or _boolean(smoke["sampled_password"], "distribution.smoke.sampled_password") + ): + _fail("distribution smoke facts do not match the installed-target contract") + _distribution_sha(smoke["inspect_sha256"], "distribution.smoke.inspect_sha256") + + _validate_safe_text(json_text, "distribution attestation") + if transcript != _expected_distribution_transcript(document): + _fail("distribution transcript does not agree with its canonical JSON") + required_diagram_terms = { + "15 stage-zero git blobs", + "17 exact members", + "23 files + 6 directories", + "installed smoke", + "official: false", + input_sha[:16], + wheel_sha[:16], + sdist_sha[:16], + } + folded_diagram = diagram.casefold() + if any(term not in folded_diagram for term in required_diagram_terms): + _fail("distribution diagram omits a measured fact or claim boundary") + + def _module_tree(root: Path, module: str) -> ast.Module: path = root / "src" / Path(*module.split(".")).with_suffix(".py") try: @@ -1387,6 +1846,19 @@ def validate_evidence(root: Path) -> None: textual = _validate_artifacts(repository, document["artifacts"], viewports) for artifact, text in textual.items(): _validate_safe_text(text, artifact) + distribution_document, distribution_text = _load_json( + repository / "docs/evidence/distribution-attestation.json", + label="distribution attestation", + ) + if textual["docs/evidence/distribution-attestation.json"] != distribution_text: + _fail("distribution artifact text changed between bounded reads") + _validate_distribution_attestation( + repository, + distribution_document, + distribution_text, + textual["docs/evidence/distribution-check.txt"], + textual["docs/assets/distribution-contract.svg"], + ) _validate_raw_evidence(textual) _validate_gif_fidelity(repository) _validate_ast_claims(repository, textual) diff --git a/scripts/distribution_contract.py b/scripts/distribution_contract.py new file mode 100644 index 0000000..add0d75 --- /dev/null +++ b/scripts/distribution_contract.py @@ -0,0 +1,920 @@ +"""Bounded, project-specific contracts for release archives. + +This module deliberately implements the archive profile used by this project; it +is not a generic hostile-archive sandbox. Callers supply the exact member +allowlists and the expected project metadata derived from trusted source files. +""" + +from __future__ import annotations + +import base64 +import csv +import decimal +import gzip +import hashlib +import hmac +import io +import os +import re +import shutil +import stat +import tarfile +import tempfile +import zipfile +import zlib +from collections.abc import Callable, Mapping, Sequence +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import IO, BinaryIO, Literal, NoReturn + +PROJECT_NAME = "password-policy-state-space" +PROJECT_VERSION = "0.1.0" +NORMALIZED_NAME = "password_policy_state_space" +DIST_INFO = f"{NORMALIZED_NAME}-{PROJECT_VERSION}.dist-info" +SDIST_ROOT = f"{NORMALIZED_NAME}-{PROJECT_VERSION}" + +# 2024-01-01 00:00:00 UTC. ZIP timestamps have two-second resolution. +FIXED_MTIME = 1_704_067_200 +FIXED_ZIP_TIME = (2024, 1, 1, 0, 0, 0) + +MAX_OUTER_SIZE = 5 * 1024 * 1024 +MAX_MEMBER_SIZE = 2 * 1024 * 1024 +MAX_WHEEL_MEMBERS = 128 +MAX_WHEEL_PAYLOAD = 10 * 1024 * 1024 +MAX_SDIST_MEMBERS = 256 +MAX_SDIST_PAYLOAD = 20 * 1024 * 1024 +MAX_SDIST_STREAM = 24 * 1024 * 1024 +MAX_COMPRESSION_RATIO = 100 +MAX_RAW_MTIME = 4_102_444_800 +_CHUNK_SIZE = 64 * 1024 + +EXPECTED_WHEEL = ( + b"Wheel-Version: 1.0\n" + b"Generator: setuptools (83.0.0)\n" + b"Root-Is-Purelib: true\n" + b"Tag: py3-none-any\n" + b"\n" +) +EXPECTED_ENTRY_POINTS = ( + b"[console_scripts]\npassword-policy-lab = password_policy_lab.cli:main\n" +) +EXPECTED_TOP_LEVEL = b"password_policy_lab\n" +EXPECTED_METADATA_PREFIX = ( + b"Metadata-Version: 2.4\n" + b"Name: password-policy-state-space\n" + b"Version: 0.1.0\n" + b"Summary: Exact counting and uniform sampling for constrained password policies\n" + b"Author: Omar Ibrahim\n" + b"Project-URL: Repository, https://github.com/omar07ibrahim/PasswordGenerator\n" + b"Project-URL: Issues, https://github.com/omar07ibrahim/PasswordGenerator/issues\n" + b"Requires-Python: >=3.11\n" + b"Description-Content-Type: text/markdown\n" + b"Requires-Dist: Flask==3.1.3\n" + b"Requires-Dist: waitress==3.0.2\n" + b"Provides-Extra: dev\n" + b'Requires-Dist: build==1.5.0; extra == "dev"\n' + b'Requires-Dist: matplotlib==3.11.1; extra == "dev"\n' + b'Requires-Dist: mypy==2.3.0; extra == "dev"\n' + b'Requires-Dist: numpy==2.3.5; extra == "dev"\n' + b'Requires-Dist: Pillow==12.3.0; extra == "dev"\n' + b'Requires-Dist: playwright==1.61.0; extra == "dev"\n' + b'Requires-Dist: pytest==9.1.1; extra == "dev"\n' + b'Requires-Dist: pytest-cov==7.1.0; extra == "dev"\n' + b'Requires-Dist: ruff==0.16.0; extra == "dev"\n' + b'Requires-Dist: setuptools==83.0.0; extra == "dev"\n' + b"\n" +) + +_SAFE_COMPONENT = re.compile(r"[A-Za-z0-9_.-]+\Z") +_RECORD_DIGEST = re.compile(r"sha256=([A-Za-z0-9_-]{43})\Z") +_PAX_MTIME = re.compile(r"(?:0|[1-9][0-9]{0,9})(?:\.(?:0|[0-9]{0,8}[1-9]))?\Z") +_WINDOWS_RESERVED = frozenset( + {"CON", "PRN", "AUX", "NUL"} + | {f"COM{number}" for number in range(1, 10)} + | {f"LPT{number}" for number in range(1, 10)} +) + +ErrorCode = Literal[ + "allowlist-invalid", + "archive-invalid", + "archive-too-large", + "canonical-metadata-invalid", + "compression-invalid", + "member-invalid", + "member-limit-exceeded", + "metadata-invalid", + "output-invalid", + "path-invalid", + "record-invalid", +] + + +class DistributionContractError(ValueError): + """A stable rejection whose message never includes untrusted details.""" + + __slots__ = ("code",) + + def __init__(self, code: ErrorCode) -> None: + self.code = code + super().__init__(f"distribution contract rejected: {code}") + + +@dataclass(frozen=True, slots=True) +class ProjectPayloads: + """Exact trusted payloads expected in project metadata members.""" + + metadata: bytes + wheel: bytes = EXPECTED_WHEEL + entry_points: bytes = EXPECTED_ENTRY_POINTS + top_level: bytes = EXPECTED_TOP_LEVEL + + def __post_init__(self) -> None: + if ( + not isinstance(self.metadata, bytes) + or len(self.metadata) > MAX_MEMBER_SIZE + or self.wheel != EXPECTED_WHEEL + or self.entry_points != EXPECTED_ENTRY_POINTS + or self.top_level != EXPECTED_TOP_LEVEL + ): + _reject("metadata-invalid") + + +@dataclass(frozen=True, slots=True) +class MemberRecord: + """Immutable facts measured while streaming one archive member.""" + + name: str + size: int + sha256: str + compressed_size: int | None + mode: int + mtime: int + + +@dataclass(frozen=True, slots=True) +class ArtifactRecord: + """Immutable facts measured for a complete accepted artifact.""" + + kind: Literal["wheel", "sdist"] + size: int + sha256: str + members: tuple[MemberRecord, ...] + + +@dataclass(frozen=True, slots=True) +class _Inspection: + record: ArtifactRecord + payloads: Mapping[str, bytes] + + +def build_expected_metadata(description: bytes) -> bytes: + """Bind trusted ``PACKAGE.md`` bytes to the exact project metadata header.""" + + if ( + not isinstance(description, bytes) + or len(description) > MAX_MEMBER_SIZE - len(EXPECTED_METADATA_PREFIX) + or b"\x00" in description + ): + _reject("metadata-invalid") + return EXPECTED_METADATA_PREFIX + description + + +def inspect_wheel( + path: str | os.PathLike[str], + allowed_members: Sequence[str], + payloads: ProjectPayloads, + *, + require_canonical: bool = True, +) -> ArtifactRecord: + """Inspect one wheel against an exact ordered, project-specific contract.""" + + return _public_inspect_wheel( + path, allowed_members, payloads, require_canonical=require_canonical + ).record + + +def canonicalize_wheel( + source: str | os.PathLike[str], + destination: str | os.PathLike[str], + allowed_members: Sequence[str], + payloads: ProjectPayloads, +) -> ArtifactRecord: + """Rewrite a validated wheel with fixed ZIP container metadata.""" + + inspected = _public_inspect_wheel( + source, allowed_members, payloads, require_canonical=False + ) + expected = tuple(allowed_members) + output = Path(destination) + + _atomic_output( + output, + lambda stream: _write_canonical_wheel(stream, expected, inspected.payloads), + ) + return inspect_wheel(output, expected, payloads) + + +def inspect_sdist( + path: str | os.PathLike[str], + allowed_files: Sequence[str], + payloads: ProjectPayloads, + *, + require_canonical: bool = True, +) -> ArtifactRecord: + """Inspect one source distribution with an exact regular-file allowlist.""" + + return _public_inspect_sdist( + path, allowed_files, payloads, require_canonical=require_canonical + ).record + + +def canonicalize_sdist( + source: str | os.PathLike[str], + destination: str | os.PathLike[str], + allowed_files: Sequence[str], + payloads: ProjectPayloads, +) -> ArtifactRecord: + """Rewrite a validated sdist as deterministic USTAR inside deterministic gzip.""" + + inspected = _public_inspect_sdist( + source, allowed_files, payloads, require_canonical=False + ) + files = _validated_allowlist(allowed_files, MAX_SDIST_MEMBERS) + expected = _sdist_member_plan(files) + output = Path(destination) + + _atomic_output( + output, + lambda stream: _write_canonical_sdist(stream, expected, inspected.payloads), + ) + return inspect_sdist(output, files, payloads) + + +def materialize_canonical_sdist( + archive_path: str | os.PathLike[str], + destination: str | os.PathLike[str], + allowed_files: Sequence[str], + payloads: ProjectPayloads, +) -> ArtifactRecord: + """Materialize accepted regular files without using ``extractall``. + + ``destination`` must not already exist and becomes the sdist root contents; + the archive's fixed top-level directory is intentionally stripped. + """ + + inspected = _public_inspect_sdist( + archive_path, allowed_files, payloads, require_canonical=True + ) + files = _validated_allowlist(allowed_files, MAX_SDIST_MEMBERS) + target = Path(destination) + parent = target.parent + temporary: Path | None = None + try: + _require_absent(target) + parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=".sdist-materialize-", dir=parent)) + os.chmod(temporary, 0o755) + for relative in files: + full_name = f"{SDIST_ROOT}/{relative}" + output = temporary.joinpath(*relative.split("/")) + output.parent.mkdir(parents=True, exist_ok=True, mode=0o755) + _verify_materialized_parent(temporary, output.parent) + descriptor = os.open( + output, + os.O_WRONLY | os.O_CREAT | os.O_EXCL | _no_follow_flag(), + 0o600, + ) + try: + stream = os.fdopen(descriptor, "wb", closefd=True) + descriptor = -1 + with stream: + stream.write(inspected.payloads[full_name]) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(output, 0o644, follow_symlinks=False) + finally: + if descriptor >= 0: + os.close(descriptor) + for directory in sorted( + (item for item in temporary.rglob("*") if item.is_dir()), + key=lambda item: len(item.parts), + reverse=True, + ): + os.chmod(directory, 0o755, follow_symlinks=False) + os.replace(temporary, target) + temporary = None + except DistributionContractError: + raise + except (OSError, ValueError): + raise DistributionContractError("output-invalid") from None + finally: + if temporary is not None: + shutil.rmtree(temporary, ignore_errors=True) + return inspected.record + + +def _public_inspect_wheel( + path: str | os.PathLike[str], + allowed_members: Sequence[str], + payloads: ProjectPayloads, + *, + require_canonical: bool, +) -> _Inspection: + try: + return _inspect_wheel(path, allowed_members, payloads, require_canonical) + except DistributionContractError: + raise + except (OSError, EOFError, UnicodeError, ValueError, zipfile.BadZipFile): + raise DistributionContractError("archive-invalid") from None + + +def _public_inspect_sdist( + path: str | os.PathLike[str], + allowed_files: Sequence[str], + payloads: ProjectPayloads, + *, + require_canonical: bool, +) -> _Inspection: + try: + return _inspect_sdist(path, allowed_files, payloads, require_canonical) + except DistributionContractError: + raise + except (OSError, EOFError, UnicodeError, ValueError, tarfile.TarError): + raise DistributionContractError("archive-invalid") from None + + +def _inspect_wheel( + path: str | os.PathLike[str], + allowed_members: Sequence[str], + project_payloads: ProjectPayloads, + require_canonical: bool, +) -> _Inspection: + expected = _validated_allowlist(allowed_members, MAX_WHEEL_MEMBERS) + required = { + f"{DIST_INFO}/METADATA", + f"{DIST_INFO}/WHEEL", + f"{DIST_INFO}/entry_points.txt", + f"{DIST_INFO}/top_level.txt", + f"{DIST_INFO}/RECORD", + } + if not required.issubset(expected): + _reject("allowlist-invalid") + + with _open_outer(path) as stream: + outer_data, outer_hash = _read_outer(stream) + outer_size = len(outer_data) + with zipfile.ZipFile( + io.BytesIO(outer_data), mode="r", allowZip64=False + ) as archive: + if archive.comment: + _reject("canonical-metadata-invalid") + infos = archive.infolist() + if len(infos) > MAX_WHEEL_MEMBERS: + _reject("member-limit-exceeded") + observed = tuple(info.filename for info in infos) + _validate_observed_paths(observed) + if any(info.orig_filename != info.filename for info in infos): + _reject("path-invalid") + if observed != expected: + _reject("allowlist-invalid") + + advertised_total = 0 + for info in infos: + _validate_zip_info(info, require_canonical) + advertised_total += info.file_size + if advertised_total > MAX_WHEEL_PAYLOAD: + _reject("member-limit-exceeded") + + records: list[MemberRecord] = [] + contents: dict[str, bytes] = {} + measured_total = 0 + for info in infos: + data, digest = _read_zip_member(archive, info) + measured_total += len(data) + if measured_total > MAX_WHEEL_PAYLOAD: + _reject("member-limit-exceeded") + contents[info.filename] = data + records.append( + MemberRecord( + name=info.filename, + size=len(data), + sha256=digest, + compressed_size=info.compress_size, + mode=info.external_attr >> 16, + mtime=_zip_timestamp(info.date_time), + ) + ) + + _validate_project_payloads(contents, project_payloads) + _validate_record(contents[f"{DIST_INFO}/RECORD"], tuple(records), expected) + if require_canonical: + canonical = io.BytesIO() + _write_canonical_wheel(canonical, expected, contents) + _require_canonical_bytes(outer_data, canonical.getvalue()) + return _Inspection( + ArtifactRecord("wheel", outer_size, outer_hash, tuple(records)), contents + ) + + +def _inspect_sdist( + path: str | os.PathLike[str], + allowed_files: Sequence[str], + project_payloads: ProjectPayloads, + require_canonical: bool, +) -> _Inspection: + files = _validated_allowlist(allowed_files, MAX_SDIST_MEMBERS) + if "PKG-INFO" not in files: + _reject("allowlist-invalid") + expected_plan = _sdist_member_plan(files) + with _open_outer(path) as stream: + outer_data, outer_hash = _read_outer(stream) + outer_size = len(outer_data) + if require_canonical: + _validate_gzip_header(io.BytesIO(outer_data)) + tar_data = _decompress_sdist(outer_data) + with tarfile.open(fileobj=io.BytesIO(tar_data), mode="r:") as archive: + if archive.pax_headers: + _reject("canonical-metadata-invalid") + records: list[MemberRecord] = [] + contents: dict[str, bytes] = {} + measured_total = 0 + aliases: set[str] = set() + for expected_name, expected_directory in expected_plan: + member = archive.next() + if member is None: + _reject("allowlist-invalid") + _validate_observed_path(member.name, aliases) + if member.name != expected_name: + _reject("allowlist-invalid") + _validate_tar_info(member, expected_directory, require_canonical) + if expected_directory: + records.append( + MemberRecord( + member.name, + 0, + hashlib.sha256(b"").hexdigest(), + None, + member.mode, + int(member.mtime), + ) + ) + continue + measured_total += member.size + if measured_total > MAX_SDIST_PAYLOAD: + _reject("member-limit-exceeded") + extracted = archive.extractfile(member) + if extracted is None: + _reject("member-invalid") + data, digest = _read_bounded(extracted, member.size) + contents[member.name] = data + records.append( + MemberRecord( + member.name, + len(data), + digest, + None, + member.mode, + int(member.mtime), + ) + ) + if archive.next() is not None: + if len(expected_plan) >= MAX_SDIST_MEMBERS: + _reject("member-limit-exceeded") + _reject("allowlist-invalid") + + if contents[f"{SDIST_ROOT}/PKG-INFO"] != project_payloads.metadata: + _reject("metadata-invalid") + if require_canonical: + canonical = io.BytesIO() + _write_canonical_sdist(canonical, expected_plan, contents) + _require_canonical_bytes(outer_data, canonical.getvalue()) + return _Inspection( + ArtifactRecord("sdist", outer_size, outer_hash, tuple(records)), contents + ) + + +def _validate_project_payloads( + contents: Mapping[str, bytes], payloads: ProjectPayloads +) -> None: + expected = { + f"{DIST_INFO}/METADATA": payloads.metadata, + f"{DIST_INFO}/WHEEL": payloads.wheel, + f"{DIST_INFO}/entry_points.txt": payloads.entry_points, + f"{DIST_INFO}/top_level.txt": payloads.top_level, + } + if any(contents.get(name) != value for name, value in expected.items()): + _reject("metadata-invalid") + + +def _validate_record( + record_data: bytes, + members: tuple[MemberRecord, ...], + expected_order: tuple[str, ...], +) -> None: + record_name = f"{DIST_INFO}/RECORD" + try: + text = record_data.decode("ascii") + rows = list(csv.reader(io.StringIO(text, newline=""), strict=True)) + except (UnicodeError, csv.Error): + _reject("record-invalid") + if len(rows) != len(members) or any(len(row) != 3 for row in rows): + _reject("record-invalid") + if tuple(row[0] for row in rows) != expected_order: + _reject("record-invalid") + by_name = {member.name: member for member in members} + canonical_lines: list[str] = [] + for name, encoded_digest, encoded_size in rows: + if name == record_name: + if encoded_digest or encoded_size: + _reject("record-invalid") + canonical_lines.append(f"{name},,\n") + continue + match = _RECORD_DIGEST.fullmatch(encoded_digest) + member = by_name.get(name) + if match is None or member is None or not _canonical_decimal(encoded_size): + _reject("record-invalid") + expected_digest = bytes.fromhex(member.sha256) + canonical_digest = ( + base64.urlsafe_b64encode(expected_digest).rstrip(b"=").decode("ascii") + ) + if ( + not hmac.compare_digest(match.group(1), canonical_digest) + or int(encoded_size) != member.size + ): + _reject("record-invalid") + canonical_lines.append(f"{name},{encoded_digest},{encoded_size}\n") + if record_data != "".join(canonical_lines).encode("ascii"): + _reject("record-invalid") + + +def _validate_zip_info(info: zipfile.ZipInfo, require_canonical: bool) -> None: + mode = info.external_attr >> 16 + if info.file_size < 0 or info.file_size > MAX_MEMBER_SIZE: + _reject("member-limit-exceeded") + if ( + info.is_dir() + or not stat.S_ISREG(mode) + or info.flag_bits != 0 + or info.extra + or info.comment + or info.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED} + or info.compress_size < 0 + ): + _reject("member-invalid") + if info.file_size and ( + info.compress_size == 0 + or info.file_size > info.compress_size * MAX_COMPRESSION_RATIO + ): + _reject("compression-invalid") + if require_canonical and ( + info.create_system != 3 + or mode != stat.S_IFREG | 0o644 + or info.date_time != FIXED_ZIP_TIME + or info.compress_type != zipfile.ZIP_DEFLATED + or info.internal_attr != 0 + ): + _reject("canonical-metadata-invalid") + + +def _validate_tar_info( + member: tarfile.TarInfo, expected_directory: bool, require_canonical: bool +) -> None: + if member.size < 0 or member.size > MAX_MEMBER_SIZE: + _reject("member-limit-exceeded") + _validate_member_pax_mtime(member, require_canonical) + if member.sparse is not None or member.linkname: + _reject("member-invalid") + if expected_directory: + if not member.isdir() or member.size != 0: + _reject("member-invalid") + elif not member.isreg(): + _reject("member-invalid") + if require_canonical and ( + member.mode != (0o755 if expected_directory else 0o644) + or member.uid != 0 + or member.gid != 0 + or member.uname != "" + or member.gname != "" + or member.mtime != FIXED_MTIME + ): + _reject("canonical-metadata-invalid") + + +def _validate_member_pax_mtime( + member: tarfile.TarInfo, require_canonical: bool +) -> None: + headers = member.pax_headers + if not headers: + return + if set(headers) != {"mtime"}: + _reject("member-invalid") + value = headers["mtime"] + if _PAX_MTIME.fullmatch(value) is None: + _reject("member-invalid") + parsed = decimal.Decimal(value) + if parsed > MAX_RAW_MTIME or parsed != decimal.Decimal(str(member.mtime)): + _reject("member-invalid") + if require_canonical: + _reject("canonical-metadata-invalid") + + +def _validated_allowlist(names: Sequence[str], limit: int) -> tuple[str, ...]: + if isinstance(names, (str, bytes)): + _reject("allowlist-invalid") + try: + result = tuple(names) + except TypeError: + _reject("allowlist-invalid") + if not result or len(result) > limit: + _reject("allowlist-invalid") + aliases: set[str] = set() + for name in result: + if not isinstance(name, str): + _reject("allowlist-invalid") + _validate_safe_path(name) + alias = _path_alias(name) + if alias in aliases: + _reject("allowlist-invalid") + aliases.add(alias) + return result + + +def _validate_observed_paths(names: Sequence[str]) -> None: + aliases: set[str] = set() + for name in names: + _validate_observed_path(name, aliases) + + +def _validate_observed_path(name: str, aliases: set[str]) -> None: + _validate_safe_path(name) + alias = _path_alias(name) + if alias in aliases: + _reject("path-invalid") + aliases.add(alias) + + +def _validate_safe_path(name: str) -> None: + if ( + not name + or len(name) > 240 + or name.startswith("/") + or name.endswith("/") + or "\\" in name + or "\x00" in name + ): + _reject("path-invalid") + try: + name.encode("ascii", errors="strict") + except UnicodeEncodeError: + _reject("path-invalid") + components = name.split("/") + for component in components: + stem = component.split(".", 1)[0].upper() + if ( + not component + or component in {".", ".."} + or component.endswith(".") + or _SAFE_COMPONENT.fullmatch(component) is None + or stem in _WINDOWS_RESERVED + ): + _reject("path-invalid") + + +def _path_alias(name: str) -> str: + return "/".join(component.casefold().rstrip(". ") for component in name.split("/")) + + +def _sdist_member_plan(files: tuple[str, ...]) -> tuple[tuple[str, bool], ...]: + directories = {SDIST_ROOT} + full_files: set[str] = set() + for relative in files: + full = f"{SDIST_ROOT}/{relative}" + full_files.add(full) + components = full.split("/") + for length in range(1, len(components)): + directories.add("/".join(components[:length])) + if directories & full_files: + _reject("allowlist-invalid") + names = directories | full_files + if len(names) > MAX_SDIST_MEMBERS: + _reject("allowlist-invalid") + return tuple((name, name in directories) for name in sorted(names)) + + +def _open_outer(path: str | os.PathLike[str]) -> BinaryIO: + try: + descriptor = os.open(path, os.O_RDONLY | _no_follow_flag()) + except OSError: + raise DistributionContractError("archive-invalid") from None + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + _reject("archive-invalid") + if metadata.st_size > MAX_OUTER_SIZE: + _reject("archive-too-large") + return os.fdopen(descriptor, "rb", closefd=True) + except BaseException: + os.close(descriptor) + raise + + +def _read_outer(stream: BinaryIO) -> tuple[bytes, str]: + digest = hashlib.sha256() + output = io.BytesIO() + size = 0 + stream.seek(0) + while chunk := stream.read(_CHUNK_SIZE): + size += len(chunk) + if size > MAX_OUTER_SIZE: + _reject("archive-too-large") + digest.update(chunk) + output.write(chunk) + return output.getvalue(), digest.hexdigest() + + +def _decompress_sdist(data: bytes) -> bytes: + decompressor = zlib.decompressobj(wbits=16 + zlib.MAX_WBITS) + try: + payload = decompressor.decompress(data, MAX_SDIST_STREAM + 1) + if len(payload) > MAX_SDIST_STREAM or decompressor.unconsumed_tail: + _reject("member-limit-exceeded") + payload += decompressor.flush(MAX_SDIST_STREAM - len(payload) + 1) + except zlib.error: + raise DistributionContractError("archive-invalid") from None + if len(payload) > MAX_SDIST_STREAM: + _reject("member-limit-exceeded") + if not decompressor.eof or decompressor.unused_data: + _reject("archive-invalid") + return payload + + +def _read_zip_member( + archive: zipfile.ZipFile, info: zipfile.ZipInfo +) -> tuple[bytes, str]: + try: + with archive.open(info, mode="r") as stream: + return _read_bounded(stream, info.file_size) + except (OSError, EOFError, RuntimeError, zipfile.BadZipFile): + raise DistributionContractError("archive-invalid") from None + + +def _read_bounded(stream: IO[bytes], expected_size: int) -> tuple[bytes, str]: + digest = hashlib.sha256() + output = io.BytesIO() + size = 0 + while chunk := stream.read(_CHUNK_SIZE): + size += len(chunk) + if size > MAX_MEMBER_SIZE or size > expected_size: + _reject("member-limit-exceeded") + digest.update(chunk) + output.write(chunk) + if size != expected_size: + _reject("member-invalid") + return output.getvalue(), digest.hexdigest() + + +def _validate_gzip_header(stream: BinaryIO) -> None: + stream.seek(0) + header = stream.read(10) + if ( + len(header) != 10 + or header[:3] != b"\x1f\x8b\x08" + or header[3] != 0 + or int.from_bytes(header[4:8], "little") != FIXED_MTIME + or header[8] != 2 + or header[9] != 255 + ): + _reject("canonical-metadata-invalid") + + +def _zip_timestamp(value: tuple[int, int, int, int, int, int]) -> int: + if value == FIXED_ZIP_TIME: + return FIXED_MTIME + # A stable sentinel is enough for noncanonical inspection records. + return 0 + + +def _canonical_decimal(value: str) -> bool: + return ( + bool(value) + and value.isascii() + and value.isdecimal() + and (value == "0" or not value.startswith("0")) + ) + + +def _write_canonical_wheel( + stream: BinaryIO, + members: Sequence[str], + payloads: Mapping[str, bytes], +) -> None: + with zipfile.ZipFile( + stream, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + strict_timestamps=True, + ) as archive: + for name in members: + info = zipfile.ZipInfo(name, date_time=FIXED_ZIP_TIME) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o644) << 16 + info.internal_attr = 0 + info.extra = b"" + info.comment = b"" + archive.writestr(info, payloads[name], compresslevel=9) + + +def _write_canonical_sdist( + stream: BinaryIO, + members: Sequence[tuple[str, bool]], + payloads: Mapping[str, bytes], +) -> None: + with ( + gzip.GzipFile( + filename="", + mode="wb", + fileobj=stream, + compresslevel=9, + mtime=FIXED_MTIME, + ) as compressed, + tarfile.open( + fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT + ) as archive, + ): + for full_name, is_directory in members: + info = tarfile.TarInfo(full_name) + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = FIXED_MTIME + if is_directory: + info.type = tarfile.DIRTYPE + info.mode = 0o755 + info.size = 0 + archive.addfile(info) + else: + data = payloads[full_name] + info.type = tarfile.REGTYPE + info.mode = 0o644 + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + + +def _require_canonical_bytes(observed: bytes, expected: bytes) -> None: + if not hmac.compare_digest(observed, expected): + _reject("canonical-metadata-invalid") + + +def _atomic_output(path: Path, writer: Callable[[BinaryIO], None]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w+b", closefd=True) as stream: + writer(stream) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o644, follow_symlinks=False) + if temporary.stat(follow_symlinks=False).st_size > MAX_OUTER_SIZE: + _reject("archive-too-large") + os.replace(temporary, path) + except DistributionContractError: + raise + except (OSError, ValueError, zipfile.BadZipFile, tarfile.TarError): + raise DistributionContractError("output-invalid") from None + finally: + with suppress(OSError): + temporary.unlink(missing_ok=True) + + +def _require_absent(path: Path) -> None: + try: + path.lstat() + except FileNotFoundError: + return + except OSError: + _reject("output-invalid") + _reject("output-invalid") + + +def _verify_materialized_parent(root: Path, parent: Path) -> None: + current = root + for component in parent.relative_to(root).parts: + current /= component + metadata = current.lstat() + if not stat.S_ISDIR(metadata.st_mode): + _reject("output-invalid") + + +def _no_follow_flag() -> int: + return getattr(os, "O_NOFOLLOW", 0) + + +def _reject(code: ErrorCode) -> NoReturn: + raise DistributionContractError(code) diff --git a/scripts/evidence_rendering.py b/scripts/evidence_rendering.py index 9963144..567148d 100644 --- a/scripts/evidence_rendering.py +++ b/scripts/evidence_rendering.py @@ -342,7 +342,11 @@ def write_setup_svg(path: Path) -> None: "Install project", ("pip install -e '.[dev]'", "pinned top-level dev tools"), ), - ("03", "Run gates", ("make check", "lint · types · tests")), + ( + "03", + "Run gates", + ("make check", "lint · types · tests", "distribution · evidence"), + ), ) nodes: list[str] = [] arrows: list[str] = [] @@ -419,6 +423,140 @@ def write_setup_svg(path: Path) -> None: ) +def write_distribution_svg( + path: Path, + *, + input_sha256: str, + wheel_sha256: str, + sdist_sha256: str, +) -> None: + """Render the measured Git-input-to-installed-wheel attestation flow.""" + + digests = (input_sha256, wheel_sha256, sdist_sha256) + if any( + len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + for digest in digests + ): + raise ValueError("distribution evidence requires lowercase SHA-256 values") + + nodes = ( + _svg_node( + x=55, + y=205, + width=310, + height=162, + index="1", + title="Immutable inputs", + details=( + "15 stage-zero Git blobs", + f"sha256 {input_sha256[:16]}…", + "normalized modes + mtime", + ), + ), + _svg_node( + x=430, + y=205, + width=310, + height=162, + index="2", + title="Build A + B", + details=( + "setuptools 83.0.0", + "fixed epoch · umask 0022", + "package index disabled", + ), + ), + _svg_node( + x=805, + y=145, + width=350, + height=162, + index="3W", + title="Canonical wheel", + details=( + "17 exact members · 0644", + f"sha256 {wheel_sha256[:16]}…", + "two raw wheels byte-equal", + ), + accent=GOLD, + ), + _svg_node( + x=805, + y=380, + width=350, + height=162, + index="3S", + title="Canonical sdist", + details=( + "23 files + 6 directories", + f"sha256 {sdist_sha256[:16]}…", + "raw sdist equality unclaimed", + ), + accent=GOLD, + ), + _svg_node( + x=1220, + y=265, + width=310, + height=162, + index="4", + title="Sdist rebuild", + details=( + "safe manual materialization", + "canonical wheel byte-equal", + "no extractall", + ), + ), + _svg_node( + x=1595, + y=265, + width=310, + height=162, + index="5", + title="Installed smoke", + details=( + "offline pip --target", + "external cwd + exact origin", + "inspect only · no sample", + ), + ), + ) + arrows = f""" + + + + + + + + +""" + body = f""" The release archive is measured, normalized, rebuilt, then installed + Every value comes from the real project-specific attestation; raw backend artifacts are never presented as canonical releases. +{"".join(nodes)} +{arrows} + + Claim boundary + No license, signature, dependency-integrity, cross-platform, or arbitrary-archive guarantee. + The smoke uses current pinned checker dependencies; it is not a fresh dependency environment. + official: false""" + path.write_text( + _svg_document( + title="Reproducible distribution attestation flow", + description=( + "Fifteen immutable Git inputs feed two builds, canonical wheel " + "and source archives, a source-archive rebuild, and an installed " + "deterministic inspection smoke test." + ), + width=1960, + height=830, + body=body, + ), + encoding="utf-8", + ) + + def _wrapped_lines(transcript: str, width: int) -> list[str]: lines: list[str] = [] for line in transcript.rstrip("\n").splitlines(): diff --git a/scripts/generate_evidence.py b/scripts/generate_evidence.py index 83a24e7..46d377a 100644 --- a/scripts/generate_evidence.py +++ b/scripts/generate_evidence.py @@ -16,7 +16,7 @@ import threading import tomllib import xml.etree.ElementTree as ElementTree -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from contextlib import contextmanager from dataclasses import dataclass from io import StringIO @@ -25,6 +25,7 @@ from unittest.mock import patch from urllib.parse import urlsplit +import attest_distribution as distribution_attester from evidence_rendering import ( GOLD, LINE, @@ -35,6 +36,7 @@ recompress_png, render_terminal_png, write_architecture_svg, + write_distribution_svg, write_gif, write_sampling_svg, write_setup_svg, @@ -70,12 +72,16 @@ "PYTHONPATH=src python -m pytest --cov=password_policy_lab " "--cov-branch --cov-report=term-missing -q" ), + "python scripts/attest_distribution.py", "python -m pip check", ) +CHROMIUM_ARGS = ("--num-raster-threads=1",) OUTPUT_PATHS = ( "docs/assets/architecture.svg", "docs/assets/cli-inspect.png", + "docs/assets/distribution-check.png", + "docs/assets/distribution-contract.svg", "docs/assets/quality-gate.png", "docs/assets/setup-workflow.svg", "docs/assets/state-space-sweep.png", @@ -85,6 +91,8 @@ "docs/assets/web-invalid-length.png", "docs/assets/web-validation-demo.gif", "docs/evidence/cli-inspect.txt", + "docs/evidence/distribution-attestation.json", + "docs/evidence/distribution-check.txt", "docs/evidence/quality-gate.txt", "docs/evidence/state-space-sweep.csv", "docs/evidence/web-validation-reference.png", @@ -556,7 +564,9 @@ def _verify_setup_contract() -> None: raise RuntimeError("evidence dependencies are not pinned in the dev extra") makefile = (ROOT / "Makefile").read_text(encoding="utf-8") required_make_contract = ( - "check: lint typecheck test dependencies evidence-check", + "check: lint typecheck test dependencies distribution-check evidence-check", + "distribution-check:", + "scripts/attest_distribution.py", "evidence:", "scripts/generate_evidence.py", "evidence-check:", @@ -671,6 +681,24 @@ def _goto(page: Page, url: str, expected_status: int) -> None: _assert_no_output(page) +def _settle_rendering(page: Page) -> None: + page.evaluate( + """async () => { + await document.fonts.ready; + for (const animation of document.getAnimations()) { + try { + animation.finish(); + } catch { + animation.cancel(); + } + } + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + }""" + ) + + def _capture_full_document( page: Page, *, @@ -678,16 +706,17 @@ def _capture_full_document( width: int, viewport_height: int, ) -> None: + if page.viewport_size != {"width": width, "height": viewport_height}: + raise RuntimeError("full-document capture started from an invalid viewport") + _settle_rendering(page) scroll_height = cast( int, page.evaluate("() => Math.ceil(document.documentElement.scrollHeight)"), ) if not 1 <= scroll_height <= 16_000: raise RuntimeError("document height is outside the screenshot safety bound") - page.set_viewport_size({"width": width, "height": scroll_height}) page.evaluate("() => window.scrollTo(0, 0)") - page.screenshot(path=path, animations="disabled") - page.set_viewport_size({"width": width, "height": viewport_height}) + page.screenshot(path=path, animations="disabled", full_page=True) recompress_png(path) @@ -696,7 +725,10 @@ def _capture_web_evidence() -> _CaptureResult: external_requests: list[str] = [] with _guarded_server() as server: with sync_playwright() as playwright: - browser = playwright.chromium.launch(headless=True) + browser = playwright.chromium.launch( + headless=True, + args=list(CHROMIUM_ARGS), + ) chromium_version = browser.version desktop = _new_context(browser, width=1440, height=960) @@ -746,6 +778,7 @@ def _capture_web_evidence() -> _CaptureResult: "() => window.scrollTo(0, " "document.querySelector('.workspace').offsetTop - 12)" ) + _settle_rendering(page) page.screenshot( path=ASSET_DIR / "web-home-mobile.png", animations="disabled", @@ -828,9 +861,11 @@ def _capture_web_evidence() -> _CaptureResult: "() => window.scrollTo(0, " "document.querySelector('.workspace').offsetTop - 12)" ) + _settle_rendering(page) frames = [page.screenshot(animations="disabled")] page.locator("#length").fill("7") page.locator("#length").evaluate("(element) => element.blur()") + _settle_rendering(page) frames.append(page.screenshot(animations="disabled")) with page.expect_navigation(wait_until="networkidle") as navigation: page.locator("form").evaluate( @@ -845,6 +880,7 @@ def _capture_web_evidence() -> _CaptureResult: "() => window.scrollTo(0, " "document.querySelector('.workspace').offsetTop - 12)" ) + _settle_rendering(page) frames.append(page.screenshot(animations="disabled")) fidelity = write_gif( frames=frames, @@ -888,6 +924,61 @@ def _capture_web_evidence() -> _CaptureResult: ) +def _distribution_mapping(value: object, label: str) -> Mapping[str, object]: + if type(value) is not dict: + raise RuntimeError(f"distribution report field is not an object: {label}") + return cast(dict[str, object], value) + + +def _distribution_digest(value: object, label: str) -> str: + if type(value) is not str or re.fullmatch(r"[0-9a-f]{64}", value) is None: + raise RuntimeError(f"distribution report digest is invalid: {label}") + return value + + +def _write_distribution_evidence() -> None: + """Run one real attestation and render every derivative from that result.""" + + document = distribution_attester.attest(ROOT) + artifacts = _distribution_mapping(document.get("artifacts"), "artifacts") + wheel = _distribution_mapping(artifacts.get("wheel"), "artifacts.wheel") + sdist = _distribution_mapping(artifacts.get("sdist"), "artifacts.sdist") + source = _distribution_mapping(document.get("source"), "source") + input_sha256 = _distribution_digest( + source.get("distribution_input_sha256"), + "source.distribution_input_sha256", + ) + wheel_sha256 = _distribution_digest(wheel.get("sha256"), "wheel.sha256") + sdist_sha256 = _distribution_digest(sdist.get("sha256"), "sdist.sha256") + + json_text = distribution_attester._canonical_json(document) + transcript = ( + "$ python scripts/attest_distribution.py\n" + + distribution_attester._text_report(document) + ) + if _ABSOLUTE_PATH.search(json_text) or _ABSOLUTE_PATH.search(transcript): + raise RuntimeError("distribution evidence contains an absolute machine path") + (EVIDENCE_DIR / "distribution-attestation.json").write_text( + json_text, + encoding="utf-8", + ) + (EVIDENCE_DIR / "distribution-check.txt").write_text( + transcript, + encoding="utf-8", + ) + render_terminal_png( + transcript=transcript, + title="Distribution attestation · canonical archives verified", + path=ASSET_DIR / "distribution-check.png", + ) + write_distribution_svg( + ASSET_DIR / "distribution-contract.svg", + input_sha256=input_sha256, + wheel_sha256=wheel_sha256, + sdist_sha256=sdist_sha256, + ) + + def _normalize_quality_output(output: str) -> str: normalized = output.replace("\r\n", "\n").replace(str(ROOT), ".") normalized = _PYTEST_DURATION.sub(r"\1", normalized) @@ -929,6 +1020,7 @@ def _quality_invocations() -> tuple[tuple[str, ...], ...]: "--cov-report=term-missing", "-q", ), + (str(PYTHON), "scripts/attest_distribution.py"), (str(PYTHON), "-m", "pip", "check"), ) @@ -959,7 +1051,9 @@ def _write_quality_evidence(transcript: str) -> None: def _source_paths() -> list[Path]: paths = [ + ROOT / "MANIFEST.in", ROOT / "Makefile", + ROOT / "PACKAGE.md", ROOT / "README.md", ROOT / "app.py", ROOT / "pyproject.toml", @@ -1011,6 +1105,14 @@ def _artifact_assertions() -> dict[str, list[str]]: "rendered from exact deterministic CLI transcript", "sampled candidate absent", ], + "docs/assets/distribution-check.png": [ + "rendered from the real distribution attestation transcript", + "canonical wheel, sdist rebuild, and installed smoke passed", + ], + "docs/assets/distribution-contract.svg": [ + "rendered from measured distribution hashes and member counts", + "claim boundaries remain explicit", + ], "docs/assets/quality-gate.png": [ "rendered from normalized real gate transcript", "all commands exited zero", @@ -1049,6 +1151,14 @@ def _artifact_assertions() -> dict[str, list[str]]: "real CLI output", "timestamp and absolute path absent", ], + "docs/evidence/distribution-attestation.json": [ + "canonical path-free report from two real builds", + "exact archive inventories and honest claim boundaries", + ], + "docs/evidence/distribution-check.txt": [ + "real normalized attestation output", + "no password sampled", + ], "docs/evidence/quality-gate.txt": [ "real normalized command output", "timing and absolute path absent", @@ -1069,6 +1179,7 @@ def _media_type(path: Path) -> str: return { ".csv": "text/csv", ".gif": "image/gif", + ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".txt": "text/plain", @@ -1125,6 +1236,7 @@ def _manifest( return { "artifacts": _artifact_manifest(), "capture": { + "chromium_launch_args": list(CHROMIUM_ARGS), "requests": capture.requests, "sampler_calls": capture.sampler_calls, "sampling_guard": "raise-on-call", @@ -1181,7 +1293,10 @@ def _write_manifest(manifest: dict[str, object]) -> None: def _provisional_quality_transcript() -> str: existing = EVIDENCE_DIR / "quality-gate.txt" if existing.is_file() and (ASSET_DIR / "quality-gate.png").is_file(): - return existing.read_text(encoding="utf-8") + transcript = existing.read_text(encoding="utf-8") + offsets = [transcript.find(command) for command in QUALITY_COMMANDS] + if all(offset >= 0 for offset in offsets) and offsets == sorted(offsets): + return transcript return ( "\n".join( f"$ {command}\nprovisional evidence graph ready" @@ -1201,6 +1316,7 @@ def main() -> int: write_architecture_svg(ASSET_DIR / "architecture.svg") write_sampling_svg(ASSET_DIR / "uniform-sampling-flow.svg") write_setup_svg(ASSET_DIR / "setup-workflow.svg") + _write_distribution_evidence() sweep_rows = _write_cli_evidence() _render_sweep_chart(sweep_rows) @@ -1234,7 +1350,10 @@ def main() -> int: raise RuntimeError("normalized quality gate did not reach a fixed point") if (EVIDENCE_DIR / "quality-gate.txt").read_text(encoding="utf-8") != second_gate: raise RuntimeError("committed quality transcript differs from verified gate") - print("Evidence rebuilt and verified: 14 artifacts + canonical manifest.") + print( + f"Evidence rebuilt and verified: {len(OUTPUT_PATHS)} artifacts + " + "canonical manifest." + ) return 0 diff --git a/tests/test_distribution_attestation.py b/tests/test_distribution_attestation.py new file mode 100644 index 0000000..71a8b46 --- /dev/null +++ b/tests/test_distribution_attestation.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from types import SimpleNamespace +from typing import Protocol, cast + +import pytest + + +class _IndexEntry(Protocol): + path: str + object_id: str + + +class _SourceState(Protocol): + tree: str + + +class _Attester(Protocol): + AttestationError: type[ValueError] + DISTRIBUTION_INPUTS: tuple[str, ...] + PACKAGE_INPUTS: tuple[str, ...] + SDIST_FILES: tuple[str, ...] + WHEEL_MEMBERS: tuple[str, ...] + + def _parse_index_entries(self, raw: bytes) -> tuple[_IndexEntry, ...]: ... + + def _parse_tree_entries(self, raw: bytes) -> tuple[_IndexEntry, ...]: ... + + def _canonical_input_digest( + self, + files: Sequence[tuple[str, bytes]], + ) -> str: ... + + def _process_environment( + self, + temporary_root: Path, + *, + python_path: Path | None = None, + ) -> dict[str, str]: ... + + def _prepare_environment_directories( + self, + environment: Mapping[str, str], + ) -> None: ... + + def _validate_work_root(self, repository: Path, raw_path: str) -> Path: ... + + def _repository_argument(self, raw_path: str) -> Path: ... + + def _validate_working_package_inventory(self, repository: Path) -> None: ... + + def _collect_source(self, repository: Path) -> _SourceState: ... + + def _artifact_document(self, record: object) -> dict[str, object]: ... + + def _validate_claim_guards( + self, + *, + raw_wheels_equal: bool, + canonical_wheels_equal: bool, + canonical_sdists_equal: bool, + rebuilt_wheel_equal: bool, + smoke_passed: bool, + ) -> None: ... + + def _canonical_json(self, document: Mapping[str, object]) -> str: ... + + def run( + self, + argv: Sequence[str], + *, + stdout: io.StringIO, + stderr: io.StringIO, + repository: Path | None = None, + ) -> int: ... + + +def _load_attester() -> _Attester: + script_directory = Path(__file__).resolve().parents[1] / "scripts" + script_path = script_directory / "attest_distribution.py" + spec = importlib.util.spec_from_file_location( + "portfolio_distribution_attester", + script_path, + ) + if spec is None or spec.loader is None: + raise RuntimeError("could not load the distribution attester") + inserted = str(script_directory) + sys.path.insert(0, inserted) + try: + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + finally: + sys.path.remove(inserted) + return cast(_Attester, module) + + +attest_distribution = _load_attester() + + +def _index_record(path: str, *, mode: str = "100644", stage: str = "0") -> bytes: + return f"{mode} {'a' * 40} {stage}\t{path}".encode("ascii") + b"\0" + + +def _tree_record(path: str, *, object_id: str | None = None) -> bytes: + digest = "a" * 40 if object_id is None else object_id + return f"100644 blob {digest}\t{path}".encode("ascii") + b"\0" + + +def _prepare_source_fixture(repository: Path) -> dict[str, bytes]: + source_root = Path(__file__).resolve().parents[1] + (repository / ".git").mkdir() + payloads: dict[str, bytes] = {} + for relative in attest_distribution.DISTRIBUTION_INPUTS: + data = source_root.joinpath(*relative.split("/")).read_bytes() + destination = repository.joinpath(*relative.split("/")) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + payloads[relative] = data + return payloads + + +def _git_blob_id(data: bytes) -> str: + header = f"blob {len(data)}\0".encode("ascii") + return hashlib.sha1(header + data, usedforsecurity=False).hexdigest() + + +def test_static_release_contract_has_exact_bounded_inventories() -> None: + assert len(attest_distribution.DISTRIBUTION_INPUTS) == 15 + assert len(attest_distribution.PACKAGE_INPUTS) == 12 + assert len(attest_distribution.WHEEL_MEMBERS) == 17 + assert len(attest_distribution.SDIST_FILES) == 23 + assert set(attest_distribution.PACKAGE_INPUTS).issubset( + attest_distribution.DISTRIBUTION_INPUTS + ) + assert len(set(attest_distribution.WHEEL_MEMBERS)) == 17 + assert len(set(attest_distribution.SDIST_FILES)) == 23 + + +def test_index_parser_accepts_only_regular_stage_zero_ascii_entries() -> None: + raw = _index_record("MANIFEST.in") + _index_record("PACKAGE.md") + + entries = attest_distribution._parse_index_entries(raw) + + assert [(entry.path, entry.object_id) for entry in entries] == [ + ("MANIFEST.in", "a" * 40), + ("PACKAGE.md", "a" * 40), + ] + + +@pytest.mark.parametrize( + "raw", + [ + _index_record("MANIFEST.in", mode="100755"), + _index_record("MANIFEST.in", stage="2"), + _index_record("../MANIFEST.in"), + _index_record("MANIFEST.in") + _index_record("MANIFEST.in"), + b"100644 not-an-object 0\tMANIFEST.in\0", + b"100644 " + (b"a" * 40) + b" 0\tPACKAGE-\xff.md\0", + _index_record("MANIFEST.in")[:-1], + ], +) +def test_index_parser_rejects_ambiguous_or_nonregular_entries(raw: bytes) -> None: + with pytest.raises(attest_distribution.AttestationError, match="index-invalid"): + attest_distribution._parse_index_entries(raw) + + +def test_source_collection_rejects_index_movement_after_immutable_snapshot( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + payloads = _prepare_source_fixture(tmp_path) + object_ids = {path: _git_blob_id(data) for path, data in payloads.items()} + by_object_id = {object_ids[path]: data for path, data in payloads.items()} + tree_document = b"".join( + _tree_record(path, object_id=object_ids[path]) + for path in attest_distribution.DISTRIBUTION_INPUTS + ) + source_tree = "1" * 40 + moved_tree = "2" * 40 + write_tree_calls = 0 + cat_file_targets: list[str] = [] + + def fake_git( + repository: Path, + arguments: Sequence[str], + *, + maximum_output: int = 2 * 1024 * 1024, + ) -> bytes: + nonlocal write_tree_calls + del maximum_output + assert repository == tmp_path + command = tuple(arguments) + if command == ("rev-parse", "--show-toplevel"): + return f"{tmp_path}\n".encode() + if command == ("write-tree",): + write_tree_calls += 1 + tree = source_tree if write_tree_calls == 1 else moved_tree + return f"{tree}\n".encode("ascii") + if command[:4] == ("ls-tree", "-r", "-z", source_tree): + return tree_document + if command[:2] == ("cat-file", "blob"): + cat_file_targets.append(command[2]) + return by_object_id[command[2]] + if command[:3] == ("ls-files", "--others", "--exclude-standard"): + return b"" + raise AssertionError(f"unexpected Git command: {command!r}") + + monkeypatch.setattr("portfolio_distribution_attester._git", fake_git) + + with pytest.raises( + attest_distribution.AttestationError, + match="source-dirty", + ): + attest_distribution._collect_source(tmp_path) + + assert write_tree_calls == 2 + assert set(cat_file_targets) == set(by_object_id) + assert all(not target.startswith(":") for target in cat_file_targets) + + +def test_source_collection_reads_only_the_immutable_index_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + payloads = _prepare_source_fixture(tmp_path) + object_ids = {path: _git_blob_id(data) for path, data in payloads.items()} + by_object_id = {object_ids[path]: data for path, data in payloads.items()} + tree_document = b"".join( + _tree_record(path, object_id=object_ids[path]) + for path in attest_distribution.DISTRIBUTION_INPUTS + ) + source_tree = "4" * 40 + tree_targets: list[str] = [] + + def fake_git( + repository: Path, + arguments: Sequence[str], + *, + maximum_output: int = 2 * 1024 * 1024, + ) -> bytes: + del maximum_output + assert repository == tmp_path + command = tuple(arguments) + if command == ("rev-parse", "--show-toplevel"): + return f"{tmp_path}\n".encode() + if command == ("write-tree",): + return f"{source_tree}\n".encode("ascii") + if command[:3] == ("ls-tree", "-r", "-z"): + tree_targets.append(command[3]) + if command[3] != source_tree: + raise AssertionError("tree lookup used a mutable ref") + return tree_document + if command[:2] == ("cat-file", "blob"): + return by_object_id[command[2]] + if command[:3] == ("ls-files", "--others", "--exclude-standard"): + return b"" + raise AssertionError(f"unexpected Git command: {command!r}") + + monkeypatch.setattr("portfolio_distribution_attester._git", fake_git) + + source = attest_distribution._collect_source(tmp_path) + + assert source.tree == source_tree + assert tree_targets == [source_tree] + + +def test_input_digest_is_order_independent_but_boundary_sensitive() -> None: + files = [ + (path, f"payload:{path}".encode()) + for path in attest_distribution.DISTRIBUTION_INPUTS + ] + + forward = attest_distribution._canonical_input_digest(files) + reversed_order = attest_distribution._canonical_input_digest(list(reversed(files))) + changed = list(files) + path, payload = changed[4] + changed[4] = (path, payload + b"\x00") + + assert forward == reversed_order + assert forward != attest_distribution._canonical_input_digest(changed) + assert len(forward) == 64 + + +def test_input_digest_rejects_missing_duplicate_and_unknown_inputs() -> None: + files = [(path, b"x") for path in attest_distribution.DISTRIBUTION_INPUTS] + invalid = ( + files[:-1], + [*files[:-1], files[0]], + [*files[:-1], ("src/password_policy_lab/extra.py", b"x")], + ) + + for candidate in invalid: + with pytest.raises( + attest_distribution.AttestationError, + match="index-invalid", + ): + attest_distribution._canonical_input_digest(candidate) + + +def test_subprocess_environment_is_allowlisted_and_prepares_private_dirs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "must-not-survive") + monkeypatch.setenv("PYTHONPATH", "/host/source") + monkeypatch.setenv("HOME", "/host/home") + environment_root = tmp_path / "environment" + target = tmp_path / "target" + + environment = attest_distribution._process_environment( + environment_root, + python_path=target, + ) + attest_distribution._prepare_environment_directories(environment) + + assert "GITHUB_TOKEN" not in environment + assert environment["PYTHONPATH"] == str(target) + assert environment["PIP_NO_INDEX"] == "1" + assert environment["PYTHONHASHSEED"] == "0" + assert environment["SOURCE_DATE_EPOCH"].isascii() + assert environment["TZ"] == "UTC" + assert set(environment) == { + "HOME", + "LANG", + "LC_ALL", + "PATH", + "PIP_CONFIG_FILE", + "PIP_DISABLE_PIP_VERSION_CHECK", + "PIP_NO_INDEX", + "PYTHONDONTWRITEBYTECODE", + "PYTHONHASHSEED", + "PYTHONNOUSERSITE", + "PYTHONPATH", + "SOURCE_DATE_EPOCH", + "TMPDIR", + "TZ", + } + assert Path(environment["HOME"]).stat().st_mode & 0o777 == 0o700 + assert Path(environment["TMPDIR"]).stat().st_mode & 0o777 == 0o700 + + +def test_work_root_accepts_repo_local_relative_and_absolute_paths( + tmp_path: Path, +) -> None: + relative = attest_distribution._validate_work_root(tmp_path, "relative/work") + absolute_path = tmp_path / "absolute" / "work" + absolute = attest_distribution._validate_work_root( + tmp_path, + absolute_path.as_posix(), + ) + + assert relative == tmp_path / "relative" / "work" + assert absolute == absolute_path + assert relative.is_dir() + assert absolute.is_dir() + + +def test_work_root_rejects_escape_and_existing_symlink( + tmp_path: Path, +) -> None: + target = tmp_path / "target" + target.mkdir() + linked = tmp_path / "linked" + linked.symlink_to(target, target_is_directory=True) + + with pytest.raises( + attest_distribution.AttestationError, + match="work-root-invalid", + ): + attest_distribution._validate_work_root( + tmp_path, + (tmp_path.parent / "outside").as_posix(), + ) + with pytest.raises( + attest_distribution.AttestationError, + match="work-root-invalid", + ): + attest_distribution._validate_work_root( + tmp_path, + (linked / "child").as_posix(), + ) + + +def test_repository_argument_requires_canonical_absolute_nonsymlink_path( + tmp_path: Path, +) -> None: + repository = tmp_path / "repository" + repository.mkdir() + linked = tmp_path / "repository-link" + linked.symlink_to(repository, target_is_directory=True) + + assert attest_distribution._repository_argument(repository.as_posix()) == repository + with pytest.raises( + attest_distribution.AttestationError, + match="arguments-invalid", + ): + attest_distribution._repository_argument("relative/repository") + with pytest.raises( + attest_distribution.AttestationError, + match="arguments-invalid", + ): + attest_distribution._repository_argument(linked.as_posix()) + + +def test_working_package_inventory_allows_only_exact_sources_and_bytecode_cache( + tmp_path: Path, +) -> None: + for relative in attest_distribution.PACKAGE_INPUTS: + path = tmp_path.joinpath(*relative.split("/")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"fixture") + cache = tmp_path / "src/password_policy_lab/__pycache__" + cache.mkdir() + (cache / "cli.cpython-312.pyc").write_bytes(b"cache") + + attest_distribution._validate_working_package_inventory(tmp_path) + + unexpected = tmp_path / "src/password_policy_lab/ignored_extra.py" + unexpected.write_bytes(b"unexpected") + with pytest.raises( + attest_distribution.AttestationError, + match="index-invalid", + ): + attest_distribution._validate_working_package_inventory(tmp_path) + + +def test_artifact_inventory_document_is_path_free_and_exact() -> None: + member = SimpleNamespace( + name="password_policy_lab/__init__.py", + size=7, + sha256="a" * 64, + compressed_size=9, + mode=0o100644, + mtime=1_704_067_200, + ) + record = SimpleNamespace( + kind="wheel", + size=123, + sha256="b" * 64, + members=(member,), + ) + + document = attest_distribution._artifact_document(record) + encoded = attest_distribution._canonical_json(document) + + assert document == { + "inventory": [ + { + "compressed_size": 9, + "mode": "0644", + "mtime": 1_704_067_200, + "name": "password_policy_lab/__init__.py", + "sha256": "a" * 64, + "size": 7, + } + ], + "member_count": 1, + "sha256": "b" * 64, + "size": 123, + } + assert encoded.endswith("\n") + assert "/home/" not in encoded + assert list(json.loads(encoded)) == [ + "inventory", + "member_count", + "sha256", + "size", + ] + + +@pytest.mark.parametrize( + ( + "raw_wheels_equal", + "canonical_wheels_equal", + "canonical_sdists_equal", + "rebuilt_wheel_equal", + "smoke_passed", + "failure_code", + ), + [ + (False, True, True, True, True, "artifact-mismatch"), + (True, False, True, True, True, "artifact-mismatch"), + (True, True, False, True, True, "artifact-mismatch"), + (True, True, True, False, True, "artifact-mismatch"), + (True, True, True, True, False, "smoke-failed"), + ], +) +def test_claim_guards_reject_unproven_positive_report_flags( + raw_wheels_equal: bool, + canonical_wheels_equal: bool, + canonical_sdists_equal: bool, + rebuilt_wheel_equal: bool, + smoke_passed: bool, + failure_code: str, +) -> None: + with pytest.raises( + attest_distribution.AttestationError, + match=failure_code, + ): + attest_distribution._validate_claim_guards( + raw_wheels_equal=raw_wheels_equal, + canonical_wheels_equal=canonical_wheels_equal, + canonical_sdists_equal=canonical_sdists_equal, + rebuilt_wheel_equal=rebuilt_wheel_equal, + smoke_passed=smoke_passed, + ) + + +def test_json_cli_emits_canonical_unofficial_claim_boundaries( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + report: dict[str, object] = { + "claim_boundaries": { + "arbitrary_archive_safety": False, + "artifact_signature_verified": False, + "cross_platform_reproducibility": False, + "dependency_integrity_verified": False, + "fresh_dependency_environment": False, + "license_declared": False, + }, + "official": False, + "schema_version": 1, + } + + def fake_attest(repository: Path, *, work_root: str) -> dict[str, object]: + assert repository == tmp_path + assert work_root == ".evidence-work/distribution" + return report + + monkeypatch.setattr( + "portfolio_distribution_attester.attest", + fake_attest, + ) + stdout = io.StringIO() + stderr = io.StringIO() + + status = attest_distribution.run( + ["--format", "json"], + stdout=stdout, + stderr=stderr, + repository=tmp_path, + ) + + assert status == 0 + assert stderr.getvalue() == "" + assert stdout.getvalue() == attest_distribution._canonical_json(report) + payload = json.loads(stdout.getvalue()) + assert payload["official"] is False + assert not any(payload["claim_boundaries"].values()) + + +@pytest.mark.parametrize( + ("arguments", "expected_code"), + [ + (["--format", "yaml"], "arguments-invalid"), + (["--format", "json", "--format", "text"], "arguments-invalid"), + (["--root", "/tmp/elsewhere"], "arguments-invalid"), + (["--root", "/tmp/a", "--root", "/tmp/b"], "arguments-invalid"), + ], +) +def test_cli_returns_stable_argument_failure_codes( + tmp_path: Path, + arguments: list[str], + expected_code: str, +) -> None: + stdout = io.StringIO() + stderr = io.StringIO() + + status = attest_distribution.run( + arguments, + stdout=stdout, + stderr=stderr, + repository=tmp_path, + ) + + assert status == 1 + assert stdout.getvalue() == "" + assert stderr.getvalue() == ( + f"error: distribution attestation rejected: {expected_code}\n" + ) + + +def test_cli_maps_attestation_failure_without_leaking_values( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def failed_attest(repository: Path, *, work_root: str) -> dict[str, object]: + del repository, work_root + raise attest_distribution.AttestationError("build-failed") + + monkeypatch.setattr( + "portfolio_distribution_attester.attest", + failed_attest, + ) + stdout = io.StringIO() + stderr = io.StringIO() + + status = attest_distribution.run( + [], + stdout=stdout, + stderr=stderr, + repository=tmp_path, + ) + + assert status == 1 + assert stdout.getvalue() == "" + assert stderr.getvalue() == ( + "error: distribution attestation rejected: build-failed\n" + ) + assert str(tmp_path) not in stderr.getvalue() diff --git a/tests/test_distribution_contract.py b/tests/test_distribution_contract.py new file mode 100644 index 0000000..01b971e --- /dev/null +++ b/tests/test_distribution_contract.py @@ -0,0 +1,905 @@ +from __future__ import annotations + +import base64 +import gzip +import hashlib +import importlib.util +import io +import stat +import struct +import sys +import tarfile +import zipfile +from collections.abc import Callable, Iterator, Mapping, Sequence +from contextlib import contextmanager +from functools import partial +from pathlib import Path +from types import ModuleType +from typing import Any, Protocol, cast + +import pytest + + +class _ContractError(Protocol): + code: str + + +def _load_contract() -> ModuleType: + path = Path(__file__).resolve().parents[1] / "scripts/distribution_contract.py" + spec = importlib.util.spec_from_file_location( + "distribution_contract_for_tests", path + ) + if spec is None or spec.loader is None: + raise RuntimeError("could not load distribution contract") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +contract = cast(Any, _load_contract()) + +DESCRIPTION = b"# Installable package\n\nA compact package description.\n" +PAYLOADS = contract.ProjectPayloads(contract.build_expected_metadata(DESCRIPTION)) +WHEEL_FILES = ( + "password_policy_lab/__init__.py", + f"{contract.DIST_INFO}/METADATA", + f"{contract.DIST_INFO}/WHEEL", + f"{contract.DIST_INFO}/entry_points.txt", + f"{contract.DIST_INFO}/top_level.txt", + f"{contract.DIST_INFO}/RECORD", +) +SDIST_FILES = ( + "PACKAGE.md", + "PKG-INFO", + "src/password_policy_lab/__init__.py", +) + + +def _wheel_payloads( + replacements: Mapping[str, bytes] | None = None, +) -> dict[str, bytes]: + result = { + "password_policy_lab/__init__.py": b'__version__ = "0.1.0"\n', + f"{contract.DIST_INFO}/METADATA": PAYLOADS.metadata, + f"{contract.DIST_INFO}/WHEEL": PAYLOADS.wheel, + f"{contract.DIST_INFO}/entry_points.txt": PAYLOADS.entry_points, + f"{contract.DIST_INFO}/top_level.txt": PAYLOADS.top_level, + } + if replacements is not None: + result.update(replacements) + return result + + +def _record(order: Sequence[str], payloads: Mapping[str, bytes]) -> bytes: + rows: list[str] = [] + for name in order: + if name == f"{contract.DIST_INFO}/RECORD": + rows.append(f"{name},,\n") + continue + data = payloads[name] + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=") + rows.append(f"{name},sha256={digest.decode('ascii')},{len(data)}\n") + return "".join(rows).encode("ascii") + + +def _write_wheel( + path: Path, + *, + order: Sequence[str] = WHEEL_FILES, + replacements: Mapping[str, bytes] | None = None, + canonical: bool = True, + mode: int = stat.S_IFREG | 0o644, + compression: int = zipfile.ZIP_DEFLATED, + extra: bytes = b"", + member_comment: bytes = b"", + archive_comment: bytes = b"", + record_override: bytes | None = None, +) -> None: + payloads = _wheel_payloads(replacements) + payloads[f"{contract.DIST_INFO}/RECORD"] = ( + _record(order, payloads) if record_override is None else record_override + ) + with zipfile.ZipFile(path, "w", compression=compression) as archive: + archive.comment = archive_comment + for name in order: + info = zipfile.ZipInfo( + name, + date_time=( + contract.FIXED_ZIP_TIME if canonical else (2025, 2, 2, 2, 2, 2) + ), + ) + info.create_system = 3 + info.external_attr = mode << 16 + info.compress_type = compression + info.extra = extra + info.comment = member_comment + archive.writestr(info, payloads[name], compresslevel=9) + + +def _sdist_plan(files: Sequence[str] = SDIST_FILES) -> tuple[tuple[str, bool], ...]: + directories = {contract.SDIST_ROOT} + regular = {f"{contract.SDIST_ROOT}/{name}" for name in files} + for name in regular: + parts = name.split("/") + directories.update("/".join(parts[:index]) for index in range(1, len(parts))) + return tuple((name, name in directories) for name in sorted(directories | regular)) + + +def _sdist_payloads( + replacements: Mapping[str, bytes] | None = None, +) -> dict[str, bytes]: + result = { + f"{contract.SDIST_ROOT}/PACKAGE.md": DESCRIPTION, + f"{contract.SDIST_ROOT}/PKG-INFO": PAYLOADS.metadata, + f"{contract.SDIST_ROOT}/src/password_policy_lab/__init__.py": ( + b'__version__ = "0.1.0"\n' + ), + } + if replacements is not None: + result.update(replacements) + return result + + +TarMutator = Callable[[tarfile.TarInfo, bool], None] + + +def _write_sdist( + path: Path, + *, + files: Sequence[str] = SDIST_FILES, + replacements: Mapping[str, bytes] | None = None, + canonical: bool = True, + plan: Sequence[tuple[str, bool]] | None = None, + mutate: TarMutator | None = None, + tar_format: int = tarfile.USTAR_FORMAT, + gzip_name: str = "", + global_pax: Mapping[str, str] | None = None, +) -> None: + payloads = _sdist_payloads(replacements) + selected_plan = _sdist_plan(files) if plan is None else tuple(plan) + with ( + path.open("wb") as output, + gzip.GzipFile( + filename=gzip_name, + fileobj=output, + mode="wb", + compresslevel=9, + mtime=contract.FIXED_MTIME if canonical else 1_700_000_000, + ) as compressed, + tarfile.open( + fileobj=compressed, + mode="w", + format=tar_format, + pax_headers={} if global_pax is None else dict(global_pax), + ) as archive, + ): + for name, is_directory in selected_plan: + info = tarfile.TarInfo(name) + info.type = tarfile.DIRTYPE if is_directory else tarfile.REGTYPE + info.mode = (0o755 if is_directory else 0o644) if canonical else 0o600 + info.uid = 0 if canonical else 1000 + info.gid = 0 if canonical else 1000 + info.uname = "" if canonical else "builder" + info.gname = "" if canonical else "builder" + info.mtime = contract.FIXED_MTIME if canonical else 1_700_000_000 + data = b"" if is_directory else payloads[name] + info.size = len(data) + if mutate is not None: + mutate(info, is_directory) + archive.addfile(info, None if info.isdir() else io.BytesIO(data)) + + +def _assert_rejected( + operation: Callable[[], object], code: str | None = None +) -> _ContractError: + with pytest.raises(contract.DistributionContractError) as captured: + operation() + error = cast(_ContractError, captured.value) + if code is not None: + assert error.code == code + assert str(error) == f"distribution contract rejected: {error.code}" + assert "tmp" not in str(error).lower() + return error + + +def _inspect_fixture_wheel(path: Path) -> None: + contract.inspect_wheel(path, WHEEL_FILES, PAYLOADS) + + +def _inspect_fixture_sdist_noncanonical(path: Path) -> None: + contract.inspect_sdist(path, SDIST_FILES, PAYLOADS, require_canonical=False) + + +@contextmanager +def _patched_constant(name: str, value: int) -> Iterator[None]: + original = getattr(contract, name) + setattr(contract, name, value) + try: + yield + finally: + setattr(contract, name, original) + + +def test_canonical_wheel_inspection_records_streamed_facts(tmp_path: Path) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel) + + result = contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS) + + assert result.kind == "wheel" + assert result.size == wheel.stat().st_size + assert result.sha256 == hashlib.sha256(wheel.read_bytes()).hexdigest() + assert tuple(member.name for member in result.members) == WHEEL_FILES + assert all(member.mode == stat.S_IFREG | 0o644 for member in result.members) + assert all(member.mtime == contract.FIXED_MTIME for member in result.members) + + +@pytest.mark.parametrize( + "unsafe", + ( + "../escape.py", + "/absolute.py", + "nested\\windows.py", + "nested//empty.py", + "nested/./dot.py", + "nested/../parent.py", + "nul\x00suffix.py", + "caf\N{LATIN SMALL LETTER E WITH ACUTE}.py", + "NUL.txt", + "trailing./file.py", + ), +) +def test_path_contract_rejects_nonportable_names(tmp_path: Path, unsafe: str) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel) + names = (unsafe, *WHEEL_FILES[1:]) + + _assert_rejected( + lambda: contract.inspect_wheel(wheel, names, PAYLOADS), "path-invalid" + ) + + +def test_path_contract_rejects_duplicates_and_case_aliases(tmp_path: Path) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel) + duplicate = (*WHEEL_FILES[:-1], WHEEL_FILES[0]) + alias = ( + WHEEL_FILES[0], + WHEEL_FILES[0].upper(), + *WHEEL_FILES[1:], + ) + + _assert_rejected( + lambda: contract.inspect_wheel(wheel, duplicate, PAYLOADS), + "allowlist-invalid", + ) + _assert_rejected( + lambda: contract.inspect_wheel(wheel, alias, PAYLOADS), "allowlist-invalid" + ) + + +def test_zip_nul_name_is_rejected_even_when_zipfile_truncates_it( + tmp_path: Path, +) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel) + data = wheel.read_bytes() + original = WHEEL_FILES[0].encode("ascii") + corrupted = original.replace(b"_", b"\x00", 1) + assert len(corrupted) == len(original) + wheel.write_bytes(data.replace(original, corrupted)) + + _assert_rejected(lambda: contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS)) + + +@pytest.mark.parametrize( + ("variant", "code"), + ( + ("link", "member-invalid"), + ("extra", "member-invalid"), + ("member-comment", "member-invalid"), + ("archive-comment", "canonical-metadata-invalid"), + ("compression", "member-invalid"), + ), +) +def test_wheel_rejects_links_extras_comments_and_unsupported_compression( + tmp_path: Path, variant: str, code: str +) -> None: + wheel = tmp_path / "package.whl" + if variant == "link": + _write_wheel(wheel, mode=stat.S_IFLNK | 0o777) + elif variant == "extra": + _write_wheel(wheel, extra=b"\x01\x00\x00\x00") + elif variant == "member-comment": + _write_wheel(wheel, member_comment=b"comment") + elif variant == "archive-comment": + _write_wheel(wheel, archive_comment=b"comment") + else: + _write_wheel(wheel, compression=zipfile.ZIP_BZIP2) + + _assert_rejected(lambda: contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS), code) + + +def test_wheel_rejects_encryption_flag_before_reading(tmp_path: Path) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel) + data = bytearray(wheel.read_bytes()) + local = data.index(b"PK\x03\x04") + central = data.index(b"PK\x01\x02") + struct.pack_into( + " None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel, replacements={"password_policy_lab/__init__.py": b"aaaa"}) + with _patched_constant("MAX_MEMBER_SIZE", 3): + _assert_rejected( + lambda: contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS), + "member-limit-exceeded", + ) + with _patched_constant("MAX_COMPRESSION_RATIO", 1): + _assert_rejected( + lambda: contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS), + "compression-invalid", + ) + + +def test_outer_file_must_be_small_regular_and_not_a_symlink(tmp_path: Path) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel) + link = tmp_path / "linked.whl" + link.symlink_to(wheel.name) + directory = tmp_path / "archive-directory" + directory.mkdir() + + _assert_rejected(lambda: contract.inspect_wheel(link, WHEEL_FILES, PAYLOADS)) + _assert_rejected(lambda: contract.inspect_wheel(directory, WHEEL_FILES, PAYLOADS)) + with _patched_constant("MAX_OUTER_SIZE", wheel.stat().st_size - 1): + _assert_rejected( + lambda: contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS), + "archive-too-large", + ) + + +def test_wheel_requires_exact_member_order(tmp_path: Path) -> None: + wheel = tmp_path / "package.whl" + changed_order = (WHEEL_FILES[1], WHEEL_FILES[0], *WHEEL_FILES[2:]) + _write_wheel(wheel, order=changed_order) + + _assert_rejected( + lambda: contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS), + "allowlist-invalid", + ) + + +@pytest.mark.parametrize( + "record", + ( + b"", + b"password_policy_lab/__init__.py,sha256=bad,1\n", + b"password_policy_lab/__init__.py,,\n", + ), +) +def test_wheel_rejects_malformed_or_incomplete_record( + tmp_path: Path, record: bytes +) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel, record_override=record) + + _assert_rejected( + lambda: contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS), "record-invalid" + ) + + +def test_wheel_rejects_record_digest_size_and_self_hash_corruption( + tmp_path: Path, +) -> None: + good = _wheel_payloads() + good_record = _record(WHEEL_FILES, good) + lines = good_record.splitlines(keepends=True) + first_path, first_digest, first_size = lines[0].removesuffix(b"\n").split(b",") + wrong_size = ( + b",".join((first_path, first_digest, str(int(first_size) + 1).encode("ascii"))) + + b"\n" + + b"".join(lines[1:]) + ) + replacement = b"A" if first_digest[-1:] != b"A" else b"B" + wrong_digest = ( + b",".join((first_path, first_digest[:-1] + replacement, first_size)) + + b"\n" + + b"".join(lines[1:]) + ) + mutations = ( + wrong_size, + wrong_digest, + good_record.replace(b"RECORD,,", b"RECORD,sha256=bad,1"), + ) + for index, mutation in enumerate(mutations): + wheel = tmp_path / f"package-{index}.whl" + _write_wheel(wheel, record_override=mutation) + + _assert_rejected( + partial(_inspect_fixture_wheel, wheel), + "record-invalid", + ) + + +def test_wheel_rejects_noncanonical_base64url_record_digest(tmp_path: Path) -> None: + wheel = tmp_path / "package.whl" + good_record = _record(WHEEL_FILES, _wheel_payloads()) + lines = good_record.splitlines(keepends=True) + first_path, first_digest, first_size = lines[0].removesuffix(b"\n").split(b",") + alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + encoded = bytearray(first_digest.removeprefix(b"sha256=")) + last_index = alphabet.index(encoded[-1]) + encoded[-1] = alphabet[(last_index & 0b110000) | ((last_index + 1) & 0b001111)] + assert ( + base64.urlsafe_b64decode(encoded + b"=") + == hashlib.sha256(_wheel_payloads()[WHEEL_FILES[0]]).digest() + ) + mutation = ( + b",".join((first_path, b"sha256=" + encoded, first_size)) + + b"\n" + + b"".join(lines[1:]) + ) + _write_wheel(wheel, record_override=mutation) + + _assert_rejected(partial(_inspect_fixture_wheel, wheel), "record-invalid") + + +@pytest.mark.parametrize( + "name", + ( + f"{contract.DIST_INFO}/METADATA", + f"{contract.DIST_INFO}/WHEEL", + f"{contract.DIST_INFO}/entry_points.txt", + f"{contract.DIST_INFO}/top_level.txt", + ), +) +def test_wheel_rejects_project_metadata_corruption(tmp_path: Path, name: str) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel, replacements={name: b"corrupted\n"}) + + _assert_rejected( + lambda: contract.inspect_wheel(wheel, WHEEL_FILES, PAYLOADS), + "metadata-invalid", + ) + + +def test_wheel_canonicalization_is_deterministic(tmp_path: Path) -> None: + first_raw = tmp_path / "first-raw.whl" + second_raw = tmp_path / "second-raw.whl" + _write_wheel(first_raw, canonical=False) + _write_wheel(second_raw, canonical=False, mode=stat.S_IFREG | 0o600) + first = tmp_path / "first.whl" + second = tmp_path / "second.whl" + + first_record = contract.canonicalize_wheel(first_raw, first, WHEEL_FILES, PAYLOADS) + second_record = contract.canonicalize_wheel( + second_raw, second, WHEEL_FILES, PAYLOADS + ) + + assert first.read_bytes() == second.read_bytes() + assert first_record.sha256 == second_record.sha256 + assert contract.inspect_wheel(first, WHEEL_FILES, PAYLOADS) == first_record + + +@pytest.mark.parametrize("mutation", ("prefix", "suffix", "local-time")) +def test_canonical_wheel_rejects_bytes_outside_its_exact_container_profile( + tmp_path: Path, mutation: str +) -> None: + wheel = tmp_path / "package.whl" + _write_wheel(wheel) + data = bytearray(wheel.read_bytes()) + if mutation == "prefix": + data[:0] = b"self-extracting-prefix" + elif mutation == "suffix": + data.extend(b"trailing-junk") + else: + local = data.index(b"PK\x03\x04") + struct.pack_into(" None: + raw = tmp_path / "raw.tar.gz" + canonical = tmp_path / "canonical.tar.gz" + _write_sdist(raw, canonical=False, gzip_name="raw-name") + result = contract.canonicalize_sdist(raw, canonical, SDIST_FILES, PAYLOADS) + + assert result.kind == "sdist" + assert tuple(member.name for member in result.members) == tuple( + name for name, _ in _sdist_plan() + ) + with tarfile.open(canonical, "r:gz") as archive: + members = archive.getmembers() + assert all(member.uid == member.gid == 0 for member in members) + assert all(member.uname == member.gname == "" for member in members) + assert all(member.mtime == contract.FIXED_MTIME for member in members) + assert all( + member.mode == (0o755 if member.isdir() else 0o644) for member in members + ) + + +@pytest.mark.parametrize( + ("field", "value"), + ( + ("mode", 0o600), + ("uid", 1), + ("gid", 1), + ("uname", "builder"), + ("gname", "builder"), + ("mtime", contract.FIXED_MTIME + 1), + ), +) +def test_sdist_rejects_noncanonical_member_metadata( + tmp_path: Path, field: str, value: object +) -> None: + archive = tmp_path / "package.tar.gz" + + def mutate(info: tarfile.TarInfo, is_directory: bool) -> None: + if not is_directory and info.name.endswith("PACKAGE.md"): + setattr(info, field, value) + + _write_sdist(archive, mutate=mutate) + + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "canonical-metadata-invalid", + ) + + +def test_sdist_requires_sorted_exact_rooted_plan(tmp_path: Path) -> None: + archive = tmp_path / "package.tar.gz" + reversed_plan = tuple(reversed(_sdist_plan())) + _write_sdist(archive, plan=reversed_plan) + + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "allowlist-invalid", + ) + + +@pytest.mark.parametrize("member_type", (tarfile.SYMTYPE, tarfile.CHRTYPE)) +def test_sdist_rejects_links_and_devices(tmp_path: Path, member_type: bytes) -> None: + archive = tmp_path / "package.tar.gz" + + def mutate(info: tarfile.TarInfo, is_directory: bool) -> None: + if not is_directory and info.name.endswith("__init__.py"): + info.type = member_type + if member_type == tarfile.SYMTYPE: + info.linkname = "../../escape" + info.size = 0 + else: + info.devmajor = 1 + info.devminor = 3 + info.size = 0 + + _write_sdist(archive, mutate=mutate) + + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "member-invalid", + ) + + +def test_sdist_rejects_pax_path_overrides(tmp_path: Path) -> None: + archive = tmp_path / "package.tar.gz" + + def mutate(info: tarfile.TarInfo, is_directory: bool) -> None: + if not is_directory and info.name.endswith("PACKAGE.md"): + info.pax_headers = {"path": info.name} + + _write_sdist(archive, mutate=mutate, tar_format=tarfile.PAX_FORMAT) + + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "member-invalid", + ) + + +def test_raw_sdist_accepts_backend_pax_mtime_then_canonicalizes( + tmp_path: Path, +) -> None: + raw = tmp_path / "raw.tar.gz" + canonical = tmp_path / "canonical.tar.gz" + + def add_backend_mtime(info: tarfile.TarInfo, is_directory: bool) -> None: + value = "1700000000.0" if is_directory else "1700000000.125" + info.pax_headers = {"mtime": value} + + _write_sdist( + raw, + canonical=False, + mutate=add_backend_mtime, + tar_format=tarfile.PAX_FORMAT, + ) + + raw_result = contract.inspect_sdist( + raw, SDIST_FILES, PAYLOADS, require_canonical=False + ) + canonical_result = contract.canonicalize_sdist( + raw, canonical, SDIST_FILES, PAYLOADS + ) + + assert raw_result.kind == canonical_result.kind == "sdist" + assert raw_result.sha256 != canonical_result.sha256 + with tarfile.open(canonical, "r:gz") as accepted: + assert all(not member.pax_headers for member in accepted) + assert contract.inspect_sdist(canonical, SDIST_FILES, PAYLOADS) == canonical_result + + +def test_canonical_sdist_still_rejects_an_otherwise_valid_pax_mtime( + tmp_path: Path, +) -> None: + archive = tmp_path / "package.tar.gz" + + def add_mtime(info: tarfile.TarInfo, is_directory: bool) -> None: + del is_directory + info.pax_headers = {"mtime": f"{contract.FIXED_MTIME}.0"} + + _write_sdist(archive, mutate=add_mtime, tar_format=tarfile.PAX_FORMAT) + + contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS, require_canonical=False) + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "canonical-metadata-invalid", + ) + + +@pytest.mark.parametrize( + "value", + ( + "01700000000.0", + "+1700000000.0", + "1700000000.00", + "1700000000.", + "1700000000e0", + "NaN", + "Infinity", + "-1", + "4102444801", + "1700000000.123456789", + ), +) +def test_raw_sdist_rejects_noncanonical_or_unbounded_pax_mtime( + tmp_path: Path, value: str +) -> None: + archive = tmp_path / "package.tar.gz" + + def add_mtime(info: tarfile.TarInfo, is_directory: bool) -> None: + if not is_directory and info.name.endswith("PACKAGE.md"): + info.pax_headers = {"mtime": value} + + _write_sdist(archive, mutate=add_mtime, tar_format=tarfile.PAX_FORMAT) + + _assert_rejected( + lambda: contract.inspect_sdist( + archive, SDIST_FILES, PAYLOADS, require_canonical=False + ) + ) + + +def test_raw_sdist_rejects_extra_and_global_pax_keys(tmp_path: Path) -> None: + member_archive = tmp_path / "member.tar.gz" + global_archive = tmp_path / "global.tar.gz" + + def add_extra_key(info: tarfile.TarInfo, is_directory: bool) -> None: + if not is_directory and info.name.endswith("PACKAGE.md"): + info.pax_headers = { + "mtime": "1700000000.0", + "atime": "1700000000.0", + } + + _write_sdist(member_archive, mutate=add_extra_key, tar_format=tarfile.PAX_FORMAT) + _write_sdist( + global_archive, + tar_format=tarfile.PAX_FORMAT, + global_pax={"comment": "unexpected"}, + ) + + for archive in (member_archive, global_archive): + _assert_rejected(partial(_inspect_fixture_sdist_noncanonical, archive)) + + +def test_sdist_rejects_gnu_sparse_metadata(tmp_path: Path) -> None: + archive = tmp_path / "package.tar.gz" + + def mutate(info: tarfile.TarInfo, is_directory: bool) -> None: + if not is_directory and info.name.endswith("PACKAGE.md"): + info.pax_headers = { + "GNU.sparse.map": f"0,{info.size}", + "GNU.sparse.size": str(info.size), + } + + _write_sdist(archive, mutate=mutate, tar_format=tarfile.PAX_FORMAT) + + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "member-invalid", + ) + + +def test_sdist_enforces_member_and_payload_limits_with_tiny_files( + tmp_path: Path, +) -> None: + archive = tmp_path / "package.tar.gz" + _write_sdist(archive) + with _patched_constant("MAX_MEMBER_SIZE", len(PAYLOADS.metadata) - 1): + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "member-limit-exceeded", + ) + with _patched_constant("MAX_SDIST_PAYLOAD", len(PAYLOADS.metadata)): + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "member-limit-exceeded", + ) + + +def test_sdist_rejects_an_oversized_header_before_advancing_past_its_body( + tmp_path: Path, +) -> None: + archive = tmp_path / "oversized-header.tar.gz" + root = tarfile.TarInfo(contract.SDIST_ROOT) + root.type = tarfile.DIRTYPE + root.mode = 0o755 + root.size = 0 + oversized = tarfile.TarInfo(f"{contract.SDIST_ROOT}/PACKAGE.md") + oversized.type = tarfile.REGTYPE + oversized.mode = 0o644 + oversized.size = contract.MAX_MEMBER_SIZE + 1 + headers_only = root.tobuf(format=tarfile.USTAR_FORMAT) + oversized.tobuf( + format=tarfile.USTAR_FORMAT + ) + archive.write_bytes(gzip.compress(headers_only, mtime=contract.FIXED_MTIME)) + + _assert_rejected( + lambda: contract.inspect_sdist( + archive, SDIST_FILES, PAYLOADS, require_canonical=False + ), + "member-limit-exceeded", + ) + + +def test_sdist_rejects_pkg_info_corruption(tmp_path: Path) -> None: + archive = tmp_path / "package.tar.gz" + _write_sdist( + archive, + replacements={f"{contract.SDIST_ROOT}/PKG-INFO": b"corrupted\n"}, + ) + + _assert_rejected( + lambda: contract.inspect_sdist(archive, SDIST_FILES, PAYLOADS), + "metadata-invalid", + ) + + +def test_sdist_canonicalization_is_deterministic_and_has_no_gzip_name( + tmp_path: Path, +) -> None: + first_raw = tmp_path / "first-raw.tar.gz" + second_raw = tmp_path / "second-raw.tar.gz" + _write_sdist(first_raw, canonical=False, gzip_name="first-source") + _write_sdist(second_raw, canonical=False, gzip_name="second-source") + first = tmp_path / "first.tar.gz" + second = tmp_path / "second.tar.gz" + + first_record = contract.canonicalize_sdist(first_raw, first, SDIST_FILES, PAYLOADS) + second_record = contract.canonicalize_sdist( + second_raw, second, SDIST_FILES, PAYLOADS + ) + + assert first.read_bytes() == second.read_bytes() + assert first_record.sha256 == second_record.sha256 + header = first.read_bytes()[:10] + assert header[3] == 0 + assert int.from_bytes(header[4:8], "little") == contract.FIXED_MTIME + + +@pytest.mark.parametrize("mutation", ("crc", "size", "suffix")) +def test_sdist_rejects_invalid_or_trailing_gzip_container_bytes( + tmp_path: Path, mutation: str +) -> None: + archive = tmp_path / "package.tar.gz" + _write_sdist(archive) + data = bytearray(archive.read_bytes()) + if mutation == "crc": + data[-8] ^= 1 + elif mutation == "size": + data[-1] ^= 1 + else: + data.extend(b"trailing-junk") + archive.write_bytes(data) + + _assert_rejected( + lambda: contract.inspect_sdist( + archive, SDIST_FILES, PAYLOADS, require_canonical=False + ), + "archive-invalid", + ) + + +def test_materialize_canonical_sdist_writes_only_prevalidated_regular_files( + tmp_path: Path, +) -> None: + raw = tmp_path / "raw.tar.gz" + archive = tmp_path / "package.tar.gz" + destination = tmp_path / "materialized" + _write_sdist(raw, canonical=False) + expected = contract.canonicalize_sdist(raw, archive, SDIST_FILES, PAYLOADS) + + result = contract.materialize_canonical_sdist( + archive, destination, SDIST_FILES, PAYLOADS + ) + + assert result == expected + assert (destination / "PACKAGE.md").read_bytes() == DESCRIPTION + assert (destination / "PKG-INFO").read_bytes() == PAYLOADS.metadata + assert ( + destination / "src/password_policy_lab/__init__.py" + ).read_bytes() == b'__version__ = "0.1.0"\n' + assert all( + stat.S_IMODE(path.stat().st_mode) == (0o755 if path.is_dir() else 0o644) + for path in destination.rglob("*") + ) + _assert_rejected( + lambda: contract.materialize_canonical_sdist( + archive, destination, SDIST_FILES, PAYLOADS + ), + "output-invalid", + ) + + +def test_materialize_canonical_sdist_never_replaces_an_empty_destination( + tmp_path: Path, +) -> None: + raw = tmp_path / "raw.tar.gz" + archive = tmp_path / "package.tar.gz" + destination = tmp_path / "reserved" + _write_sdist(raw, canonical=False) + contract.canonicalize_sdist(raw, archive, SDIST_FILES, PAYLOADS) + destination.mkdir() + + _assert_rejected( + lambda: contract.materialize_canonical_sdist( + archive, destination, SDIST_FILES, PAYLOADS + ), + "output-invalid", + ) + assert destination.is_dir() + assert not tuple(destination.iterdir()) + + +def test_expected_metadata_builder_is_bounded_and_rejects_nul() -> None: + assert contract.build_expected_metadata(DESCRIPTION) == PAYLOADS.metadata + _assert_rejected( + lambda: contract.build_expected_metadata(b"bad\x00description"), + "metadata-invalid", + ) + with _patched_constant("MAX_MEMBER_SIZE", len(contract.EXPECTED_METADATA_PREFIX)): + _assert_rejected( + lambda: contract.build_expected_metadata(b"x"), "metadata-invalid" + ) diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 978d882..88cc4dc 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import importlib.util import json import struct @@ -45,6 +46,19 @@ def _expected_inspection(self, report: StateSpaceInspection) -> str: ... def _load_json(self, path: Path) -> tuple[dict[str, object], str]: ... + def _distribution_input_digest(self, root: Path) -> str: ... + + def _validate_distribution_attestation( + self, + root: Path, + document: dict[str, object], + json_text: str, + transcript: str, + diagram: str, + ) -> None: ... + + def _validate_capture(self, value: object) -> dict[str, tuple[int, int]]: ... + def _validate_ast_claims( self, root: Path, @@ -253,6 +267,105 @@ def test_manifest_loader_rejects_duplicates_and_noncanonical_json( check_evidence._load_json(path) +def test_distribution_attestation_is_source_bound_and_rejects_overclaim() -> None: + root = Path(__file__).resolve().parents[1] + path = root / "docs/evidence/distribution-attestation.json" + document, text = check_evidence._load_json(path) + transcript = (root / "docs/evidence/distribution-check.txt").read_text( + encoding="utf-8" + ) + diagram = (root / "docs/assets/distribution-contract.svg").read_text( + encoding="utf-8" + ) + + check_evidence._validate_distribution_attestation( + root, + document, + text, + transcript, + diagram, + ) + source = cast(dict[str, object], document["source"]) + assert set(source) == { + "distribution_input_count", + "distribution_input_sha256", + "git_index_stage", + } + assert source["distribution_input_sha256"] == ( + check_evidence._distribution_input_digest(root) + ) + + overclaimed = copy.deepcopy(document) + boundaries = cast(dict[str, object], overclaimed["claim_boundaries"]) + boundaries["license_declared"] = True + with pytest.raises( + check_evidence.EvidenceValidationError, + match="claim boundaries", + ): + check_evidence._validate_distribution_attestation( + root, + overclaimed, + text, + transcript, + diagram, + ) + + stale = copy.deepcopy(document) + stale_source = cast(dict[str, object], stale["source"]) + stale_source["distribution_input_sha256"] = "0" * 64 + with pytest.raises( + check_evidence.EvidenceValidationError, + match="input digest", + ): + check_evidence._validate_distribution_attestation( + root, + stale, + text, + transcript, + diagram, + ) + + +def test_distribution_attestation_rejects_self_referential_git_fields() -> None: + root = Path(__file__).resolve().parents[1] + document, text = check_evidence._load_json( + root / "docs/evidence/distribution-attestation.json" + ) + transcript = (root / "docs/evidence/distribution-check.txt").read_text( + encoding="utf-8" + ) + diagram = (root / "docs/assets/distribution-contract.svg").read_text( + encoding="utf-8" + ) + source = cast(dict[str, object], document["source"]) + source["git_index_tree"] = "a" * 40 + + with pytest.raises( + check_evidence.EvidenceValidationError, + match="unexpected schema", + ): + check_evidence._validate_distribution_attestation( + root, + document, + text, + transcript, + diagram, + ) + + +def test_capture_rejects_unpinned_parallel_rasterization() -> None: + root = Path(__file__).resolve().parents[1] + document, _ = check_evidence._load_json(root / check_evidence.MANIFEST_PATH) + capture = cast(dict[str, object], document["capture"]) + capture["chromium_launch_args"] = [] + + with pytest.raises( + check_evidence.EvidenceValidationError, + match="one raster thread", + ): + check_evidence._validate_capture(capture) + + def test_ast_claims_match_the_audited_core() -> None: root = Path(__file__).resolve().parents[1] textual = {