From 9529964d66f6a5937233b3e6175a531f77d2fd7d Mon Sep 17 00:00:00 2001 From: actools-pl Date: Fri, 11 Sep 2026 19:22:00 +0000 Subject: [PATCH 1/3] CP-002: strict configuration parser and canonical defaults --- CHANGELOG.md | 23 +- CONTRIBUTING.md | 14 +- NOTICE.md | 4 +- README.md | 8 +- ...iguration-contract-and-canonicalization.md | 67 ++ docs/development/source-ci.md | 81 +- docs/reference/configuration.md | 97 +++ pyproject.toml | 13 +- requirements/ci.in | 7 +- requirements/ci.lock | 201 ++++- src/actools/contracts/__init__.py | 34 + src/actools/contracts/canonical.py | 25 + src/actools/contracts/configuration.py | 711 ++++++++++++++++++ src/actools/contracts/errors.py | 16 + .../schemas/common-1.0.0.schema.json | 13 + .../schemas/configuration-1.0.0.schema.json | 38 + .../schemas/configuration-defaults-1.0.0.json | 15 + .../fixtures/configuration/invalid_alias.yaml | 5 + .../configuration/invalid_ambiguous.yaml | 4 + .../configuration/invalid_duplicate.json | 1 + .../configuration/invalid_duplicate.yaml | 3 + .../configuration/invalid_lone_surrogate.json | 1 + .../fixtures/configuration/invalid_merge.yaml | 6 + .../configuration/invalid_nonfinite.json | 1 + tests/fixtures/configuration/invalid_tag.yaml | 2 + .../configuration/invalid_unknown.json | 1 + .../fixtures/configuration/valid_minimal.json | 10 + .../fixtures/configuration/valid_minimal.yaml | 17 + .../configuration/valid_null_false.json | 9 + .../configuration/valid_null_false.yaml | 13 + tests/fixtures/jcs/cyberphone/PROVENANCE.md | 259 +++++++ .../fixtures/jcs/cyberphone/input/arrays.json | 8 + .../fixtures/jcs/cyberphone/input/french.json | 6 + .../jcs/cyberphone/input/structures.json | 8 + .../jcs/cyberphone/input/unicode.json | 3 + .../fixtures/jcs/cyberphone/input/values.json | 5 + .../fixtures/jcs/cyberphone/input/weird.json | 11 + .../jcs/cyberphone/output/arrays.json | 1 + .../jcs/cyberphone/output/french.json | 1 + .../jcs/cyberphone/output/structures.json | 1 + .../jcs/cyberphone/output/unicode.json | 1 + .../jcs/cyberphone/output/values.json | 1 + .../fixtures/jcs/cyberphone/output/weird.json | 1 + tests/test_configuration_contract.py | 425 +++++++++++ tests/test_jcs_conformance.py | 43 ++ tests/test_package_contents.py | 210 +++++- tools/check_source.py | 376 +++++++-- 47 files changed, 2656 insertions(+), 144 deletions(-) create mode 100644 docs/adr/0002-configuration-contract-and-canonicalization.md create mode 100644 docs/reference/configuration.md create mode 100644 src/actools/contracts/__init__.py create mode 100644 src/actools/contracts/canonical.py create mode 100644 src/actools/contracts/configuration.py create mode 100644 src/actools/contracts/errors.py create mode 100644 src/actools/contracts/schemas/common-1.0.0.schema.json create mode 100644 src/actools/contracts/schemas/configuration-1.0.0.schema.json create mode 100644 src/actools/contracts/schemas/configuration-defaults-1.0.0.json create mode 100644 tests/fixtures/configuration/invalid_alias.yaml create mode 100644 tests/fixtures/configuration/invalid_ambiguous.yaml create mode 100644 tests/fixtures/configuration/invalid_duplicate.json create mode 100644 tests/fixtures/configuration/invalid_duplicate.yaml create mode 100644 tests/fixtures/configuration/invalid_lone_surrogate.json create mode 100644 tests/fixtures/configuration/invalid_merge.yaml create mode 100644 tests/fixtures/configuration/invalid_nonfinite.json create mode 100644 tests/fixtures/configuration/invalid_tag.yaml create mode 100644 tests/fixtures/configuration/invalid_unknown.json create mode 100644 tests/fixtures/configuration/valid_minimal.json create mode 100644 tests/fixtures/configuration/valid_minimal.yaml create mode 100644 tests/fixtures/configuration/valid_null_false.json create mode 100644 tests/fixtures/configuration/valid_null_false.yaml create mode 100644 tests/fixtures/jcs/cyberphone/PROVENANCE.md create mode 100644 tests/fixtures/jcs/cyberphone/input/arrays.json create mode 100644 tests/fixtures/jcs/cyberphone/input/french.json create mode 100644 tests/fixtures/jcs/cyberphone/input/structures.json create mode 100644 tests/fixtures/jcs/cyberphone/input/unicode.json create mode 100644 tests/fixtures/jcs/cyberphone/input/values.json create mode 100644 tests/fixtures/jcs/cyberphone/input/weird.json create mode 100644 tests/fixtures/jcs/cyberphone/output/arrays.json create mode 100644 tests/fixtures/jcs/cyberphone/output/french.json create mode 100644 tests/fixtures/jcs/cyberphone/output/structures.json create mode 100644 tests/fixtures/jcs/cyberphone/output/unicode.json create mode 100644 tests/fixtures/jcs/cyberphone/output/values.json create mode 100644 tests/fixtures/jcs/cyberphone/output/weird.json create mode 100644 tests/test_configuration_contract.py create mode 100644 tests/test_jcs_conformance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e57ff3..5927f7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,21 @@ ### Added -- CP-001 development package skeleton for the fresh-install rewrite, with no runtime dependencies. -- Truthful `actools version [--format human|json]`, controlled help delivery and bounded non-reflective exit-3 invocation errors; no installer or future command stubs are exposed. -- Narrow `Source CI` workflow and canonical source checker with exact workflow-shape enforcement, pinned action identities, hash-locked tooling input, complete scanner-coverage policy, closed wheel shape, installed-launcher/import-origin checks and reason-bound negative fixtures. -- Root license/notices, contributor/agent/security guidance and the initial package/source-CI ADR. +- CP-001 development package skeleton for the fresh-install rewrite and truthful `actools version`/help/error behavior. +- CP-001 narrow `Source CI` workflow and canonical fail-closed source checker with immutable action pins, locked tooling, complete scanner-coverage policy, closed wheel shape and installed-launcher checks. +- CP-002 configuration contract `1.0.0` for the `single-site-production` profile, with Draft 2020-12 schemas, strict UTF-8 JSON and restricted Actools YAML parsing, finite resource limits, explicit semantic checks and non-reflective errors. +- CP-002 omission-only canonical defaults with deterministic JSON-Pointer origin records using only `operator` and `release_default`. +- CP-002 RFC 8785 canonical bytes for resolved configuration and resolution envelopes, with six pinned Apache-2.0 JCS conformance-vector pairs and recorded provenance. + +### Changed + +- The Python distribution now declares exactly three runtime dependencies: `PyYAML==6.0.3`, `jsonschema==4.26.0`, and `rfc8785==0.1.4`. +- Source/package checks now expect the `actools.contracts` subpackage and its three packaged JSON contract resources, and installed-wheel verification uses a fresh isolated venv whose dependency closure is hash-installed from the actual generated `requirements/ci.lock` before the candidate wheel is installed with dependency resolution disabled; the exact CP-001 CLI and unchanged Source CI workflow authority are preserved. ### Known limits -- This is a development skeleton, not a working Drupal installer or release. -- Source CI is configured by this candidate but has not run on GitHub before publication. -- The CP-001 lock must be regenerated/compared with the declared pip-tools/Python environment before remote publication because the coordinator build environment could not reach the package index. -- No CP-001 result qualifies servers, Drupal behavior, restore, signing, release or product-wide security gates. +- This remains a development package, not a working Drupal installer or production-qualified release. +- CP-002 introduces no CLI configuration command, wizard, filesystem publication, host discovery, journal, plan/apply operation, privileged execution, secret loading/generation or network/server effect. +- The CP-002 `requirements/ci.lock` must be resolver-generated and verified by the Human-Git operator under the accepted Ubuntu Server 26.04.1 / CPython 3.14.7 / pip 26.2.1 / pip-tools 7.6.1 contract. A coding environment that cannot reproduce that environment must not fabricate the lock. +- JCS vector SHA-256 values in provenance require independent Human-Git receipt recomputation before the final candidate review. +- No CP-002 result by itself qualifies servers, Drupal behavior, restore, signing, release support or any product-wide gate. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c1d1c6..e821e5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,11 +2,11 @@ This project is built as small, reviewed slices. Start with `AGENTS.md`, `coding/00_START_HERE_CODER_DOCUMENTATION.md`, the active architecture and the current task card. Use synthetic data only; do not place credentials or private diagnostics in source, issues, logs or prompts. -## CP-001 development environment +## Source-CI development environment -The reproducible source-CI target is **CPython 3.14.7 on Ubuntu 26.04 LTS x86_64**. Local E01 qualification uses Ubuntu Server 26.04.1 LTS; hosted source CI uses GitHub's `ubuntu-26.04` x64 runner label. This aligns source checks with architecture D01, but source CI alone does not qualify the installed host or full product stack. +The reproducible source-CI target is **CPython 3.14.7 on Ubuntu 26.04 LTS x86_64**. The accepted local reference environment is Ubuntu Server 26.04.1 LTS; hosted source CI uses GitHub's `ubuntu-26.04` x64 runner label. This aligns source checks with architecture D01, but source CI alone does not qualify the installed host or full product stack. -Create an isolated environment with CPython 3.14.7, then install the hash-locked source tools: +Create an isolated environment with CPython 3.14.7, then install the reviewed hash lock: ```text python -m venv .venv @@ -15,15 +15,15 @@ python -m pip install --require-hashes -r requirements/ci.lock python tools/check_source.py ``` -On Windows, activate the environment using its `Scripts` directory; the current CI lock is deliberately bound to the Ubuntu x86_64 runner, so use an equivalent Ubuntu/WSL environment for exact source-CI reproduction rather than weakening hashes. +On Windows, activate the environment using its `Scripts` directory; the final CI lock must be generated and qualified for the Ubuntu x86_64 source-test target, so use an equivalent Ubuntu/WSL environment for exact source-CI reproduction rather than weakening hashes. -Direct source inputs are in `requirements/ci.in`. The committed lock was generated from those exact bytes with CPython 3.14.7, pip 26.2.1 and `pip-tools==7.6.1` on the Ubuntu 26.04.1 x86_64 E01 host. Regenerate and compare under that contract before changing any pinned dependency. The lock header records the truthful generation command; the external E01 receipt binds the OS/interpreter/tool versions, input/output digests and semantic comparison. +Direct source inputs are in `requirements/ci.in`. CP-002 retains the five CP-001 source/test pins and adds exactly three runtime pins: `PyYAML==6.0.3`, `jsonschema==4.26.0`, and `rfc8785==0.1.4`. Generate `requirements/ci.lock` from those exact bytes only under the documented CPython 3.14.7 / Ubuntu Server 26.04.1 x86_64 contract with `pip==26.2.1` and `pip-tools==7.6.1`; do not hand-edit resolver hashes or reuse the CP-001 lock after changing direct inputs. See `docs/development/source-ci.md`. ## What the canonical check establishes -It verifies exact Python/tool versions and hash-closed inputs; the complete fixed source-workflow shape; unit and reason-bound controlled negative fixtures; a closed wheel inventory and metadata; the actual installed `actools` launcher plus import origin; truthful version/help and fixed non-reflective exit-3 invocation errors; real-pipe output failure; and exact complete `pip-audit==2.10.1` dependency coverage. Required execution that is unavailable, incomplete or errors remains a failed check. +It verifies exact Python/tool/runtime versions, `pip check`, and hash-closed inputs; the complete fixed source-workflow shape; strict configuration/parser/default/JCS tests; unit and reason-bound controlled negative fixtures; the closed wheel inventory and exact runtime metadata; the actual installed `actools` launcher plus import origin and packaged schema resource; truthful version/help and fixed non-reflective exit-3 invocation errors; real-pipe output failure; and exact complete `pip-audit==2.10.1` dependency coverage. Required execution that is unavailable, incomplete or errors remains a failed check. -A passing source check is not a release, server, Drupal, backup/restore or production qualification. Do not mark G01/G09/G22 or other product-wide gates PASS from CP-001 source fixtures. +A passing source check is not a release, server, Drupal, backup/restore or production qualification. CP-002 contract validity also does not mark G03/G09 or any other product-wide gate PASS. ## Review and integration diff --git a/NOTICE.md b/NOTICE.md index d85c502..53a6ea8 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,7 +1,9 @@ # Notices -This repository is the independent **actDrupal** installer project. Source added by CP-001 is licensed under the root MIT License and retains the Actools 2026 notice. +This repository is the independent **actDrupal** installer project. Actools-authored source is licensed under the root MIT License and retains the Actools 2026 notice. Drupal, Open Source components installed later, operating-system packages, container images, and other third-party software retain their own licenses and notices. Their presence in a future installation does not relicense those components under this repository's MIT License. CP-001 contains no imported runtime source from the historical `actoolsDrupal` repository. Any later third-party or inherited source must record its provenance and applicable license before integration. + +CP-002 vendors only six compact JSON Canonicalization Scheme conformance input/output pairs from `cyberphone/json-canonicalization` commit `19d51d7fe467d4706a3ff08adf8a748f29fc21e0`. That upstream vector/reference material is Copyright 2018 Anders Rundgren and licensed under the Apache License, Version 2.0. Exact source paths, local byte digests and the applicable Apache-2.0 notice are recorded in `tests/fixtures/jcs/cyberphone/PROVENANCE.md`. No upstream implementation source is vendored by CP-002. diff --git a/README.md b/README.md index c35020d..b759812 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,10 @@ Secure Drupal installation and operations toolkit being developed from the beginning in https://github.com/actools-pl/actDrupal. -**Status:** repository bootstrap only. The installer is not implemented or qualified. No installation command or production-ready release is available from this initial commit. +**Status:** pre-alpha development. CP-001 established the minimal Python package, truthful `actools version`/help behavior, and fail-closed source CI. The current unreleased CP-002 source adds a strict data-only configuration contract library; it does **not** add an installation command, wizard, host mutation, or production-ready release. -The current product scope is fresh installations only. Existing-site migration and legacy backup imports are excluded. Backup, restore, disaster recovery and supported updates for sites created by the new installer are planned requirements. +The current product scope is fresh installations only. Existing-site migration and legacy backup imports are excluded. Backup, restore, disaster recovery and supported updates for sites created by the new installer remain planned requirements. -Coding Package v1.3 and the active Architecture v1.5.1 are included under `coding/`. Start with the [coder documentation](coding/00_START_HERE_CODER_DOCUMENTATION.md). BOOT-001 imports and reviews this workflow; installer implementation remains pending. Product implementation begins with CP-001 only after the documentation import is reviewed, merged and its integration checks pass. +CP-002 configuration contract `1.0.0` supports only profile `single-site-production`. It parses strict UTF-8 JSON or the restricted Actools YAML subset, applies omission-only release defaults with explicit origins, validates the closed schema and semantic relationships, and exposes RFC 8785 canonical bytes. Contract-valid configuration is not installation or production readiness. See `docs/reference/configuration.md` and ADR-0002. + +Coding Package v1.3 and the active Architecture v1.5.1 are included under `coding/`. Start with the [coder documentation](coding/00_START_HERE_CODER_DOCUMENTATION.md), the current task card, and the exact task base before changing source. diff --git a/docs/adr/0002-configuration-contract-and-canonicalization.md b/docs/adr/0002-configuration-contract-and-canonicalization.md new file mode 100644 index 0000000..3abb49a --- /dev/null +++ b/docs/adr/0002-configuration-contract-and-canonicalization.md @@ -0,0 +1,67 @@ +# ADR-0002 — Strict configuration contract and RFC 8785 canonicalization + +Status: **candidate for independent CP-002 review; not accepted, merged or published by this coding delivery.** + +## Context + +CP-002 is the first WP02 configuration-contract slice after the accepted CP-001 package/source-CI baseline. Architecture v1.5.1 requires one canonical data model, strict schema ownership, omission/null/false fidelity, explicit default origins and identical data for later preview/authorization/apply consumers. This slice must remain a pure library: it adds no CLI command, wizard, host discovery, filesystem publication, journal, plan/apply operation, privileged execution, secret loading/generation or network/server effect. + +The activation `CP002-ACT-32ecc89-v1` fixes configuration contract `1.0.0`, Draft 2020-12, profile `single-site-production`, RFC 8785 and runtime dependencies `PyYAML==6.0.3`, `jsonschema==4.26.0`, `rfc8785==0.1.4`. +The reviewed `rfc8785` tag `v0.1.4` resolves to upstream commit `4d9b161f6054301d98d0566e813d020fb019ee10`; the dependency is consumed as a pinned package, not vendored source. + +## Decision + +### One bounded configuration shape + +The contract uses these closed groups: + +- `schema_version`, `profile`; +- `installation.id`; +- `site.id`, `site.domain`; +- `environment.id`, `environment.type`; +- `platform.os_profile`, `platform.runtime_backend`; +- `capabilities.selected_ids` with only `valkey-cache` and `cloudflare-standard-proxy` in this version; +- `host.management_endpoint`, `host.filesystem_authority_id`, and the three accepted access-policy identifiers; +- `drupal.storage`, `drupal.cache.mode`, `drupal.ingress.mode`; +- `recovery.rpo_minutes`, `recovery.rto_minutes`; +- `monitoring` cadence plus `email`/`telegram` notification enable/reference records; +- `secrets.database_credentials` as a reference only; +- `references.policy`, `references.release`. + +Every object is closed with `additionalProperties: false`. The schema expresses structure; `configuration.py` owns the finite cross-field checks: Valkey mode and capability must agree, standard Cloudflare ingress and capability must agree, an enabled notification requires a secret reference, and a disabled notification cannot retain one. These relationships assert configuration consistency only; they do not claim the capability is implemented or production-qualified. + +### Strict parser boundary + +JSON is bytes-first strict UTF-8 with duplicate-key detection before dictionary collapse, non-finite rejection, safe-integer bounds and a Unicode-scalar walk. Restricted YAML uses PyYAML only for syntax/tokens/events. Actools constructs the data model itself and rejects anchors, aliases, tags, merges, directives, multiple documents, duplicate/non-string keys, implicit empty nulls, YAML 1.1 boolean variants, dates/timestamps, non-finite values and YAML-only numeric forms. Quoted YAML remains string; only lowercase `null`/`true`/`false` and JSON-number lexical forms receive non-string types. + +Resource limits are fixed at 1 MiB input, 32 container levels (root container is level 1), 256 entries/elements per mapping/sequence, 4,096 aggregate nodes, 16,384 Unicode scalar values per ordinary string and the I-JSON safe native integer range ±9,007,199,254,740,991. Schema `integer` fields use a strict native-integer type checker, so integral floats are not silently accepted as integers. Aggregate nodes count each container/scalar and every mapping-key scalar. + +Errors expose only an owned JSON-pointer path plus a bounded reason code; raw parser failures fall back to `/` when an unowned key would otherwise be reflected, and wrapped parser/canonicalizer failures suppress underlying exception chains. Rejected values are never echoed. + +### Format assertion + +Draft 2020-12 validation uses an explicit `FormatChecker` whose registry is reduced to four Actools-owned formats: domain, endpoint, filesystem-authority ID and secret reference. Schema-quality validation fails if any other format name appears. + +### Defaults and origins + +JSON Schema `default` is not used for mutation. `configuration-defaults-1.0.0.json` is the sole release-default source. Defaults apply only where a field is omitted; explicit null/false/zero/empty values are preserved and may then fail the schema if invalid. The operator input is never modified in place. + +Origins are a deterministic JSON-Pointer map for every resolved scalar leaf and every empty mapping/sequence leaf. Operator-present values receive `{"origin":"operator"}`. Release-default values additionally identify `configuration-defaults-1.0.0` and default-set version `1.0.0`. No `policy_default` source is invented. + +### Canonical bytes and vectors + +Canonical output is `rfc8785.dumps()` bytes for parser/validator-bounded I-JSON-compatible data, for both the resolved configuration and `{configuration, origins}` resolution envelope. Ordinary `json.dumps(sort_keys=True)` is not treated as canonicalization. + +Six byte-for-byte published vectors are vendored from `cyberphone/json-canonicalization@19d51d7fe467d4706a3ff08adf8a748f29fc21e0`: arrays, french, structures, unicode, values and weird. Their Apache-2.0 provenance and byte receipts are recorded beside the fixtures. The upstream 100-million-number corpus is not vendored. + +### Package/source-CI transition + +The distribution still has version `0.1.0.dev0` and the CP-001 CLI remains untouched. The wheel grows from nine members to a closed sixteen-member shape: the two CP-001 Python files, four contract Python modules, three schema/default JSON resources and the same seven distribution metadata/license members. Runtime metadata must contain exactly the three accepted runtime pins. `.github/workflows/source-ci.yml` remains byte-for-byte the CP-001 workflow; the checker and lock inputs evolve underneath that unchanged authority. + +## Consequences and evidence boundary + +Configuration validity is not installation/production readiness. No host, Drupal, backup, restore, release-signing or product-wide gate is established by this ADR. + +The final CP-002 review subject must contain a real resolver-generated `requirements/ci.lock` produced under the accepted Ubuntu Server 26.04.1 x86_64 / CPython 3.14.7 / pip 26.2.1 / pip-tools 7.6.1 contract. This coder environment did not provide that accepted generation environment, so the lock remains an operator-generated final-candidate item rather than a hand-written artifact. + +The vector receipt values are present for deterministic source testing, but the packet-required Human-Git independent digest recomputation is still an evidence obligation before final review. diff --git a/docs/development/source-ci.md b/docs/development/source-ci.md index f71e0fd..16339ef 100644 --- a/docs/development/source-ci.md +++ b/docs/development/source-ci.md @@ -1,8 +1,8 @@ -# CP-001 source CI and development checks +# Source CI and development checks ## Supported check environment -The source-check OS line is **Ubuntu 26.04 LTS x86_64** with CPython **3.14.7**, matching architecture D01. Local E01 qualification used a clean Ubuntu Server **26.04.1 LTS** x86_64 host. Hosted source CI uses GitHub-hosted `ubuntu-26.04` x64. The accepted PR-hosted and post-merge runs both recorded Ubuntu 26.04.1 with runner image version `20260907.131.1`; retain the hosted label/version as evidence rather than silently substituting another OS. Source-CI success does not by itself qualify host hardening or the complete installed product. +The source-check OS line is **Ubuntu 26.04 LTS x86_64** with CPython **3.14.7**, matching architecture D01. CP-001 local E01 qualification used clean Ubuntu Server **26.04.1 LTS** x86_64. Hosted Source CI remains the unchanged GitHub-hosted `ubuntu-26.04` x64 workflow. CP-002 does not broaden workflow triggers, permissions or runner authority. Install the reviewed hash lock and invoke the canonical checker: @@ -11,46 +11,73 @@ python -m pip install --require-hashes -r requirements/ci.lock python tools/check_source.py ``` -The workflow uses exactly the same checker. Its workflow name is `Source CI`; the job name is `source`. Neither name is configured as a branch-protection required check by CP-001. +The workflow name remains `Source CI` and job name `source`. ## Inputs and lock discipline -`requirements/ci.in` pins the five direct inputs. `requirements/ci.lock` is now the resolver-generated CI closure: 34 distributions with SHA-256 hashes emitted by `pip-compile` under CPython 3.14.7 on Ubuntu Server 26.04.1 LTS x86_64. The lock may carry hashes for published wheel candidates across platforms; the later hash-enforced installation receipt records which compatible artifacts are actually selected on the declared target. The build package itself has zero runtime dependencies. +`requirements/ci.in` now pins eight direct inputs: the five CP-001 source/test tools plus the three CP-002 runtime dependencies: -Lock-generation contract: use CPython 3.14.7, `pip==26.2.1` and `pip-tools==7.6.1` in a disposable generation environment with pip configuration disabled, explicit PyPI index input, `--allow-unsafe`, `--generate-hashes`, backtracking resolver, no hash reuse, and wheel-only input policy. E01 Phase 1C generated the committed lock from unchanged `requirements/ci.in` (SHA-256 `615a1f8c33300e7b47c366916c89738bd572b37b8124e98064efe1fca27f86f8`). Generated lock SHA-256 is `6836c3ed72a3667e97b6901f9836f6c0957dd3512911b221986bc7c1b223e7e7`. Relative to the prior handwritten 35-distribution lock, the resolver produced 34 distributions: `typing-extensions` is absent, `boolean.py` is canonically emitted as `boolean-py`, and transitive pins move to `cachecontrol==0.14.4`, `msgpack==1.2.2` and `pip-api==0.0.35`. Phase 1B and 1C dependency bodies were byte-identical; Phase 1C corrected only pip-tools' displayed command header. The later pinned regeneration/readback supplement reproduced the committed lock byte-for-byte, and the hash-enforced local Phase-2 installation/full canonical execution completed on exact candidate `2aafec4c15a8ea1f05a2a4d97268b8dd1e3fbbec`. - -## Assertions made by `tools/check_source.py` +```text +build==1.6.0 +jsonschema==4.26.0 +pip==26.2.1 +pip-audit==2.10.1 +pytest==9.1.1 +PyYAML==6.0.3 +rfc8785==0.1.4 +setuptools==84.0.0 +``` -1. Exact Python and direct source-tool versions are present. -2. Every locked requirement is exact and hashed, the direct input set is unchanged, and the lock contains exactly pip-tools' canonical `--only-binary :all:` directive while the source input retains its equivalent `--only-binary=:all:` form. -3. The source workflow matches the complete approved CP-001 workflow shape: only pull requests to `main` and pushes to `main`, repository `contents: read`, GitHub-hosted `ubuntu-26.04`, CPython 3.14.7, immutable checkout/setup-python pins, non-persisted checkout credentials, the exact locked-install step and the canonical check step. Extra triggers, job-level authority, ignored failures, runner changes or extra execution are rejected. -4. Unit tests cover exact human/JSON version output and help, architecture exit code **3** for invalid invocation/report failures, bounded non-reflective error text, closed stdin, secret canaries, write/flush failures and real closed pipes in buffered and unbuffered Python. -5. Controlled failure fixtures must fail for their intended reason: the deliberate marker and the intentionally missing build backend are checked, so a missing test tool cannot masquerade as a successful negative test. -6. A wheel is built and must have the closed nine-member CP-001 inventory: the two owned runtime Python files plus only the expected `actools_drupal-0.1.0.dev0.dist-info` metadata/license files. Duplicate, malformed, traversal, nested test/Git/credential material and unrelated metadata roots are rejected. Distribution identity, Python range, zero runtime requirements, MIT license, console entry point, pure-wheel metadata and packaged license bytes are checked. -7. That exact wheel digest is bound to installed acceptance checks. The fresh environment is invoked through its absolute `actools` console launcher from outside the source tree with `PYTHONPATH`/`PYTHONHOME` overrides removed; an isolated import probe must resolve `actools` inside that environment and confirm distribution/version/zero-runtime-requirement metadata. -8. `pip-audit==2.10.1` runs with strict/hash requirements, `--no-deps` and `--disable-pip`. Its pinned JSON formatter contract is the object envelope `{"dependencies": [...], "fixes": [...]}`. PASS requires complete, duplicate-free, unskipped package/version coverage exactly matching every locked distribution, explicit empty vulnerability lists and no fixes. Empty/incomplete coverage, skipped or malformed records, mismatched versions, scanner error/unavailability or any vulnerability is non-PASS. The exact 2.10.1 formatter contract is bound to `pypa/pip-audit` tag `v2.10.1`, `pip_audit/_format/json.py`; real scanner output remains part of the E01 execution gate. +The final `requirements/ci.lock` is a resolver result, never a hand-written prediction. Generate it only with CPython 3.14.7, `pip==26.2.1` and `pip-tools==7.6.1` on the accepted Ubuntu Server 26.04.1 x86_64 reference, with pip configuration disabled, explicit PyPI index input, `--allow-unsafe`, `--generate-hashes`, backtracking resolution, no hash reuse and the wheel-only input policy. Record input and generated-lock SHA-256 values from actual bytes. -## Controlled negative fixtures +The accepted generation command shape is: ```text -python tests/fixtures/deliberate_failure.py -python -m build --wheel --no-isolation tests/fixtures/invalid_package +pip-compile --no-config --index-url=https://pypi.org/simple --no-emit-index-url --allow-unsafe --generate-hashes --resolver=backtracking --no-reuse-hashes --no-annotate --no-strip-extras --output-file=requirements/ci.lock requirements/ci.in ``` -Both commands are expected to return nonzero **for the intended fixture reason**. Scanner-policy negatives are data-only files under `tests/fixtures/scanner_policy/`; no exploit code is executed. +Run it only from the disposable accepted generation environment after confirming `python --version`, `python -m pip --version`, and `pip-compile --version`; do not substitute a different platform or tool version merely to obtain a lock. + +This coding delivery intentionally leaves `requirements/ci.lock` operator-generated/pending because its sandbox is not that accepted environment. A stale CP-001 lock is not a CP-002 final candidate. + +## Canonical checker assertions + +`tools/check_source.py` retains the CP-001 fail-closed workflow/scanner/CLI policy and evolves package truth for CP-002: + +1. exact CPython 3.14.7 and all eight direct installed versions; +2. a hash-locked resolver closure whose direct pins exactly match `requirements/ci.in`, plus a successful `python -m pip check`; +3. byte-for-byte unchanged Source CI workflow shape, including only PR/push to `main`, `contents: read`, immutable action pins, `persist-credentials: false`, Ubuntu 26.04 and Python 3.14.7; +4. the complete test suite, including strict parser/default/origin/JCS adversarial tests and all unchanged CP-001 CLI tests; +5. the two CP-001 controlled negative build fixtures, still reason-bound; +6. one closed **16-member** wheel: `actools/__init__.py`, `actools/cli.py`, four `actools.contracts` Python modules, three declared schema/default JSON resources, and the same seven distribution metadata/license members; +7. exact wheel metadata runtime requirements: `PyYAML==6.0.3`, `jsonschema==4.26.0`, `rfc8785==0.1.4`; +8. byte equality for packaged contract/schema resources and root license/notices; +9. an actual installed `actools` launcher in a fresh isolated virtual environment outside the source tree: that verifier environment first hash-installs the complete actual `requirements/ci.lock` and passes `pip check`, then installs only the already-built Actools wheel with `--no-index --no-deps`; the installed probe preserves CP-001 version/help/error behavior, proves the Draft 2020-12 schema resource is loadable, checks the exact three runtime versions, and requires each runtime distribution/module path to resolve inside that fresh verifier environment rather than from an outer/system site-packages location; +10. strict `pip-audit==2.10.1` complete, duplicate-free, unskipped package/version coverage exactly equal to the generated lock. Any scanner vulnerability, skip, malformed output, incomplete coverage or unavailability is non-PASS. -## Expected outcomes and limits +## Targeted CP-002 test command -On complete success the checker prints the exact built wheel SHA-256 followed by `SOURCE-CI: PASS` and exits zero. Any required missing/wrong tool, lock or workflow mismatch, build/install/inventory failure, scanner failure or assertion failure exits nonzero and prints `SOURCE-CI: FAIL` with bounded diagnostic context. +After the real lock has been generated and installed in the accepted environment: -A source-CI PASS does **not** establish Drupal installation, privileged execution, server hardening, firewall/SSH behavior, backups/restores, release signing or production admission. It does not mark full G01/G09/G22 or any other product-wide gate PASS. +```text +python -m pytest -q \ + tests/test_configuration_contract.py \ + tests/test_jcs_conformance.py \ + tests/test_package_contents.py +``` + +Then run: + +```text +python tools/check_source.py +``` -## Accepted CP-001 evidence boundary +A command printed in documentation is not evidence that it ran. Record actual environment, candidate identity, start/end time, exit and bounded output as PASS/FAIL/BLOCKED/NOT_RUN/NOT_APPLICABLE. -Independent review `CP001-IR-40362742-v1` originally returned **CHANGES REQUESTED** for F01-F06 plus evidence gate E01. `CP001-IR-1bdb6b09-v1` verified the six source corrections while retaining E01. The accepted candidate is `2aafec4c15a8ea1f05a2a4d97268b8dd1e3fbbec` / tree `4ca87f34300ba92e37f3e331d8e5ce1805fb41d0`. +## JCS provenance receipt -Local E01 then completed on Ubuntu 26.04.1 x86_64 / CPython 3.14.7: the retained Phase-2 evidence archive SHA-256 is `d452112d68a46553cc8b25dd1c9c81deabe9f234907289622d26e801e66e2e52`; the later generator/source-integrity supplement SHA-256 is `630e558c316bdfba6d0e4f9f756136a55d6e09ddd14f8ebefc8217b6901a463f`; `CP001-IR-2aafec4-local-receipts-v1` accepted Local E01. PR-hosted Source CI run `34569300056` / job `103167778389` completed success on the GitHub PR merge subject for the accepted candidate, with wheel SHA-256 `85da105c2b8baec7983b568187fea377e1a4ef4aefe76af5498508c8ff32ce77`; `CP001-IR-2aafec4-E01H-v1` closed E01-H and overall E01. +The six compact vector pairs are pinned to `cyberphone/json-canonicalization@19d51d7fe467d4706a3ff08adf8a748f29fc21e0`. `tests/fixtures/jcs/cyberphone/PROVENANCE.md` records source paths, coder-stage local-byte SHA-256 values and Apache-2.0 notice. The Human-Git operator must independently recompute/confirm every file digest and the set digest before the final candidate is independently reviewed. -Human-accepted PR #3 integrated the candidate as normal merge `58cfa0d53abb002bd33cd185f56f62c9d830162c`, whose tree equals the accepted candidate tree. Push-triggered Source CI run `34571622780` / job `103174769272` then completed success on that exact merge with wheel SHA-256 `a9c57aa62fdbbef84dc1cc6952316de4eb9f892c4ebe5011260061afe5d9c35b` and `SOURCE-CI: PASS`. `CP001-IR-58cfa0d-post-merge-v1` independently accepts the bounded source/integration result and leaves only PM-R01 records chronology closeout. +## Evidence boundary -These receipts qualify only the CP-001 source/package/CI slice. They do **not** establish Drupal installation, privileged execution, host hardening, firewall/SSH behavior, backup/restore, release signing, production admission or any product-wide G gate. PM-R01 changes status/evidence chronology only and requires its own focused records review before integration. +Source-CI success does not establish Drupal installation, privileged execution, host hardening, firewall/SSH behavior, backup/restore, release signing or production admission. CP-002 is a pure data-contract slice and does not mark G03/G09 or any product-wide gate PASS by itself. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000..533680b --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,97 @@ +# Configuration contract 1.0.0 + +CP-002 defines a **data-only** configuration contract. A document that passes this contract is structurally and semantically valid for later Actools consumers; it is not proof that installation, a selected capability, a host or production is ready. + +## Identity and supported profile + +- Contract: `1.0.0` +- Schema dialect: JSON Schema Draft 2020-12 +- Profile: `single-site-production` only +- Inputs: UTF-8 JSON or the restricted Actools YAML subset +- Canonical bytes: RFC 8785 JCS + +The resolved object has these closed groups: `schema_version`, `profile`, `installation`, `site`, `environment`, `platform`, `capabilities`, `host`, `drupal`, `recovery`, `monitoring`, `secrets`, and `references`. Unknown fields fail. + +The only selectable capability identifiers represented by this contract version are `valkey-cache` and `cloudflare-standard-proxy`. They describe requested configuration relationships only; later owners must independently implement and qualify them. + +## Strict JSON + +JSON parsing is bytes-first. The parser enforces the 1 MiB cap before decode, requires strict UTF-8 and one JSON document, detects duplicate object keys before dictionary collapse, rejects `NaN`/`Infinity` and overflow to non-finite values, rejects lone/invalid Unicode surrogates, and restricts native integers to `-9007199254740991..9007199254740991`. Fields declared as integer require the native integer model; integral floats such as `60.0` are not accepted as integer configuration values. Comments, trailing data and extensions are not accepted. + +## Restricted Actools YAML + +YAML is a convenience syntax for the same JSON-compatible model, not a general YAML configuration language. PyYAML 6.0.3 supplies scanning/parsing only; Actools owns scalar meaning. + +Accepted plain non-string forms are exactly lowercase `null`, `true`, `false`, and JSON-number lexical forms. Quoted scalars stay strings. Ordinary safe unquoted text stays string. + +Rejected forms include anchors/aliases, tags, merge keys, directives, multiple documents, duplicate/non-string keys, `~`, implicit empty nulls, `yes/no/on/off` variants, date/timestamp/time implicit forms, hexadecimal/octal/binary/sexagesimal/underscore numbers, `.nan`/`.inf`, leading-zero numeric forms, leading-plus/trailing-dot/dot-leading numeric extensions, and block scalar styles. + +## Resource limits + +| Limit | Value / convention | +|---|---:| +| Input bytes | 1,048,576 | +| Container nesting | 32; root mapping/sequence is level 1 | +| Mapping entries | 256 per mapping | +| Sequence elements | 256 per sequence | +| Aggregate nodes | 4,096 | +| Ordinary string | 16,384 Unicode scalar values | +| Native integer | I-JSON safe range above | + +Aggregate-node accounting counts every container/scalar value and every mapping-key scalar. + +## Owned formats and semantic checks + +Draft 2020-12 format assertions are active. The finite checker set contains only: + +- `actools-domain`: lowercase DNS-style multi-label domain; +- `actools-endpoint`: DNS/IPv4 `host:port` or bracketed IPv6 `[address]:port`; +- `actools-filesystem-authority-id`: `fs-auth:`; +- `actools-secret-reference`: `secret:///`. + +A schema format not in that registry fails schema-quality tests. + +Cross-field validation requires: + +- `drupal.cache.mode=valkey` exactly when `valkey-cache` is selected; +- `drupal.ingress.mode=cloudflare-standard` exactly when `cloudflare-standard-proxy` is selected; +- enabled email/Telegram notification channels to carry a secret reference; +- disabled notification channels to carry `null`, not a retained credential reference. + +Secret values themselves are out of contract. `secrets.database_credentials` and notification credentials are references only. Rejected values are never echoed in error text. + +## Defaults + +Defaults are data in `configuration-defaults-1.0.0.json`, not JSON Schema mutation. They apply only to omitted fields. Explicit `null`, `false`, `0`, empty string or empty container is never replaced by a default; if that explicit value violates the schema it fails later validation. + +Version 1.0.0 release defaults include the accepted Ubuntu/Docker Compose platform references, database cache, direct Caddy ingress, local Drupal public/private storage, empty capability selection, production environment type, accepted access-policy identifiers, RPO 60 minutes, RTO 240 minutes, health/security/deep-diagnostic cadences 5 minutes/24 hours/7 days, and disabled/null email and Telegram notification channels. + +## Origin map + +Resolution returns a detached resolved configuration plus a deterministic JSON-Pointer-keyed origin map. Entries exist for every scalar leaf and every empty mapping/sequence leaf in the resolved configuration; non-empty containers do not receive their own entry. + +Operator-present values use: + +```json +{"origin":"operator"} +``` + +A release-default value uses: + +```json +{ + "origin":"release_default", + "default_set":"configuration-defaults-1.0.0", + "default_set_version":"1.0.0" +} +``` + +No `policy_default` exists in this contract version. + +## Canonical bytes + +`ResolvedConfiguration.canonical_configuration_bytes()` returns RFC 8785 bytes for the resolved configuration. `canonical_resolution_bytes()` canonicalizes the envelope containing the resolved configuration and origin map. Canonicalization is pure data processing and performs no publication, journal, secret, environment, subprocess or network operation. + +## Bounded errors + +`ConfigurationError` reports an owned JSON-pointer-like path and a reason code only. Raw parser failures fall back to `/` if reaching an unowned input key, and wrapped parser/canonicalizer failures suppress reflective exception chains. Rejected values are not interpolated, preventing secret-canary and terminal-control reflection. diff --git a/pyproject.toml b/pyproject.toml index 60a4078..4cc898f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,13 @@ build-backend = "setuptools.build_meta" [project] name = "actools-drupal" dynamic = ["version"] -description = "Development package skeleton for the actDrupal fresh-install installer" +description = "Development package for the actDrupal fresh-install installer rewrite" requires-python = ">=3.14,<3.15" -dependencies = [] +dependencies = [ + "PyYAML==6.0.3", + "jsonschema==4.26.0", + "rfc8785==0.1.4", +] license = "MIT" license-files = ["LICENSE", "NOTICE.md"] authors = [{name = "Actools"}] @@ -29,7 +33,10 @@ version = {attr = "actools.__version__"} [tool.setuptools.packages.find] where = ["src"] -include = ["actools"] +include = ["actools", "actools.*"] + +[tool.setuptools.package-data] +"actools.contracts" = ["schemas/*.json"] [tool.pytest.ini_options] addopts = "-ra --strict-config --strict-markers" diff --git a/requirements/ci.in b/requirements/ci.in index a9df4d2..b77ad9d 100644 --- a/requirements/ci.in +++ b/requirements/ci.in @@ -1,5 +1,5 @@ -# CP-001 direct source-CI inputs. Runtime dependencies for actools-drupal are zero. -# Resolution target: CPython 3.14.7 on Ubuntu 26.04 LTS x86_64 (local E01 reference: Ubuntu Server 26.04.1 LTS). +# CP-002 direct source-CI inputs. Runtime dependencies are pinned exactly below. +# Resolution target: CPython 3.14.7 on Ubuntu 26.04 LTS x86_64 (local reference: Ubuntu Server 26.04.1 LTS). # Lock-generation tool (separate environment): pip-tools==7.6.1. --only-binary=:all: @@ -8,3 +8,6 @@ pip==26.2.1 pip-audit==2.10.1 pytest==9.1.1 setuptools==84.0.0 +PyYAML==6.0.3 +jsonschema==4.26.0 +rfc8785==0.1.4 diff --git a/requirements/ci.lock b/requirements/ci.lock index 37b7903..959f0c2 100644 --- a/requirements/ci.lock +++ b/requirements/ci.lock @@ -2,10 +2,12 @@ # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --no-config --index-url=https://pypi.org/simple --no-emit-index-url --allow-unsafe --generate-hashes --resolver=backtracking --no-reuse-hashes --no-annotate --no-strip-extras --output-file=requirements/ci.lock requirements/ci.in +# pip-compile --allow-unsafe --generate-hashes --index-url=https://pypi.org/simple --no-annotate --no-emit-index-url --no-index --no-strip-extras --output-file=requirements/ci.lock requirements/ci.in # --only-binary :all: +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 boolean-py==5.0 \ --hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9 build==1.6.0 \ @@ -196,6 +198,10 @@ idna==3.19 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 iniconfig==2.3.0 \ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 +jsonschema==4.26.0 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe license-expression==30.4.4 \ --hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 markdown-it-py==4.2.0 \ @@ -314,10 +320,203 @@ pyproject-hooks==1.2.0 \ --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 pytest==9.1.1 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 +rfc8785==0.1.4 \ + --hash=sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48 rich==15.0.0 \ --hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ + --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ + --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ + --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef sortedcontainers==2.4.0 \ --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 tomli==2.4.1 \ diff --git a/src/actools/contracts/__init__.py b/src/actools/contracts/__init__.py new file mode 100644 index 0000000..a560226 --- /dev/null +++ b/src/actools/contracts/__init__.py @@ -0,0 +1,34 @@ +"""Versioned data-only contracts for the actDrupal fresh-install rewrite.""" + +from .canonical import canonical_json_bytes +from .configuration import ( + CONTRACT_VERSION, + DEFAULT_SET, + MAX_INPUT_BYTES, + SUPPORTED_PROFILE, + ResolvedConfiguration, + assert_schema_quality, + load_configuration, + parse_configuration_bytes, + parse_json_bytes, + parse_yaml_bytes, + resolve_configuration, +) +from .errors import CanonicalizationError, ConfigurationError + +__all__ = [ + "CONTRACT_VERSION", + "DEFAULT_SET", + "MAX_INPUT_BYTES", + "SUPPORTED_PROFILE", + "ResolvedConfiguration", + "ConfigurationError", + "CanonicalizationError", + "canonical_json_bytes", + "assert_schema_quality", + "parse_json_bytes", + "parse_yaml_bytes", + "parse_configuration_bytes", + "resolve_configuration", + "load_configuration", +] diff --git a/src/actools/contracts/canonical.py b/src/actools/contracts/canonical.py new file mode 100644 index 0000000..8188016 --- /dev/null +++ b/src/actools/contracts/canonical.py @@ -0,0 +1,25 @@ +"""Finite RFC 8785 canonical byte helpers shared by CP-002 contracts.""" + +from __future__ import annotations + +from typing import Any + +import rfc8785 + +from .errors import CanonicalizationError + +SAFE_INTEGER_MAX = 9_007_199_254_740_991 +SAFE_INTEGER_MIN = -SAFE_INTEGER_MAX +MAX_CONTAINER_DEPTH = 32 +MAX_MAPPING_ENTRIES = 256 +MAX_SEQUENCE_ELEMENTS = 256 +MAX_AGGREGATE_NODES = 4_096 +MAX_STRING_SCALARS = 16_384 + + +def canonical_json_bytes(value: Any) -> bytes: + """Return RFC 8785 JCS UTF-8 bytes without reflecting rejected values.""" + try: + return rfc8785.dumps(value) + except (rfc8785.CanonicalizationError, TypeError, ValueError, UnicodeError) as exc: + raise CanonicalizationError("value is not representable under RFC 8785 JCS") from None diff --git a/src/actools/contracts/configuration.py b/src/actools/contracts/configuration.py new file mode 100644 index 0000000..d440012 --- /dev/null +++ b/src/actools/contracts/configuration.py @@ -0,0 +1,711 @@ +"""Strict configuration parsing, validation, defaults and origin resolution.""" +from __future__ import annotations +import copy, ipaddress, json, math, re +from collections.abc import Mapping +from dataclasses import dataclass +from importlib import resources +from typing import Any +import yaml +from jsonschema import Draft202012Validator, FormatChecker, validators +from yaml.events import AliasEvent, CollectionEndEvent, DocumentEndEvent, DocumentStartEvent, MappingEndEvent, MappingStartEvent, ScalarEvent, SequenceEndEvent, SequenceStartEvent, StreamEndEvent, StreamStartEvent +from yaml.tokens import AliasToken, AnchorToken, DirectiveToken, TagToken +from .canonical import MAX_AGGREGATE_NODES, MAX_CONTAINER_DEPTH, MAX_MAPPING_ENTRIES, MAX_SEQUENCE_ELEMENTS, MAX_STRING_SCALARS, SAFE_INTEGER_MAX, SAFE_INTEGER_MIN, canonical_json_bytes +from .errors import ConfigurationError +CONTRACT_VERSION = '1.0.0' +SUPPORTED_PROFILE = 'single-site-production' +DEFAULT_SET = 'configuration-defaults-1.0.0' +MAX_INPUT_BYTES = 1048576 +_JSON_NUMBER = re.compile('-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\\Z') +_DOMAIN_LABEL = re.compile('[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\Z') +_FS_AUTHORITY = re.compile('fs-auth:[a-z][a-z0-9-]{1,62}\\Z') +_SECRET_REFERENCE = re.compile('secret://[a-z][a-z0-9-]{0,62}/[a-z][a-z0-9-]{0,62}\\Z') +_DATE_LIKE = re.compile('\\d{4}-\\d{2}-\\d{2}(?:[Tt ].*)?\\Z') +_TIME_LIKE = re.compile('\\d{1,2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:?\\d{2})?\\Z') +_SEXAGESIMAL = re.compile('[-+]?\\d+(?::[0-5]?\\d)+(?:\\.\\d+)?\\Z') +_NUMERIC_EXTENSION = re.compile('(?:[-+]?0[xX][0-9a-fA-F_]+|[-+]?0[oO][0-7_]+|[-+]?0[bB][01_]+|[-+]?0[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|[-+]?\\.[0-9]+(?:[eE][+-]?[0-9]+)?|[-+]?[0-9]+\\.|\\+[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\\Z') +_AMBIGUOUS_WORDS = {'yes', 'no', 'on', 'off', 'y', 'n'} +_FORMAT_CHECKER = FormatChecker() +_FORMAT_CHECKER.checkers.clear() + + +def _native_integer_type(checker: object, instance: object) -> bool: + del checker + return isinstance(instance, int) and not isinstance(instance, bool) + + +_STRICT_TYPE_CHECKER = Draft202012Validator.TYPE_CHECKER.redefine( + 'integer', _native_integer_type +) +StrictDraft202012Validator = validators.extend( + Draft202012Validator, type_checker=_STRICT_TYPE_CHECKER +) + +def _is_domain(value: object) -> bool: + if not isinstance(value, str) or len(value) > 253 or value.endswith('.'): + return False + labels = value.split('.') + return len(labels) >= 2 and all((_DOMAIN_LABEL.fullmatch(label) for label in labels)) + +def _is_endpoint(value: object) -> bool: + if not isinstance(value, str) or not value: + return False + if value.startswith('['): + close = value.find(']') + if close <= 1 or close + 1 >= len(value) or value[close + 1] != ':': + return False + host = value[1:close] + port_text = value[close + 2:] + try: + if ipaddress.ip_address(host).version != 6: + return False + except ValueError: + return False + else: + if value.count(':') != 1: + return False + host, port_text = value.rsplit(':', 1) + try: + ipaddress.IPv4Address(host) + except ValueError: + if not _is_domain(host): + return False + if not port_text.isascii() or not port_text.isdigit() or (len(port_text) > 1 and port_text.startswith('0')): + return False + return 1 <= int(port_text) <= 65535 + +def _is_fs_authority(value: object) -> bool: + return isinstance(value, str) and _FS_AUTHORITY.fullmatch(value) is not None + +def _is_secret_reference(value: object) -> bool: + return isinstance(value, str) and _SECRET_REFERENCE.fullmatch(value) is not None +_FORMAT_CHECKER.checks('actools-domain')(_is_domain) +_FORMAT_CHECKER.checks('actools-endpoint')(_is_endpoint) +_FORMAT_CHECKER.checks('actools-filesystem-authority-id')(_is_fs_authority) +_FORMAT_CHECKER.checks('actools-secret-reference')(_is_secret_reference) + +def _pointer_escape(part: str) -> str: + return part.replace('~', '~0').replace('/', '~1') + +def _json_pointer(parts: list[object]) -> str: + return '' if not parts else '/' + '/'.join((_pointer_escape(str(p)) for p in parts)) + + +_OWNED_ERROR_PATH_SEGMENTS = { + 'schema_version', 'profile', 'installation', 'id', 'site', 'domain', + 'environment', 'type', 'platform', 'os_profile', 'runtime_backend', + 'capabilities', 'selected_ids', 'host', 'management_endpoint', + 'filesystem_authority_id', 'access', 'linux_administration', + 'privileged_drupal', 'editor', 'drupal', 'storage', 'public', 'private', + 'cache', 'mode', 'ingress', 'recovery', 'rpo_minutes', 'rto_minutes', + 'monitoring', 'health_interval_minutes', 'security_audit_interval_hours', + 'deep_diagnostic_interval_days', 'notifications', 'email', 'telegram', + 'enabled', 'secret_ref', 'secrets', 'database_credentials', 'references', + 'policy', 'release', +} + + +def _owned_error_path(parts: list[object]) -> str: + for part in parts: + if isinstance(part, int): + continue + if not isinstance(part, str) or part not in _OWNED_ERROR_PATH_SEGMENTS: + return '/' + pointer = _json_pointer(parts) + return pointer or '/' + +def _resource_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ConfigurationError('/', 'contract_resource_duplicate_key') + result[key] = value + return result + + +def _resource_constant(_: str) -> Any: + raise ConfigurationError('/', 'contract_resource_nonfinite_number') + + +def _schema_resource(name: str) -> dict[str, Any]: + try: + raw = resources.files('actools.contracts').joinpath('schemas', name).read_bytes() + text = raw.decode('utf-8', 'strict') + value = json.loads( + text, + object_pairs_hook=_resource_pairs, + parse_int=_json_integer, + parse_float=_json_float, + parse_constant=_resource_constant, + ) + except ConfigurationError: + raise + except Exception as exc: + raise ConfigurationError('/', 'schema_resource_unavailable') from None + if not isinstance(value, dict): + raise ConfigurationError('/', 'schema_resource_invalid') + return value + +def _known_formats(schema: Any) -> set[str]: + found = set() + if isinstance(schema, dict): + if isinstance(schema.get('format'), str): + found.add(schema['format']) + for v in schema.values(): + found.update(_known_formats(v)) + elif isinstance(schema, list): + for v in schema: + found.update(_known_formats(v)) + return found + +def assert_schema_quality(schema: Mapping[str, Any] | None=None) -> None: + target = dict(schema) if schema is not None else _schema_resource('configuration-1.0.0.schema.json') + try: + Draft202012Validator.check_schema(target) + except Exception as exc: + raise ConfigurationError('/', 'schema_invalid') from None + if _known_formats(target) - set(_FORMAT_CHECKER.checkers): + raise ConfigurationError('/', 'schema_unregistered_format') + +def _expand_common_refs(value: Any, common: Mapping[str, Any]) -> Any: + if isinstance(value, dict): + if set(value) == {'$ref'} and isinstance(value['$ref'], str): + prefix = 'common-1.0.0.schema.json#/$defs/' + if value['$ref'].startswith(prefix): + name = value['$ref'][len(prefix):] + definition = common.get('$defs', {}).get(name) + if definition is None: + raise ConfigurationError('/', 'schema_reference_invalid') + return _expand_common_refs(copy.deepcopy(definition), common) + return {k: _expand_common_refs(v, common) for k, v in value.items()} + if isinstance(value, list): + return [_expand_common_refs(v, common) for v in value] + return value + +def _configuration_schema() -> dict[str, Any]: + common = _schema_resource('common-1.0.0.schema.json') + schema = _schema_resource('configuration-1.0.0.schema.json') + expanded = _expand_common_refs(schema, common) + assert_schema_quality(expanded) + return expanded + +def _valid_json_pointer(pointer: object) -> bool: + if not isinstance(pointer, str) or not pointer.startswith('/'): + return False + for token in pointer.split('/')[1:]: + index = 0 + while index < len(token): + if token[index] == '~': + if index + 1 >= len(token) or token[index + 1] not in {'0', '1'}: + return False + index += 2 + else: + index += 1 + return True + + +def _defaults_document() -> dict[str, Any]: + value = _schema_resource('configuration-defaults-1.0.0.json') + if set(value) != { + 'schema_version', + 'profile', + 'default_set', + 'default_set_version', + 'defaults', + }: + raise ConfigurationError('/', 'default_set_invalid') + if ( + value.get('schema_version') != CONTRACT_VERSION + or value.get('profile') != SUPPORTED_PROFILE + or value.get('default_set') != DEFAULT_SET + or value.get('default_set_version') != CONTRACT_VERSION + ): + raise ConfigurationError('/', 'default_set_identity_mismatch') + defaults = value.get('defaults') + if not isinstance(defaults, dict) or not defaults: + raise ConfigurationError('/', 'default_set_invalid') + if not all(_valid_json_pointer(pointer) for pointer in defaults): + raise ConfigurationError('/', 'default_set_pointer_invalid') + _validate_data_limits(defaults) + return value + +def _validate_unicode(text: str, path: str) -> None: + if len(text) > MAX_STRING_SCALARS: + raise ConfigurationError(path, 'string_too_long') + if any((55296 <= ord(ch) <= 57343 for ch in text)): + raise ConfigurationError(path, 'invalid_unicode_scalar') + +def _validate_data_limits(value: Any) -> None: + nodes = 0 + + def walk(item: Any, depth: int, parts: list[object]) -> None: + nonlocal nodes + path = _owned_error_path(parts) + nodes += 1 + if nodes > MAX_AGGREGATE_NODES: + raise ConfigurationError('/', 'aggregate_node_limit') + if item is None or isinstance(item, bool): + return + if isinstance(item, int): + if not SAFE_INTEGER_MIN <= item <= SAFE_INTEGER_MAX: + raise ConfigurationError(path, 'integer_outside_safe_range') + return + if isinstance(item, float): + if not math.isfinite(item): + raise ConfigurationError(path, 'nonfinite_number') + return + if isinstance(item, str): + _validate_unicode(item, path) + return + if isinstance(item, dict): + nd = depth + 1 + if nd > MAX_CONTAINER_DEPTH: + raise ConfigurationError(path, 'container_depth_limit') + if len(item) > MAX_MAPPING_ENTRIES: + raise ConfigurationError(path, 'mapping_entry_limit') + for k, v in item.items(): + if not isinstance(k, str): + raise ConfigurationError(path, 'non_string_mapping_key') + nodes += 1 + if nodes > MAX_AGGREGATE_NODES: + raise ConfigurationError('/', 'aggregate_node_limit') + _validate_unicode(k, path) + walk(v, nd, [*parts, k]) + return + if isinstance(item, list): + nd = depth + 1 + if nd > MAX_CONTAINER_DEPTH: + raise ConfigurationError(path, 'container_depth_limit') + if len(item) > MAX_SEQUENCE_ELEMENTS: + raise ConfigurationError(path, 'sequence_element_limit') + for i, v in enumerate(item): + walk(v, nd, [*parts, i]) + return + raise ConfigurationError(path, 'non_json_value') + walk(value, 0, []) + +def _json_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result = {} + for k, v in pairs: + if k in result: + raise ConfigurationError('/', 'duplicate_json_key') + result[k] = v + return result + + +def _parse_safe_integer_text(text: str, path: str) -> int: + digits = text[1:] if text.startswith('-') else text + if len(digits) > 16: + raise ConfigurationError(path, 'integer_outside_safe_range') + try: + value = int(text) + except (ValueError, OverflowError): + raise ConfigurationError(path, 'integer_outside_safe_range') from None + if not SAFE_INTEGER_MIN <= value <= SAFE_INTEGER_MAX: + raise ConfigurationError(path, 'integer_outside_safe_range') + return value + + +def _parse_finite_float_text(text: str, path: str) -> float: + try: + value = float(text) + except (ValueError, OverflowError): + raise ConfigurationError(path, 'nonfinite_number') from None + if not math.isfinite(value): + raise ConfigurationError(path, 'nonfinite_number') + return value + + +def _json_integer(text: str) -> int: + return _parse_safe_integer_text(text, '/') + + +def _json_float(text: str) -> float: + return _parse_finite_float_text(text, '/') + + +def _json_constant(_: str) -> Any: + raise ConfigurationError('/', 'nonfinite_json_constant') + +def _precheck_json_depth(text: str) -> None: + depth = 0 + in_string = False + escaped = False + for ch in text: + if in_string: + if escaped: + escaped = False + elif ch == '\\': + escaped = True + elif ch == '"': + in_string = False + continue + if ch == '"': + in_string = True + elif ch in '[{': + depth += 1 + if depth > MAX_CONTAINER_DEPTH: + raise ConfigurationError('/', 'container_depth_limit') + elif ch in ']}': + depth -= 1 + +def parse_json_bytes(data: bytes) -> Any: + if len(data) > MAX_INPUT_BYTES: + raise ConfigurationError('/', 'input_byte_limit') + try: + text = data.decode('utf-8', 'strict') + except UnicodeDecodeError as exc: + raise ConfigurationError('/', 'invalid_utf8') from None + _precheck_json_depth(text) + try: + value = json.loads( + text, + object_pairs_hook=_json_pairs, + parse_int=_json_integer, + parse_float=_json_float, + parse_constant=_json_constant, + ) + except ConfigurationError: + raise + except (json.JSONDecodeError, ValueError, OverflowError) as exc: + raise ConfigurationError('/', 'invalid_json') from None + _validate_data_limits(value) + return value + +def _plain_yaml_scalar(text: str, path: str) -> Any: + if text == '': + raise ConfigurationError(path, 'yaml_implicit_empty_null') + if text == 'null': + return None + if text == 'true': + return True + if text == 'false': + return False + lower = text.lower() + if lower in _AMBIGUOUS_WORDS or (lower in {'true', 'false', 'null'} and text != lower) or text == '~': + raise ConfigurationError(path, 'yaml_ambiguous_scalar') + if lower in {'.nan', '.inf', '+.inf', '-.inf'}: + raise ConfigurationError(path, 'yaml_nonfinite_number') + if _DATE_LIKE.fullmatch(text) or _TIME_LIKE.fullmatch(text) or _SEXAGESIMAL.fullmatch(text): + raise ConfigurationError(path, 'yaml_ambiguous_scalar') + if _NUMERIC_EXTENSION.fullmatch(text) or ('_' in text and re.match('[-+]?(?:\\d|\\.)', text)): + raise ConfigurationError(path, 'yaml_numeric_extension') + if _JSON_NUMBER.fullmatch(text): + if any((ch in text for ch in '.eE')): + return _parse_finite_float_text(text, path) + return _parse_safe_integer_text(text, path) + _validate_unicode(text, path) + return text + +def _yaml_scalar(event: ScalarEvent, path: str, *, mapping_key: bool=False) -> Any: + if event.anchor is not None: + raise ConfigurationError(path, 'yaml_anchor_forbidden') + if event.tag is not None: + raise ConfigurationError(path, 'yaml_tag_forbidden') + if event.style in {'|', '>'}: + raise ConfigurationError(path, 'yaml_scalar_style_forbidden') + if event.style in {"'", '"'}: + _validate_unicode(event.value, path) + return event.value + value = _plain_yaml_scalar(event.value, path) + if mapping_key and (not isinstance(value, str)): + raise ConfigurationError(path, 'non_string_mapping_key') + return value + +def _preflight_yaml(data: bytes) -> list[Any]: + if len(data) > MAX_INPUT_BYTES: + raise ConfigurationError('/', 'input_byte_limit') + try: + text = data.decode('utf-8', 'strict') + except UnicodeDecodeError as exc: + raise ConfigurationError('/', 'invalid_utf8') from None + try: + for token in yaml.scan(text): + if isinstance(token, DirectiveToken): + raise ConfigurationError('/', 'yaml_directive_forbidden') + if isinstance(token, AnchorToken): + raise ConfigurationError('/', 'yaml_anchor_forbidden') + if isinstance(token, AliasToken): + raise ConfigurationError('/', 'yaml_alias_forbidden') + if isinstance(token, TagToken): + raise ConfigurationError('/', 'yaml_tag_forbidden') + events: list[Any] = [] + document_count = 0 + collection_depth = 0 + parsed_nodes = 0 + for event in yaml.parse(text): + if isinstance(event, DocumentStartEvent): + document_count += 1 + if document_count > 1: + raise ConfigurationError('/', 'yaml_document_count') + if isinstance(event, (MappingStartEvent, SequenceStartEvent)): + collection_depth += 1 + parsed_nodes += 1 + if collection_depth > MAX_CONTAINER_DEPTH: + raise ConfigurationError('/', 'container_depth_limit') + elif isinstance(event, (MappingEndEvent, SequenceEndEvent)): + collection_depth -= 1 + elif isinstance(event, ScalarEvent): + parsed_nodes += 1 + if parsed_nodes > MAX_AGGREGATE_NODES: + raise ConfigurationError('/', 'aggregate_node_limit') + events.append(event) + except ConfigurationError: + raise + except yaml.YAMLError as exc: + raise ConfigurationError('/', 'invalid_yaml') from None + if document_count != 1: + raise ConfigurationError('/', 'yaml_document_count') + return events + +def parse_yaml_bytes(data: bytes) -> Any: + events = _preflight_yaml(data) + index = 0 + nodes = 0 + + def take(expected=None): + nonlocal index + if index >= len(events): + raise ConfigurationError('/', 'invalid_yaml_event_stream') + event = events[index] + if expected is not None and (not isinstance(event, expected)): + raise ConfigurationError('/', 'invalid_yaml_event_stream') + index += 1 + return event + take(StreamStartEvent) + take(DocumentStartEvent) + + def build(parts: list[object], depth: int) -> Any: + nonlocal nodes, index + if index >= len(events): + raise ConfigurationError('/', 'invalid_yaml_event_stream') + event = events[index] + path = _owned_error_path(parts) + nodes += 1 + if nodes > MAX_AGGREGATE_NODES: + raise ConfigurationError('/', 'aggregate_node_limit') + if isinstance(event, AliasEvent): + raise ConfigurationError(path, 'yaml_alias_forbidden') + if isinstance(event, ScalarEvent): + index += 1 + return _yaml_scalar(event, path) + if isinstance(event, MappingStartEvent): + if event.anchor is not None: + raise ConfigurationError(path, 'yaml_anchor_forbidden') + if event.tag is not None: + raise ConfigurationError(path, 'yaml_tag_forbidden') + if depth + 1 > MAX_CONTAINER_DEPTH: + raise ConfigurationError(path, 'container_depth_limit') + index += 1 + result = {} + while not isinstance(events[index], MappingEndEvent): + if len(result) >= MAX_MAPPING_ENTRIES: + raise ConfigurationError(path, 'mapping_entry_limit') + key_event = events[index] + if not isinstance(key_event, ScalarEvent): + raise ConfigurationError(path, 'non_string_mapping_key') + index += 1 + nodes += 1 + if nodes > MAX_AGGREGATE_NODES: + raise ConfigurationError('/', 'aggregate_node_limit') + key = _yaml_scalar(key_event, path, mapping_key=True) + if key_event.style is None and key == '<<': + raise ConfigurationError(path, 'yaml_merge_key_forbidden') + if key in result: + raise ConfigurationError(path, 'duplicate_yaml_key') + result[key] = build([*parts, key], depth + 1) + index += 1 + return result + if isinstance(event, SequenceStartEvent): + if event.anchor is not None: + raise ConfigurationError(path, 'yaml_anchor_forbidden') + if event.tag is not None: + raise ConfigurationError(path, 'yaml_tag_forbidden') + if depth + 1 > MAX_CONTAINER_DEPTH: + raise ConfigurationError(path, 'container_depth_limit') + index += 1 + result = [] + while not isinstance(events[index], SequenceEndEvent): + if len(result) >= MAX_SEQUENCE_ELEMENTS: + raise ConfigurationError(path, 'sequence_element_limit') + result.append(build([*parts, len(result)], depth + 1)) + index += 1 + return result + if isinstance(event, CollectionEndEvent): + raise ConfigurationError(path, 'invalid_yaml_event_stream') + raise ConfigurationError(path, 'yaml_event_forbidden') + value = build([], 0) + take(DocumentEndEvent) + take(StreamEndEvent) + if index != len(events): + raise ConfigurationError('/', 'yaml_document_count') + _validate_data_limits(value) + return value + +def parse_configuration_bytes(data: bytes, *, syntax: str) -> Any: + if syntax == 'json': + return parse_json_bytes(data) + if syntax == 'yaml': + return parse_yaml_bytes(data) + raise ConfigurationError('/', 'unsupported_input_syntax') + +def _prevalidate_identity(value: Any) -> None: + if not isinstance(value, dict): + raise ConfigurationError('/', 'root_must_be_object') + if value.get('schema_version') != CONTRACT_VERSION: + raise ConfigurationError('/schema_version', 'unsupported_schema_version') + if value.get('profile') != SUPPORTED_PROFILE: + raise ConfigurationError('/profile', 'unsupported_profile') + +def _lookup_pointer(document: Any, pointer: str) -> tuple[bool, Any]: + if pointer == '': + return (True, document) + current = document + for encoded in pointer.split('/')[1:]: + key = encoded.replace('~1', '/').replace('~0', '~') + if isinstance(current, dict): + if key not in current: + return (False, None) + current = current[key] + elif isinstance(current, list) and key.isdigit(): + i = int(key) + if i >= len(current): + return (False, None) + current = current[i] + else: + return (False, None) + return (True, current) + +def _set_pointer_if_omitted(document: dict[str, Any], pointer: str, value: Any) -> bool: + parts = pointer.split('/')[1:] + current = document + for encoded in parts[:-1]: + key = encoded.replace('~1', '/').replace('~0', '~') + existing = current.get(key) + if existing is None and key not in current: + current[key] = {} + existing = current[key] + if not isinstance(existing, dict): + return False + current = existing + leaf = parts[-1].replace('~1', '/').replace('~0', '~') + if leaf in current: + return False + current[leaf] = copy.deepcopy(value) + return True + +def _apply_defaults(document: dict[str, Any]) -> set[str]: + defaults = _defaults_document()['defaults'] + applied = set() + for pointer in sorted(defaults): + if _set_pointer_if_omitted(document, pointer, defaults[pointer]): + applied.add(pointer) + return applied + +def _schema_reason(error: Any) -> str: + return {'additionalProperties': 'unknown_property', 'required': 'required_field_missing', 'format': 'invalid_format', 'minimum': 'value_out_of_range', 'maximum': 'value_out_of_range', 'minLength': 'value_out_of_range', 'maxLength': 'value_out_of_range', 'minItems': 'value_out_of_range', 'maxItems': 'value_out_of_range', 'const': 'unsupported_value', 'enum': 'unsupported_value', 'type': 'wrong_type', 'uniqueItems': 'duplicate_array_value', 'pattern': 'invalid_value_syntax'}.get(getattr(error, 'validator', None), 'schema_validation_failed') + +def _validate_schema(document: dict[str, Any]) -> None: + validator = StrictDraft202012Validator( + _configuration_schema(), format_checker=_FORMAT_CHECKER + ) + errors = sorted( + validator.iter_errors(document), + key=lambda error: _json_pointer(list(error.absolute_path)), + ) + if errors: + e = errors[0] + path_parts = list(e.absolute_path) + if ( + e.validator == 'required' + and isinstance(e.validator_value, list) + and isinstance(e.instance, dict) + ): + missing = [name for name in e.validator_value if name not in e.instance] + if missing: + path_parts.append(missing[0]) + raise ConfigurationError(_json_pointer(path_parts), _schema_reason(e)) + +def _validate_semantics(document: dict[str, Any]) -> None: + selected = set(document['capabilities']['selected_ids']) + if (document['drupal']['cache']['mode'] == 'valkey') != ('valkey-cache' in selected): + raise ConfigurationError('/drupal/cache/mode', 'valkey_capability_mismatch') + if (document['drupal']['ingress']['mode'] == 'cloudflare-standard') != ('cloudflare-standard-proxy' in selected): + raise ConfigurationError('/drupal/ingress/mode', 'cloudflare_capability_mismatch') + for channel in ('email', 'telegram'): + item = document['monitoring']['notifications'][channel] + enabled = item['enabled'] + reference = item['secret_ref'] + if enabled and reference is None: + raise ConfigurationError(f'/monitoring/notifications/{channel}/secret_ref', 'enabled_channel_requires_secret_reference') + if not enabled and reference is not None: + raise ConfigurationError(f'/monitoring/notifications/{channel}/secret_ref', 'disabled_channel_forbids_secret_reference') + +def _collect_origins(document: Any, operator: Any, applied: set[str]) -> dict[str, dict[str, str]]: + origins = {} + + def walk(item: Any, parts: list[object]) -> None: + pointer = _json_pointer(parts) + if isinstance(item, dict): + if not item: + present, _ = _lookup_pointer(operator, pointer) + origin = 'operator' if present else 'release_default' + record = {'origin': origin} + if origin == 'release_default': + record.update({'default_set': DEFAULT_SET, 'default_set_version': CONTRACT_VERSION}) + origins[pointer] = record + for k, v in item.items(): + walk(v, [*parts, k]) + return + if isinstance(item, list): + if not item: + present, _ = _lookup_pointer(operator, pointer) + origin = 'operator' if present else 'release_default' + record = {'origin': origin} + if origin == 'release_default': + record.update({'default_set': DEFAULT_SET, 'default_set_version': CONTRACT_VERSION}) + origins[pointer] = record + else: + for i, v in enumerate(item): + walk(v, [*parts, i]) + return + present, _ = _lookup_pointer(operator, pointer) + if present: + origins[pointer] = {'origin': 'operator'} + else: + if not any((pointer == d or pointer.startswith(d + '/') for d in applied)): + raise ConfigurationError(pointer, 'origin_source_unresolved') + origins[pointer] = {'origin': 'release_default', 'default_set': DEFAULT_SET, 'default_set_version': CONTRACT_VERSION} + walk(document, []) + return {k: origins[k] for k in sorted(origins)} + +@dataclass(frozen=True) +class ResolvedConfiguration: + configuration: dict[str, Any] + origins: dict[str, dict[str, str]] + + def canonical_configuration_bytes(self) -> bytes: + return canonical_json_bytes(self.configuration) + + def resolution_envelope(self) -> dict[str, Any]: + return {'configuration': copy.deepcopy(self.configuration), 'origins': copy.deepcopy(self.origins)} + + def canonical_resolution_bytes(self) -> bytes: + return canonical_json_bytes(self.resolution_envelope()) + +def resolve_configuration(value: Mapping[str, Any]) -> ResolvedConfiguration: + operator = copy.deepcopy(dict(value)) + _validate_data_limits(operator) + _prevalidate_identity(operator) + resolved = copy.deepcopy(operator) + applied = _apply_defaults(resolved) + _validate_data_limits(resolved) + _validate_schema(resolved) + _validate_semantics(resolved) + origins = _collect_origins(resolved, operator, applied) + return ResolvedConfiguration(copy.deepcopy(resolved), copy.deepcopy(origins)) + +def load_configuration(data: bytes, *, syntax: str) -> ResolvedConfiguration: + parsed = parse_configuration_bytes(data, syntax=syntax) + if not isinstance(parsed, dict): + raise ConfigurationError('/', 'root_must_be_object') + return resolve_configuration(parsed) diff --git a/src/actools/contracts/errors.py b/src/actools/contracts/errors.py new file mode 100644 index 0000000..0b9f38c --- /dev/null +++ b/src/actools/contracts/errors.py @@ -0,0 +1,16 @@ +"""Bounded, non-reflective errors for CP-002 data contracts.""" + +from __future__ import annotations + + +class ConfigurationError(ValueError): + """Configuration parsing/validation failure with path and reason only.""" + + def __init__(self, path: str, reason: str) -> None: + self.path = path if path else "/" + self.reason = reason + super().__init__(f"configuration error at {self.path}: {self.reason}") + + +class CanonicalizationError(ValueError): + """RFC 8785 canonicalization failure that does not reflect rejected data.""" diff --git a/src/actools/contracts/schemas/common-1.0.0.schema.json b/src/actools/contracts/schemas/common-1.0.0.schema.json new file mode 100644 index 0000000..26d0ea7 --- /dev/null +++ b/src/actools/contracts/schemas/common-1.0.0.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.actools.invalid/common-1.0.0.schema.json", + "$defs": { + "identifier": {"type":"string","minLength":2,"maxLength":63,"pattern":"^[a-z][a-z0-9-]{1,62}$"}, + "domain": {"type":"string","maxLength":253,"format":"actools-domain"}, + "endpoint": {"type":"string","maxLength":320,"format":"actools-endpoint"}, + "filesystemAuthorityId": {"type":"string","maxLength":71,"format":"actools-filesystem-authority-id"}, + "secretReference": {"type":"string","maxLength":160,"format":"actools-secret-reference"}, + "policyReference": {"type":"string","maxLength":160,"pattern":"^policy://[a-z][a-z0-9-]{0,62}/[A-Za-z0-9._-]{1,64}$"}, + "releaseReference": {"type":"string","maxLength":160,"pattern":"^release://[a-z][a-z0-9-]{0,62}/[A-Za-z0-9._-]{1,64}$"} + } +} diff --git a/src/actools/contracts/schemas/configuration-1.0.0.schema.json b/src/actools/contracts/schemas/configuration-1.0.0.schema.json new file mode 100644 index 0000000..20de197 --- /dev/null +++ b/src/actools/contracts/schemas/configuration-1.0.0.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.actools.invalid/configuration-1.0.0.schema.json", + "title": "Actools ready configuration 1.0.0", + "$defs": { + "notificationChannel": { + "type":"object","additionalProperties":false,"required":["enabled","secret_ref"], + "properties":{"enabled":{"type":"boolean"},"secret_ref":{"oneOf":[{"type":"null"},{"$ref":"common-1.0.0.schema.json#/$defs/secretReference"}]}} + } + }, + "type":"object","additionalProperties":false, + "required":["schema_version","profile","installation","site","environment","platform","capabilities","host","drupal","recovery","monitoring","secrets","references"], + "properties": { + "schema_version":{"const":"1.0.0"}, + "profile":{"const":"single-site-production"}, + "installation":{"type":"object","additionalProperties":false,"required":["id"],"properties":{"id":{"$ref":"common-1.0.0.schema.json#/$defs/identifier"}}}, + "site":{"type":"object","additionalProperties":false,"required":["id","domain"],"properties":{"id":{"$ref":"common-1.0.0.schema.json#/$defs/identifier"},"domain":{"$ref":"common-1.0.0.schema.json#/$defs/domain"}}}, + "environment":{"type":"object","additionalProperties":false,"required":["id","type"],"properties":{"id":{"$ref":"common-1.0.0.schema.json#/$defs/identifier"},"type":{"const":"production"}}}, + "platform":{"type":"object","additionalProperties":false,"required":["os_profile","runtime_backend"],"properties":{"os_profile":{"const":"ubuntu-26.04-amd64"},"runtime_backend":{"const":"docker-compose"}}}, + "capabilities":{"type":"object","additionalProperties":false,"required":["selected_ids"],"properties":{"selected_ids":{"type":"array","maxItems":2,"uniqueItems":true,"items":{"enum":["valkey-cache","cloudflare-standard-proxy"]}}}}, + "host":{"type":"object","additionalProperties":false,"required":["management_endpoint","filesystem_authority_id","access"],"properties":{ + "management_endpoint":{"$ref":"common-1.0.0.schema.json#/$defs/endpoint"},"filesystem_authority_id":{"$ref":"common-1.0.0.schema.json#/$defs/filesystemAuthorityId"}, + "access":{"type":"object","additionalProperties":false,"required":["linux_administration","privileged_drupal","editor"],"properties":{"linux_administration":{"const":"wireguard-ssh-keys"},"privileged_drupal":{"const":"vpn-mfa"},"editor":{"const":"https-mfa-limited-role"}}} + }}, + "drupal":{"type":"object","additionalProperties":false,"required":["storage","cache","ingress"],"properties":{ + "storage":{"type":"object","additionalProperties":false,"required":["public","private"],"properties":{"public":{"const":"local"},"private":{"const":"local"}}}, + "cache":{"type":"object","additionalProperties":false,"required":["mode"],"properties":{"mode":{"enum":["database","valkey"]}}}, + "ingress":{"type":"object","additionalProperties":false,"required":["mode"],"properties":{"mode":{"enum":["direct-caddy","cloudflare-standard"]}}} + }}, + "recovery":{"type":"object","additionalProperties":false,"required":["rpo_minutes","rto_minutes"],"properties":{"rpo_minutes":{"type":"integer","minimum":1,"maximum":60},"rto_minutes":{"type":"integer","minimum":1,"maximum":240}}}, + "monitoring":{"type":"object","additionalProperties":false,"required":["health_interval_minutes","security_audit_interval_hours","deep_diagnostic_interval_days","notifications"],"properties":{ + "health_interval_minutes":{"type":"integer","const":5},"security_audit_interval_hours":{"type":"integer","const":24},"deep_diagnostic_interval_days":{"type":"integer","const":7}, + "notifications":{"type":"object","additionalProperties":false,"required":["email","telegram"],"properties":{"email":{"$ref":"#/$defs/notificationChannel"},"telegram":{"$ref":"#/$defs/notificationChannel"}}} + }}, + "secrets":{"type":"object","additionalProperties":false,"required":["database_credentials"],"properties":{"database_credentials":{"$ref":"common-1.0.0.schema.json#/$defs/secretReference"}}}, + "references":{"type":"object","additionalProperties":false,"required":["policy","release"],"properties":{"policy":{"$ref":"common-1.0.0.schema.json#/$defs/policyReference"},"release":{"$ref":"common-1.0.0.schema.json#/$defs/releaseReference"}}} + } +} diff --git a/src/actools/contracts/schemas/configuration-defaults-1.0.0.json b/src/actools/contracts/schemas/configuration-defaults-1.0.0.json new file mode 100644 index 0000000..fa8fa84 --- /dev/null +++ b/src/actools/contracts/schemas/configuration-defaults-1.0.0.json @@ -0,0 +1,15 @@ +{ + "schema_version":"1.0.0","profile":"single-site-production","default_set":"configuration-defaults-1.0.0","default_set_version":"1.0.0", + "defaults": { + "/capabilities/selected_ids":[], + "/drupal/cache/mode":"database","/drupal/ingress/mode":"direct-caddy","/drupal/storage/private":"local","/drupal/storage/public":"local", + "/environment/type":"production", + "/host/access/editor":"https-mfa-limited-role","/host/access/linux_administration":"wireguard-ssh-keys","/host/access/privileged_drupal":"vpn-mfa", + "/monitoring/deep_diagnostic_interval_days":7,"/monitoring/health_interval_minutes":5, + "/monitoring/notifications/email/enabled":false,"/monitoring/notifications/email/secret_ref":null, + "/monitoring/notifications/telegram/enabled":false,"/monitoring/notifications/telegram/secret_ref":null, + "/monitoring/security_audit_interval_hours":24, + "/platform/os_profile":"ubuntu-26.04-amd64","/platform/runtime_backend":"docker-compose", + "/recovery/rpo_minutes":60,"/recovery/rto_minutes":240 + } +} diff --git a/tests/fixtures/configuration/invalid_alias.yaml b/tests/fixtures/configuration/invalid_alias.yaml new file mode 100644 index 0000000..3a1f60a --- /dev/null +++ b/tests/fixtures/configuration/invalid_alias.yaml @@ -0,0 +1,5 @@ +schema_version: 1.0.0 +profile: single-site-production +installation: &install + id: install-main +site: *install diff --git a/tests/fixtures/configuration/invalid_ambiguous.yaml b/tests/fixtures/configuration/invalid_ambiguous.yaml new file mode 100644 index 0000000..8d1ee29 --- /dev/null +++ b/tests/fixtures/configuration/invalid_ambiguous.yaml @@ -0,0 +1,4 @@ +schema_version: 1.0.0 +profile: single-site-production +installation: + id: yes diff --git a/tests/fixtures/configuration/invalid_duplicate.json b/tests/fixtures/configuration/invalid_duplicate.json new file mode 100644 index 0000000..11aef4b --- /dev/null +++ b/tests/fixtures/configuration/invalid_duplicate.json @@ -0,0 +1 @@ +{"schema_version":"1.0.0","schema_version":"1.0.0","profile":"single-site-production"} diff --git a/tests/fixtures/configuration/invalid_duplicate.yaml b/tests/fixtures/configuration/invalid_duplicate.yaml new file mode 100644 index 0000000..6ed5f0f --- /dev/null +++ b/tests/fixtures/configuration/invalid_duplicate.yaml @@ -0,0 +1,3 @@ +schema_version: 1.0.0 +profile: single-site-production +profile: single-site-production diff --git a/tests/fixtures/configuration/invalid_lone_surrogate.json b/tests/fixtures/configuration/invalid_lone_surrogate.json new file mode 100644 index 0000000..65c6f2d --- /dev/null +++ b/tests/fixtures/configuration/invalid_lone_surrogate.json @@ -0,0 +1 @@ +{"schema_version":"1.0.0","profile":"single-site-production","installation":{"id":"\ud800"}} diff --git a/tests/fixtures/configuration/invalid_merge.yaml b/tests/fixtures/configuration/invalid_merge.yaml new file mode 100644 index 0000000..d855bef --- /dev/null +++ b/tests/fixtures/configuration/invalid_merge.yaml @@ -0,0 +1,6 @@ +schema_version: 1.0.0 +profile: single-site-production +base: &base + id: install-main +installation: + <<: *base diff --git a/tests/fixtures/configuration/invalid_nonfinite.json b/tests/fixtures/configuration/invalid_nonfinite.json new file mode 100644 index 0000000..de9bace --- /dev/null +++ b/tests/fixtures/configuration/invalid_nonfinite.json @@ -0,0 +1 @@ +{"schema_version":"1.0.0","profile":"single-site-production","value":NaN} diff --git a/tests/fixtures/configuration/invalid_tag.yaml b/tests/fixtures/configuration/invalid_tag.yaml new file mode 100644 index 0000000..af82f9e --- /dev/null +++ b/tests/fixtures/configuration/invalid_tag.yaml @@ -0,0 +1,2 @@ +schema_version: 1.0.0 +profile: !!str single-site-production diff --git a/tests/fixtures/configuration/invalid_unknown.json b/tests/fixtures/configuration/invalid_unknown.json new file mode 100644 index 0000000..3c22c1b --- /dev/null +++ b/tests/fixtures/configuration/invalid_unknown.json @@ -0,0 +1 @@ +{"schema_version":"1.0.0","profile":"single-site-production","unexpected":"CP002_UNKNOWN"} diff --git a/tests/fixtures/configuration/valid_minimal.json b/tests/fixtures/configuration/valid_minimal.json new file mode 100644 index 0000000..f4c5477 --- /dev/null +++ b/tests/fixtures/configuration/valid_minimal.json @@ -0,0 +1,10 @@ +{ + "schema_version": "1.0.0", + "profile": "single-site-production", + "installation": {"id": "install-main"}, + "site": {"id": "site-main", "domain": "example.test"}, + "environment": {"id": "production-main"}, + "host": {"management_endpoint": "admin.example.test:22", "filesystem_authority_id": "fs-auth:main"}, + "secrets": {"database_credentials": "secret://database/application"}, + "references": {"policy": "policy://single-site-production/1.0.0", "release": "release://actools/0.1.0-dev"} +} diff --git a/tests/fixtures/configuration/valid_minimal.yaml b/tests/fixtures/configuration/valid_minimal.yaml new file mode 100644 index 0000000..db1546f --- /dev/null +++ b/tests/fixtures/configuration/valid_minimal.yaml @@ -0,0 +1,17 @@ +schema_version: 1.0.0 +profile: single-site-production +installation: + id: install-main +site: + id: site-main + domain: example.test +environment: + id: production-main +host: + management_endpoint: admin.example.test:22 + filesystem_authority_id: fs-auth:main +secrets: + database_credentials: secret://database/application +references: + policy: policy://single-site-production/1.0.0 + release: release://actools/0.1.0-dev diff --git a/tests/fixtures/configuration/valid_null_false.json b/tests/fixtures/configuration/valid_null_false.json new file mode 100644 index 0000000..488cbeb --- /dev/null +++ b/tests/fixtures/configuration/valid_null_false.json @@ -0,0 +1,9 @@ +{ + "schema_version":"1.0.0","profile":"single-site-production", + "installation":{"id":"install-main"},"site":{"id":"site-main","domain":"example.test"}, + "environment":{"id":"production-main","type":"production"},"capabilities":{"selected_ids":[]}, + "host":{"management_endpoint":"admin.example.test:22","filesystem_authority_id":"fs-auth:main"}, + "monitoring":{"notifications":{"email":{"enabled":false,"secret_ref":null},"telegram":{"enabled":false,"secret_ref":null}}}, + "secrets":{"database_credentials":"secret://database/application"}, + "references":{"policy":"policy://single-site-production/1.0.0","release":"release://actools/0.1.0-dev"} +} diff --git a/tests/fixtures/configuration/valid_null_false.yaml b/tests/fixtures/configuration/valid_null_false.yaml new file mode 100644 index 0000000..c765bc2 --- /dev/null +++ b/tests/fixtures/configuration/valid_null_false.yaml @@ -0,0 +1,13 @@ +schema_version: 1.0.0 +profile: single-site-production +installation: {id: install-main} +site: {id: site-main, domain: example.test} +environment: {id: production-main, type: production} +capabilities: {selected_ids: []} +host: {management_endpoint: admin.example.test:22, filesystem_authority_id: fs-auth:main} +monitoring: + notifications: + email: {enabled: false, secret_ref: null} + telegram: {enabled: false, secret_ref: null} +secrets: {database_credentials: secret://database/application} +references: {policy: policy://single-site-production/1.0.0, release: release://actools/0.1.0-dev} diff --git a/tests/fixtures/jcs/cyberphone/PROVENANCE.md b/tests/fixtures/jcs/cyberphone/PROVENANCE.md new file mode 100644 index 0000000..94c216d --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/PROVENANCE.md @@ -0,0 +1,259 @@ +# RFC 8785/JCS conformance-vector provenance + +These twelve files are vendored byte-for-byte for CP-002 conformance testing from the public `cyberphone/json-canonicalization` repository at exact commit: + +`19d51d7fe467d4706a3ff08adf8a748f29fc21e0` + +Only the six compact input/output pairs `arrays`, `french`, `structures`, `unicode`, `values`, and `weird` are included. The upstream 100-million-number corpus is not vendored. + +## Byte identities + +The SHA-256 values below are **coder-stage** digests computed from the actual local fixture bytes. They are not the packet-required Human-Git receipt. The operator must independently recompute all twelve SHA-256 values and the set digest after receiving/applying the delivery; only that human receipt can close the provenance evidence obligation. + +| Upstream path | Local path | SHA-256 | +|---|---|---| +| `testdata/input/arrays.json` | `tests/fixtures/jcs/cyberphone/input/arrays.json` | `e503b6d71d1afa595b1c74b1016445c944cd89f90418066b23de1aeda7d17563` | +| `testdata/input/french.json` | `tests/fixtures/jcs/cyberphone/input/french.json` | `03676a951cd8753ac62589f72eb2105cc782c33425418cfe1d517c111f6e5d5a` | +| `testdata/input/structures.json` | `tests/fixtures/jcs/cyberphone/input/structures.json` | `d66893805be1784116af50af3110d08766c70a6b4aad93374723f72346e7aaa6` | +| `testdata/input/unicode.json` | `tests/fixtures/jcs/cyberphone/input/unicode.json` | `4621864e014d4a805a563f55b9ea20aba4a2d2dc09c7394f625496998c00702c` | +| `testdata/input/values.json` | `tests/fixtures/jcs/cyberphone/input/values.json` | `c4a041b503d6bc236036ef44db4dac499272f60fc22c40dc3b7a54870ba6f1c3` | +| `testdata/input/weird.json` | `tests/fixtures/jcs/cyberphone/input/weird.json` | `a3a905266bd4a49a969274ea69baa14ee0c4af0ead926d6fa2b7612b4af75387` | +| `testdata/output/arrays.json` | `tests/fixtures/jcs/cyberphone/output/arrays.json` | `099601b171cafed97c333f8878d68e7f8c8f795412adb34b2fdcf0e7c7beac42` | +| `testdata/output/french.json` | `tests/fixtures/jcs/cyberphone/output/french.json` | `d99d0ebdcb0033cb858cfa830ae46bc0fb3309413b271f1da828c89901a27ed5` | +| `testdata/output/structures.json` | `tests/fixtures/jcs/cyberphone/output/structures.json` | `605f65004ec2db7692522a0852c22f1c989e036d547e88963d1a3143cf3195d5` | +| `testdata/output/unicode.json` | `tests/fixtures/jcs/cyberphone/output/unicode.json` | `0d99aad92a125196ff887876643fd3206786a84ddce2cee52ba4ad256d2381d3` | +| `testdata/output/values.json` | `tests/fixtures/jcs/cyberphone/output/values.json` | `2d5e01a318d0f0879ab568c4be289c8b1f64ef8921a53c6277d5e069978baacb` | +| `testdata/output/weird.json` | `tests/fixtures/jcs/cyberphone/output/weird.json` | `6af595a9aa80110b964b4de3f82a05fa6ae7423005019bacfa2620dddc4e94d1` | + +For one deterministic set identity, form twelve UTF-8/LF `sha256sum`-style lines using paths relative to this directory, in this exact order: all six `input/.json` entries in `arrays`, `french`, `structures`, `unicode`, `values`, `weird` order, followed by the six matching `output/.json` entries in the same order. Hash those manifest bytes. The resulting manifest SHA-256 is: + +`99ec46b9c79cd60a60315a78346a67d760ec59c0f528f053176feefb7957414b` + +## License + +The selected upstream vector material is licensed under the Apache License, Version 2.0. The pinned upstream repository `LICENSE` carries the following copyright/license notice for this material: + +> Copyright 2018 Anders Rundgren +> +> Licensed under the Apache License, Version 2.0 (the "License"); +> you may not use this file except in compliance with the License. +> You may obtain a copy of the License at +> +> https://www.apache.org/licenses/LICENSE-2.0 +> +> Unless required by applicable law or agreed to in writing, software +> distributed under the License is distributed on an "AS IS" BASIS, +> WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +> See the License for the specific language governing permissions and +> limitations under the License. + +### Complete Apache License 2.0 text + +The complete license copy required for redistribution of the vendored vectors follows verbatim: + +```text + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +``` + +No upstream implementation code is vendored by CP-002; only the listed data vectors are copied. diff --git a/tests/fixtures/jcs/cyberphone/input/arrays.json b/tests/fixtures/jcs/cyberphone/input/arrays.json new file mode 100644 index 0000000..20e6226 --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/input/arrays.json @@ -0,0 +1,8 @@ +[ + 56, + { + "d": true, + "10": null, + "1": [ ] + } +] diff --git a/tests/fixtures/jcs/cyberphone/input/french.json b/tests/fixtures/jcs/cyberphone/input/french.json new file mode 100644 index 0000000..4ff6d3d --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/input/french.json @@ -0,0 +1,6 @@ +{ + "peach": "This sorting order", + "péché": "is wrong according to French", + "pêche": "but canonicalization MUST", + "sin": "ignore locale" +} diff --git a/tests/fixtures/jcs/cyberphone/input/structures.json b/tests/fixtures/jcs/cyberphone/input/structures.json new file mode 100644 index 0000000..eb71efb --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/input/structures.json @@ -0,0 +1,8 @@ +{ + "1": {"f": {"f": "hi","F": 5} ,"\n": 56.0}, + "10": { }, + "": "empty", + "a": { }, + "111": [ {"e": "yes","E": "no" } ], + "A": { } +} \ No newline at end of file diff --git a/tests/fixtures/jcs/cyberphone/input/unicode.json b/tests/fixtures/jcs/cyberphone/input/unicode.json new file mode 100644 index 0000000..4b5bc76 --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/input/unicode.json @@ -0,0 +1,3 @@ +{ + "Unnormalized Unicode":"A\u030a" +} diff --git a/tests/fixtures/jcs/cyberphone/input/values.json b/tests/fixtures/jcs/cyberphone/input/values.json new file mode 100644 index 0000000..f7712c2 --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/input/values.json @@ -0,0 +1,5 @@ +{ + "numbers": [333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001], + "string": "\u20ac$\u000F\u000aA'\u0042\u0022\u005c\\\"\/", + "literals": [null, true, false] +} \ No newline at end of file diff --git a/tests/fixtures/jcs/cyberphone/input/weird.json b/tests/fixtures/jcs/cyberphone/input/weird.json new file mode 100644 index 0000000..53fabe6 --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/input/weird.json @@ -0,0 +1,11 @@ +{ + "\u20ac": "Euro Sign", + "\r": "Carriage Return", + "\u000a": "Newline", + "1": "One", + "\u0080": "Control\u007f", + "\ud83d\ude02": "Smiley", + "\u00f6": "Latin Small Letter O With Diaeresis", + "\ufb33": "Hebrew Letter Dalet With Dagesh", + "": "Browser Challenge" +} diff --git a/tests/fixtures/jcs/cyberphone/output/arrays.json b/tests/fixtures/jcs/cyberphone/output/arrays.json new file mode 100644 index 0000000..5efb93d --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/output/arrays.json @@ -0,0 +1 @@ +[56,{"1":[],"10":null,"d":true}] \ No newline at end of file diff --git a/tests/fixtures/jcs/cyberphone/output/french.json b/tests/fixtures/jcs/cyberphone/output/french.json new file mode 100644 index 0000000..2e15cd1 --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/output/french.json @@ -0,0 +1 @@ +{"peach":"This sorting order","péché":"is wrong according to French","pêche":"but canonicalization MUST","sin":"ignore locale"} \ No newline at end of file diff --git a/tests/fixtures/jcs/cyberphone/output/structures.json b/tests/fixtures/jcs/cyberphone/output/structures.json new file mode 100644 index 0000000..dc21e24 --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/output/structures.json @@ -0,0 +1 @@ +{"":"empty","1":{"\n":56,"f":{"F":5,"f":"hi"}},"10":{},"111":[{"E":"no","e":"yes"}],"A":{},"a":{}} \ No newline at end of file diff --git a/tests/fixtures/jcs/cyberphone/output/unicode.json b/tests/fixtures/jcs/cyberphone/output/unicode.json new file mode 100644 index 0000000..ee60fd1 --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/output/unicode.json @@ -0,0 +1 @@ +{"Unnormalized Unicode":"Å"} \ No newline at end of file diff --git a/tests/fixtures/jcs/cyberphone/output/values.json b/tests/fixtures/jcs/cyberphone/output/values.json new file mode 100644 index 0000000..29b720b --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/output/values.json @@ -0,0 +1 @@ +{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\u000f\nA'B\"\\\\\"/"} \ No newline at end of file diff --git a/tests/fixtures/jcs/cyberphone/output/weird.json b/tests/fixtures/jcs/cyberphone/output/weird.json new file mode 100644 index 0000000..62c83a3 --- /dev/null +++ b/tests/fixtures/jcs/cyberphone/output/weird.json @@ -0,0 +1 @@ +{"\n":"Newline","\r":"Carriage Return","1":"One","":"Browser Challenge","€":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😂":"Smiley","דּ":"Hebrew Letter Dalet With Dagesh"} \ No newline at end of file diff --git a/tests/test_configuration_contract.py b/tests/test_configuration_contract.py new file mode 100644 index 0000000..470af37 --- /dev/null +++ b/tests/test_configuration_contract.py @@ -0,0 +1,425 @@ +from __future__ import annotations +import copy, json +from pathlib import Path +import pytest +from actools.contracts.configuration import CONTRACT_VERSION, SUPPORTED_PROFILE, assert_schema_quality, load_configuration, parse_configuration_bytes, parse_json_bytes, parse_yaml_bytes, resolve_configuration +from actools.contracts.errors import ConfigurationError + +ROOT=Path(__file__).resolve().parents[1]; FIX=ROOT/'tests/fixtures/configuration' +def rb(name:str)->bytes: return (FIX/name).read_bytes() +def minimal()->dict: return json.loads(rb('valid_minimal.json')) + +def test_identity(): assert (CONTRACT_VERSION,SUPPORTED_PROFILE)==('1.0.0','single-site-production') +def test_equivalent_json_yaml_origins_and_jcs(): + j=load_configuration(rb('valid_minimal.json'),syntax='json'); y=load_configuration(rb('valid_minimal.yaml'),syntax='yaml') + assert j.configuration==y.configuration; assert j.origins==y.origins + assert j.canonical_configuration_bytes()==y.canonical_configuration_bytes(); assert j.canonical_resolution_bytes()==y.canonical_resolution_bytes() +def test_defaults_omission_only_origins_and_no_input_mutation(): + src=minimal(); before=copy.deepcopy(src); r=resolve_configuration(src); assert src==before + assert r.configuration['platform']=={'os_profile':'ubuntu-26.04-amd64','runtime_backend':'docker-compose'} + assert r.configuration['capabilities']['selected_ids']==[] + assert r.origins['/platform/os_profile']=={'origin':'release_default','default_set':'configuration-defaults-1.0.0','default_set_version':'1.0.0'} + assert r.origins['/site/domain']=={'origin':'operator'} +def test_null_false_empty_preserved(): + j=load_configuration(rb('valid_null_false.json'),syntax='json'); y=load_configuration(rb('valid_null_false.yaml'),syntax='yaml') + assert j.configuration==y.configuration; assert j.origins==y.origins + assert j.origins['/capabilities/selected_ids']=={'origin':'operator'} + assert j.origins['/monitoring/notifications/email/enabled']=={'origin':'operator'} + assert j.origins['/monitoring/notifications/email/secret_ref']=={'origin':'operator'} +def test_explicit_zero_not_defaulted(): + v=minimal(); v['recovery']={'rpo_minutes':0} + with pytest.raises(ConfigurationError) as e: resolve_configuration(v) + assert (e.value.path,e.value.reason)==('/recovery/rpo_minutes','value_out_of_range') +def test_origin_map_leaf_rule(): + r=resolve_configuration(minimal()); assert '' not in r.origins and '/installation' not in r.origins and '/capabilities/selected_ids' in r.origins + +@pytest.mark.parametrize('name,syntax,reasons',[ + ('invalid_duplicate.json','json',{'duplicate_json_key'}),('invalid_duplicate.yaml','yaml',{'duplicate_yaml_key'}), + ('invalid_ambiguous.yaml','yaml',{'yaml_ambiguous_scalar'}),('invalid_tag.yaml','yaml',{'yaml_tag_forbidden'}), + ('invalid_merge.yaml','yaml',{'yaml_anchor_forbidden','yaml_alias_forbidden','yaml_merge_key_forbidden'}), + ('invalid_alias.yaml','yaml',{'yaml_anchor_forbidden','yaml_alias_forbidden'}),('invalid_unknown.json','json',{'unknown_property'}), + ('invalid_lone_surrogate.json','json',{'invalid_unicode_scalar'}),('invalid_nonfinite.json','json',{'nonfinite_json_constant'})]) +def test_fixed_negatives(name,syntax,reasons): + with pytest.raises(ConfigurationError) as e: + v=parse_configuration_bytes(rb(name),syntax=syntax) + if isinstance(v,dict): resolve_configuration(v) + assert e.value.reason in reasons + +def test_json_strict_failures_and_safe_bounds(): + cases=[(b'"\xff"','invalid_utf8'),(b'{} {}','invalid_json'),(b'{"x":Infinity}','nonfinite_json_constant'),(b'{"x":1e10000}','nonfinite_number'),(b'{"x":9007199254740992}','integer_outside_safe_range')] + for raw,expected in cases: + with pytest.raises(ConfigurationError) as e: parse_json_bytes(raw) + assert e.value.reason==expected + assert parse_json_bytes(b'{"x":9007199254740991}')["x"]==9007199254740991 + +@pytest.mark.parametrize('scalar,expected',[('null',None),('true',True),('false',False),('0',0),('-0',0),('1.25',1.25),('1e3',1000.0),('plain-text','plain-text'),('1.0.0','1.0.0'),('example.test','example.test')]) +def test_yaml_accepted_plain_surface(scalar,expected): assert parse_yaml_bytes(f'value: {scalar}\n'.encode())=={'value':expected} +@pytest.mark.parametrize('scalar',['yes','No','ON','TRUE','Null','~','2026-09-11','2026-09-11T12:00:00Z','12:34:56','0x10','0o10','0b10','01','01.2','01e2','-01.2','1_000','1:20','.5','1.','+1','.nan','.inf','-.inf']) +def test_yaml_rejected_ambiguous_surface(scalar): + with pytest.raises(ConfigurationError): parse_yaml_bytes(f'value: {scalar}\n'.encode()) +def test_yaml_quoted_ambiguous_is_string(): assert parse_yaml_bytes(b'value: "yes"\n')=={'value':'yes'} +@pytest.mark.parametrize('raw,reason',[(b'value:\n','yaml_implicit_empty_null'),(b'value: |\n text\n','yaml_scalar_style_forbidden'),(b'%YAML 1.2\n---\nvalue: text\n','yaml_directive_forbidden'),(b'---\nvalue: one\n---\nvalue: two\n','yaml_document_count'),(b'value: &a text\nother: *a\n','yaml_anchor_forbidden'),(b'value: !!str text\n','yaml_tag_forbidden'),(b'true: value\n','non_string_mapping_key'),(b'<<: {a: 1}\n','yaml_merge_key_forbidden'),(b'value: "\\uD800"\n','invalid_unicode_scalar'),(b'value: \xff\n','invalid_utf8')]) +def test_yaml_structural_extensions(raw,reason): + with pytest.raises(ConfigurationError) as e: parse_yaml_bytes(raw) + assert e.value.reason==reason + +def test_input_byte_limit_boundary(): + raw=b'{}'+b' '*(1_048_576-2); assert parse_json_bytes(raw)=={} + with pytest.raises(ConfigurationError) as e: parse_json_bytes(raw+b' ') + assert e.value.reason=='input_byte_limit' +def test_depth_boundary_root_level_one(): + v:object=0 + for _ in range(32): v=[v] + assert parse_json_bytes(json.dumps(v).encode())==v + with pytest.raises(ConfigurationError) as e: parse_json_bytes(json.dumps([v]).encode()) + assert e.value.reason=='container_depth_limit' +def test_mapping_and_sequence_boundaries(): + m={f'k{i}':0 for i in range(256)}; assert len(parse_json_bytes(json.dumps(m).encode()))==256 + m['overflow']=0 + with pytest.raises(ConfigurationError) as e: parse_json_bytes(json.dumps(m).encode()) + assert e.value.reason=='mapping_entry_limit' + assert len(parse_json_bytes(json.dumps([0]*256).encode()))==256 + with pytest.raises(ConfigurationError) as e: parse_json_bytes(json.dumps([0]*257).encode()) + assert e.value.reason=='sequence_element_limit' +def test_aggregate_node_boundary(): + ok=[[0]*256 for _ in range(15)]+[[0]*239]; assert parse_json_bytes(json.dumps(ok).encode())==ok + bad=[[0]*256 for _ in range(15)]+[[0]*240] + with pytest.raises(ConfigurationError) as e: parse_json_bytes(json.dumps(bad).encode()) + assert e.value.reason=='aggregate_node_limit' +def test_string_boundary(): + assert len(parse_json_bytes(json.dumps('x'*16384).encode()))==16384 + with pytest.raises(ConfigurationError) as e: parse_json_bytes(json.dumps('x'*16385).encode()) + assert e.value.reason=='string_too_long' + +@pytest.mark.parametrize('field,value,path',[('schema_version','2.0.0','/schema_version'),('profile','other','/profile')]) +def test_unsupported_identity(field,value,path): + v=minimal(); v[field]=value + with pytest.raises(ConfigurationError) as e: resolve_configuration(v) + assert e.value.path==path and e.value.reason.startswith('unsupported_') +def test_unknown_nested_property(): + v=minimal(); v['site']['unexpected']='x' + with pytest.raises(ConfigurationError) as e: resolve_configuration(v) + assert (e.value.path,e.value.reason)==('/site','unknown_property') +@pytest.mark.parametrize('mutator,path',[(lambda v:v['site'].update(domain='Example.COM'),'/site/domain'),(lambda v:v['host'].update(management_endpoint='bad endpoint'),'/host/management_endpoint'),(lambda v:v['host'].update(filesystem_authority_id='/srv/actools'),'/host/filesystem_authority_id'),(lambda v:v['secrets'].update(database_credentials='plaintext-password'),'/secrets/database_credentials')]) +def test_owned_formats_asserted(mutator,path): + v=minimal(); mutator(v) + with pytest.raises(ConfigurationError) as e: resolve_configuration(v) + assert (e.value.path,e.value.reason)==(path,'invalid_format') +def test_schema_files_do_not_use_json_schema_default_mutation(): + schema_dir=ROOT/'src/actools/contracts/schemas' + def contains_default_keyword(value): + if isinstance(value,dict): + return 'default' in value or any(contains_default_keyword(v) for v in value.values()) + if isinstance(value,list): + return any(contains_default_keyword(v) for v in value) + return False + for name in ('common-1.0.0.schema.json','configuration-1.0.0.schema.json'): + assert not contains_default_keyword(json.loads((schema_dir/name).read_text(encoding='utf-8'))) + +def test_unregistered_schema_format_fails_quality(): + with pytest.raises(ConfigurationError) as e: assert_schema_quality({'$schema':'https://json-schema.org/draft/2020-12/schema','type':'string','format':'not-owned'}) + assert e.value.reason=='schema_unregistered_format' +def test_semantic_valid_capabilities_and_list_origins(): + v=minimal(); v['capabilities']={'selected_ids':['valkey-cache','cloudflare-standard-proxy']}; v['drupal']={'cache':{'mode':'valkey'},'ingress':{'mode':'cloudflare-standard'}} + r=resolve_configuration(v); assert r.origins['/capabilities/selected_ids/0']=={'origin':'operator'} +def test_semantic_contradictions(): + v=minimal(); v['capabilities']={'selected_ids':['valkey-cache']} + with pytest.raises(ConfigurationError) as e: resolve_configuration(v) + assert e.value.reason=='valkey_capability_mismatch' + v=minimal(); v['monitoring']={'notifications':{'email':{'enabled':True,'secret_ref':None}}} + with pytest.raises(ConfigurationError) as e: resolve_configuration(v) + assert e.value.reason=='enabled_channel_requires_secret_reference' +def test_secret_canary_not_reflected(): + canary='CP002_SECRET_CANARY_DO_NOT_PRINT'; v=minimal(); v['secrets']['database_credentials']=canary + with pytest.raises(ConfigurationError) as e: resolve_configuration(v) + assert canary not in str(e.value) and len(str(e.value))<160 +def test_inline_secret_property_rejected_nonreflective(): + canary='CP002_SECRET_CANARY_DO_NOT_PRINT'; v=minimal(); v['secrets']['password']=canary + with pytest.raises(ConfigurationError) as e: resolve_configuration(v) + assert canary not in str(e.value) and e.value.reason=='unknown_property' +def test_envelope_detached(): + r=resolve_configuration(minimal()); e=r.resolution_envelope(); e['configuration']['site']['domain']='changed.invalid'; e['origins'].clear(); assert r.configuration['site']['domain']=='example.test' and r.origins +def test_zero_write_sentinel(tmp_path:Path,monkeypatch): + sentinel=tmp_path/'sentinel'; sentinel.write_text('unchanged'); before={p.name:p.read_bytes() for p in tmp_path.iterdir()} + def forbidden(*a,**k): raise AssertionError('write-capable pathlib operation reached') + for name in ('write_text','write_bytes','mkdir','unlink','rename','replace','touch'): monkeypatch.setattr(Path,name,forbidden) + assert load_configuration(rb('valid_minimal.json'),syntax='json').configuration['site']['domain']=='example.test' + with pytest.raises(ConfigurationError): load_configuration(rb('invalid_duplicate.json'),syntax='json') + after={p.name:p.read_bytes() for p in tmp_path.iterdir()}; assert after==before + + +def _leaf_pointers(value: object, parts: tuple[object, ...] = ()) -> set[str]: + pointer = "" if not parts else "/" + "/".join( + str(part).replace("~", "~0").replace("/", "~1") for part in parts + ) + if isinstance(value, dict): + if not value: + return {pointer} + result: set[str] = set() + for key, child in value.items(): + result.update(_leaf_pointers(child, (*parts, key))) + return result + if isinstance(value, list): + if not value: + return {pointer} + result: set[str] = set() + for index, child in enumerate(value): + result.update(_leaf_pointers(child, (*parts, index))) + return result + return {pointer} + + +def test_origin_map_exactly_covers_resolved_leaves_and_empty_containers(): + resolved = resolve_configuration(minimal()) + assert set(resolved.origins) == _leaf_pointers(resolved.configuration) + for record in resolved.origins.values(): + assert record["origin"] in {"operator", "release_default"} + if record["origin"] == "release_default": + assert record == { + "origin": "release_default", + "default_set": "configuration-defaults-1.0.0", + "default_set_version": "1.0.0", + } + else: + assert record == {"origin": "operator"} + + +def _walk_schema_objects(value: object): + if isinstance(value, dict): + if value.get("type") == "object": + yield value + for child in value.values(): + yield from _walk_schema_objects(child) + elif isinstance(value, list): + for child in value: + yield from _walk_schema_objects(child) + + +def test_owned_schema_is_closed_and_does_not_use_default_mutation_keyword(): + schema = json.loads( + (ROOT / "src/actools/contracts/schemas/configuration-1.0.0.schema.json").read_text( + encoding="utf-8" + ) + ) + common = json.loads( + (ROOT / "src/actools/contracts/schemas/common-1.0.0.schema.json").read_text( + encoding="utf-8" + ) + ) + assert '"default"' not in json.dumps(schema, sort_keys=True) + assert all(item.get("additionalProperties") is False for item in _walk_schema_objects(schema)) + assert all(item.get("additionalProperties") is False for item in _walk_schema_objects(common)) + + +@pytest.mark.parametrize( + "selected,cache,ingress,reason", + [ + (["valkey-cache"], "database", "direct-caddy", "valkey_capability_mismatch"), + ([], "valkey", "direct-caddy", "valkey_capability_mismatch"), + (["cloudflare-standard-proxy"], "database", "direct-caddy", "cloudflare_capability_mismatch"), + ([], "database", "cloudflare-standard", "cloudflare_capability_mismatch"), + ], +) +def test_capability_mode_contradictions_fail(selected, cache, ingress, reason): + value = minimal() + value["capabilities"] = {"selected_ids": selected} + value["drupal"] = {"cache": {"mode": cache}, "ingress": {"mode": ingress}} + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert exc.value.reason == reason + + +@pytest.mark.parametrize("channel", ["email", "telegram"]) +def test_enabled_notification_requires_secret_reference(channel): + value = minimal() + value["monitoring"] = { + "notifications": {channel: {"enabled": True, "secret_ref": None}} + } + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert exc.value.reason == "enabled_channel_requires_secret_reference" + + +@pytest.mark.parametrize("channel", ["email", "telegram"]) +def test_disabled_notification_rejects_retained_secret_reference(channel): + value = minimal() + value["monitoring"] = { + "notifications": { + channel: { + "enabled": False, + "secret_ref": "secret://notifications/channel", + } + } + } + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert exc.value.reason == "disabled_channel_forbids_secret_reference" + + +def test_configuration_module_has_no_effectful_runtime_imports(): + import ast + + source = (ROOT / "src/actools/contracts/configuration.py").read_text(encoding="utf-8") + tree = ast.parse(source) + forbidden = {"os", "subprocess", "socket", "urllib", "http", "requests"} + imports: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imports.add(node.module.split(".")[0]) + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + assert node.func.id != "open" + assert imports.isdisjoint(forbidden) + + +def test_explicit_null_and_empty_string_are_not_replaced_by_release_defaults(): + value = minimal() + value["environment"]["type"] = None + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert (exc.value.path, exc.value.reason) == ("/environment/type", "unsupported_value") + + value = minimal() + value["platform"] = {"os_profile": ""} + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert (exc.value.path, exc.value.reason) == ("/platform/os_profile", "unsupported_value") + + +def test_yaml_resource_boundaries_match_json_model_limits(): + nested: object = 0 + for _ in range(32): + nested = [nested] + assert parse_yaml_bytes(json.dumps(nested).encode("utf-8")) == nested + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(json.dumps([nested]).encode("utf-8")) + assert exc.value.reason == "container_depth_limit" + + mapping = {f"k{i}": 0 for i in range(256)} + assert len(parse_yaml_bytes(json.dumps(mapping).encode("utf-8"))) == 256 + mapping["overflow"] = 0 + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(json.dumps(mapping).encode("utf-8")) + assert exc.value.reason == "mapping_entry_limit" + + assert len(parse_yaml_bytes(json.dumps([0] * 256).encode("utf-8"))) == 256 + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(json.dumps([0] * 257).encode("utf-8")) + assert exc.value.reason == "sequence_element_limit" + + aggregate_ok = [[0] * 256 for _ in range(15)] + [[0] * 239] + assert parse_yaml_bytes(json.dumps(aggregate_ok).encode("utf-8")) == aggregate_ok + aggregate_bad = [[0] * 256 for _ in range(15)] + [[0] * 240] + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(json.dumps(aggregate_bad).encode("utf-8")) + assert exc.value.reason == "aggregate_node_limit" + + assert parse_yaml_bytes(("value: \"" + "x" * 16_384 + "\"\n").encode("utf-8"))["value"] == "x" * 16_384 + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(("value: \"" + "x" * 16_385 + "\"\n").encode("utf-8")) + assert exc.value.reason == "string_too_long" + + +def test_yaml_input_byte_limit_is_checked_before_parser_work(): + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(b"x" * (1_048_576 + 1)) + assert exc.value.reason == "input_byte_limit" + + +def test_integer_owned_fields_reject_integral_floats(): + value = minimal() + value["recovery"] = {"rpo_minutes": 60.0} + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert (exc.value.path, exc.value.reason) == ("/recovery/rpo_minutes", "wrong_type") + + value = minimal() + value["monitoring"] = {"health_interval_minutes": 5.0} + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert (exc.value.path, exc.value.reason) == ("/monitoring/health_interval_minutes", "wrong_type") + + +def test_traceback_output_suppresses_rejected_secret_canaries(): + import traceback + + canary = "CP002_TRACEBACK_SECRET_CANARY_DO_NOT_PRINT" + raw_yaml = f"value: [{canary}\n".encode("utf-8") + try: + parse_yaml_bytes(raw_yaml) + except ConfigurationError as exc: + rendered = "".join(traceback.format_exception(exc)) + assert canary not in rendered + assert exc.__cause__ is None + assert exc.__suppress_context__ is True + else: + pytest.fail("malformed YAML unexpectedly accepted") + + raw_json = ("{\"value\":\"" + canary).encode("utf-8") + try: + parse_json_bytes(raw_json) + except ConfigurationError as exc: + rendered = "".join(traceback.format_exception(exc)) + assert canary not in rendered + assert exc.__cause__ is None + assert exc.__suppress_context__ is True + else: + pytest.fail("malformed JSON unexpectedly accepted") + + +def test_parser_error_paths_do_not_reflect_unowned_canary_keys(): + canary = "CP002_SECRET_CANARY_UNOWNED_KEY" + with pytest.raises(ConfigurationError) as exc: + parse_json_bytes(("{\"" + canary + "\":1e10000}").encode("utf-8")) + assert exc.value.path == "/" + assert canary not in str(exc.value) + + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes((canary + ": .nan\n").encode("utf-8")) + assert exc.value.path == "/" + assert canary not in str(exc.value) + + +def test_explicit_value_equal_to_release_default_remains_operator_origin(): + value = minimal() + value["platform"] = {"os_profile": "ubuntu-26.04-amd64"} + resolved = resolve_configuration(value) + assert resolved.origins["/platform/os_profile"] == {"origin": "operator"} + assert resolved.origins["/platform/runtime_backend"] == { + "origin": "release_default", + "default_set": "configuration-defaults-1.0.0", + "default_set_version": "1.0.0", + } + + +def test_required_field_errors_point_to_owned_missing_field(): + value = minimal() + del value["installation"] + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert (exc.value.path, exc.value.reason) == ("/installation", "required_field_missing") + + value = minimal() + del value["site"]["id"] + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert (exc.value.path, exc.value.reason) == ("/site/id", "required_field_missing") + + +def test_extreme_numeric_lexemes_fail_with_bounded_actools_reasons(): + huge_integer = b"9" * 10_000 + with pytest.raises(ConfigurationError) as exc: + parse_json_bytes(huge_integer) + assert exc.value.reason == "integer_outside_safe_range" + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(b"value: " + huge_integer + b"\n") + assert exc.value.reason == "integer_outside_safe_range" + + huge_exponent = b"1e999999" + with pytest.raises(ConfigurationError) as exc: + parse_json_bytes(huge_exponent) + assert exc.value.reason == "nonfinite_number" + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(b"value: " + huge_exponent + b"\n") + assert exc.value.reason == "nonfinite_number" diff --git a/tests/test_jcs_conformance.py b/tests/test_jcs_conformance.py new file mode 100644 index 0000000..1eccc6e --- /dev/null +++ b/tests/test_jcs_conformance.py @@ -0,0 +1,43 @@ +from __future__ import annotations +import hashlib +from pathlib import Path +import pytest +from actools.contracts.canonical import canonical_json_bytes +from actools.contracts.configuration import parse_json_bytes +from actools.contracts.errors import CanonicalizationError +ROOT=Path(__file__).resolve().parents[1]; V=ROOT/'tests/fixtures/jcs/cyberphone'; N=('arrays','french','structures','unicode','values','weird') +SHA={ +'input/arrays.json':'e503b6d71d1afa595b1c74b1016445c944cd89f90418066b23de1aeda7d17563','input/french.json':'03676a951cd8753ac62589f72eb2105cc782c33425418cfe1d517c111f6e5d5a','input/structures.json':'d66893805be1784116af50af3110d08766c70a6b4aad93374723f72346e7aaa6','input/unicode.json':'4621864e014d4a805a563f55b9ea20aba4a2d2dc09c7394f625496998c00702c','input/values.json':'c4a041b503d6bc236036ef44db4dac499272f60fc22c40dc3b7a54870ba6f1c3','input/weird.json':'a3a905266bd4a49a969274ea69baa14ee0c4af0ead926d6fa2b7612b4af75387','output/arrays.json':'099601b171cafed97c333f8878d68e7f8c8f795412adb34b2fdcf0e7c7beac42','output/french.json':'d99d0ebdcb0033cb858cfa830ae46bc0fb3309413b271f1da828c89901a27ed5','output/structures.json':'605f65004ec2db7692522a0852c22f1c989e036d547e88963d1a3143cf3195d5','output/unicode.json':'0d99aad92a125196ff887876643fd3206786a84ddce2cee52ba4ad256d2381d3','output/values.json':'2d5e01a318d0f0879ab568c4be289c8b1f64ef8921a53c6277d5e069978baacb','output/weird.json':'6af595a9aa80110b964b4de3f82a05fa6ae7423005019bacfa2620dddc4e94d1'} +SET='99ec46b9c79cd60a60315a78346a67d760ec59c0f528f053176feefb7957414b' +def test_vector_receipt_digests(): + lines=[] + for side in ('input','output'): + for name in N: + rel=f'{side}/{name}.json'; d=hashlib.sha256((V/rel).read_bytes()).hexdigest(); assert d==SHA[rel]; lines.append(f'{d} {rel}\n') + assert hashlib.sha256(''.join(lines).encode()).hexdigest()==SET +@pytest.mark.parametrize('name',N) +def test_published_pairs(name): assert canonical_json_bytes(parse_json_bytes((V/'input'/f'{name}.json').read_bytes()))==(V/'output'/f'{name}.json').read_bytes() +@pytest.mark.parametrize('raw,expected',[(b'0',b'0'),(b'-0',b'0'),(b'-0.0',b'0'),(b'4.50',b'4.5'),(b'2e-3',b'0.002'),(b'1e-7',b'1e-7'),(b'1e-6',b'0.000001'),(b'1e20',b'100000000000000000000'),(b'1e21',b'1e+21')]) +def test_number_boundaries(raw,expected): assert canonical_json_bytes(parse_json_bytes(raw))==expected +def test_safe_integer_boundary(): + assert canonical_json_bytes(parse_json_bytes(b'9007199254740991'))==b'9007199254740991' + with pytest.raises(Exception): parse_json_bytes(b'9007199254740992') +def test_canonical_rejects_nonfinite_surrogate_nonreflective(): + with pytest.raises(CanonicalizationError) as e: canonical_json_bytes(float('nan')) + assert 'nan' not in str(e.value).lower() + with pytest.raises(CanonicalizationError): canonical_json_bytes('\ud800') + + +def test_canonicalization_traceback_suppresses_underlying_rejected_value(): + import traceback + + rejected = 9_007_199_254_740_992 + try: + canonical_json_bytes(rejected) + except CanonicalizationError as exc: + rendered = "".join(traceback.format_exception(exc)) + assert str(rejected) not in rendered + assert exc.__cause__ is None + assert exc.__suppress_context__ is True + else: + pytest.fail("out-of-domain integer unexpectedly canonicalized") diff --git a/tests/test_package_contents.py b/tests/test_package_contents.py index e0be678..47c4cfe 100644 --- a/tests/test_package_contents.py +++ b/tests/test_package_contents.py @@ -1,14 +1,20 @@ from __future__ import annotations +import subprocess import zipfile from pathlib import Path import pytest from tools.check_source import ( + CONTRACT_FILES, + LOCK, CheckFailure, DIST_INFO, + EXPECTED_RUNTIME_REQUIREMENTS, + EXPECTED_WHEEL_MEMBERS, EXPECTED_WORKFLOW, + _create_isolated_installed_env, requirement_records, verify_wheel_inventory, verify_workflow_text, @@ -17,17 +23,43 @@ ROOT = Path(__file__).resolve().parents[1] -def test_project_declares_zero_runtime_dependencies() -> None: +def test_project_declares_exact_runtime_dependencies() -> None: import tomllib data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) - assert data["project"]["dependencies"] == [] + assert data["project"]["dependencies"] == [ + "PyYAML==6.0.3", + "jsonschema==4.26.0", + "rfc8785==0.1.4", + ] assert data["project"]["requires-python"] == ">=3.14,<3.15" assert data["project"]["scripts"] == {"actools": "actools.cli:main"} + assert data["tool"]["setuptools"]["package-data"] == { + "actools.contracts": ["schemas/*.json"] + } + + +def test_source_inputs_include_exact_runtime_and_ci_pins() -> None: + records = requirement_records( + (ROOT / "requirements" / "ci.in").read_text(encoding="utf-8") + ) + assert records[0] == "--only-binary=:all:" + assert set(records[1:]) == { + "build==1.6.0", + "pip==26.2.1", + "pip-audit==2.10.1", + "pytest==9.1.1", + "setuptools==84.0.0", + "PyYAML==6.0.3", + "jsonschema==4.26.0", + "rfc8785==0.1.4", + } def test_lock_records_are_pinned_and_hashed() -> None: - records = requirement_records((ROOT / "requirements" / "ci.lock").read_text(encoding="utf-8")) + records = requirement_records( + (ROOT / "requirements" / "ci.lock").read_text(encoding="utf-8") + ) assert records[0] == "--only-binary :all:" packages = [record for record in records[1:] if not record.startswith("--")] assert packages @@ -35,18 +67,32 @@ def test_lock_records_are_pinned_and_hashed() -> None: def _valid_members() -> dict[str, bytes | str]: - return { + schema_dir = ROOT / "src/actools/contracts/schemas" + metadata = ( + "Metadata-Version: 2.4\n" + "Name: actools-drupal\n" + "Version: 0.1.0.dev0\n" + "License-Expression: MIT\n" + "Requires-Python: >=3.14,<3.15\n" + "Requires-Dist: PyYAML==6.0.3\n" + "Requires-Dist: jsonschema==4.26.0\n" + "Requires-Dist: rfc8785==0.1.4\n\n" + ) + members: dict[str, bytes | str] = { "actools/__init__.py": "__version__ = '0.1.0.dev0'\n", "actools/cli.py": "def main(): return 0\n", + "actools/contracts/schemas/common-1.0.0.schema.json": ( + schema_dir / "common-1.0.0.schema.json" + ).read_bytes(), + "actools/contracts/schemas/configuration-1.0.0.schema.json": ( + schema_dir / "configuration-1.0.0.schema.json" + ).read_bytes(), + "actools/contracts/schemas/configuration-defaults-1.0.0.json": ( + schema_dir / "configuration-defaults-1.0.0.json" + ).read_bytes(), f"{DIST_INFO}/licenses/LICENSE": (ROOT / "LICENSE").read_bytes(), f"{DIST_INFO}/licenses/NOTICE.md": (ROOT / "NOTICE.md").read_bytes(), - f"{DIST_INFO}/METADATA": ( - "Metadata-Version: 2.4\n" - "Name: actools-drupal\n" - "Version: 0.1.0.dev0\n" - "License-Expression: MIT\n" - "Requires-Python: >=3.14,<3.15\n\n" - ), + f"{DIST_INFO}/METADATA": metadata, f"{DIST_INFO}/WHEEL": ( "Wheel-Version: 1.0\n" "Generator: setuptools (84.0.0)\n" @@ -57,9 +103,17 @@ def _valid_members() -> dict[str, bytes | str]: f"{DIST_INFO}/top_level.txt": "actools\n", f"{DIST_INFO}/RECORD": "placeholder\n", } + for member in CONTRACT_FILES: + members[member] = (ROOT / "src" / member).read_bytes() + return members -def _wheel(tmp_path: Path, members: dict[str, bytes | str], *, duplicate: str | None = None) -> Path: +def _wheel( + tmp_path: Path, + members: dict[str, bytes | str], + *, + duplicate: str | None = None, +) -> Path: path = tmp_path / "synthetic.whl" with zipfile.ZipFile(path, "w") as archive: for name, value in members.items(): @@ -69,6 +123,20 @@ def _wheel(tmp_path: Path, members: dict[str, bytes | str], *, duplicate: str | return path +def test_expected_wheel_shape_is_closed_and_contains_contract_resources() -> None: + assert len(EXPECTED_WHEEL_MEMBERS) == 16 + assert EXPECTED_RUNTIME_REQUIREMENTS == { + "pyyaml": "6.0.3", + "jsonschema": "4.26.0", + "rfc8785": "0.1.4", + } + assert { + "actools/contracts/schemas/common-1.0.0.schema.json", + "actools/contracts/schemas/configuration-1.0.0.schema.json", + "actools/contracts/schemas/configuration-defaults-1.0.0.json", + } <= EXPECTED_WHEEL_MEMBERS + + def test_wheel_inventory_accepts_only_complete_owned_shape(tmp_path: Path) -> None: wheel = _wheel(tmp_path, _valid_members()) digest = verify_wheel_inventory(wheel) @@ -85,12 +153,15 @@ def test_wheel_inventory_accepts_only_complete_owned_shape(tmp_path: Path) -> No "actools/.git/config", "actools/tests/fixture.py", "actools/credentials/key.pem", + "actools/contracts/schemas/unowned.json", "unowned.dist-info/payload.py", "../outside", "/absolute/path", ], ) -def test_wheel_inventory_rejects_any_extra_or_unsafe_member(tmp_path: Path, forbidden: str) -> None: +def test_wheel_inventory_rejects_any_extra_or_unsafe_member( + tmp_path: Path, forbidden: str +) -> None: members = _valid_members() members[forbidden] = "x" wheel = _wheel(tmp_path, members) @@ -99,13 +170,12 @@ def test_wheel_inventory_rejects_any_extra_or_unsafe_member(tmp_path: Path, forb def test_wheel_inventory_rejects_duplicate_member(tmp_path: Path) -> None: - members = _valid_members() - wheel = _wheel(tmp_path, members, duplicate="actools/cli.py") + wheel = _wheel(tmp_path, _valid_members(), duplicate="actools/cli.py") with pytest.raises(CheckFailure): verify_wheel_inventory(wheel) -def test_wheel_inventory_rejects_unowned_metadata_or_runtime_dependency(tmp_path: Path) -> None: +def test_wheel_inventory_rejects_unowned_runtime_dependency(tmp_path: Path) -> None: members = _valid_members() members[f"{DIST_INFO}/METADATA"] = ( "Metadata-Version: 2.4\n" @@ -113,11 +183,13 @@ def test_wheel_inventory_rejects_unowned_metadata_or_runtime_dependency(tmp_path "Version: 0.1.0.dev0\n" "License-Expression: MIT\n" "Requires-Python: >=3.14,<3.15\n" - "Requires-Dist: requests\n\n" + "Requires-Dist: PyYAML==6.0.3\n" + "Requires-Dist: jsonschema==4.26.0\n" + "Requires-Dist: rfc8785==0.1.4\n" + "Requires-Dist: requests==2.34.2\n\n" ) - wheel = _wheel(tmp_path, members) with pytest.raises(CheckFailure): - verify_wheel_inventory(wheel) + verify_wheel_inventory(_wheel(tmp_path, members)) def test_exact_approved_workflow_passes() -> None: @@ -127,16 +199,106 @@ def test_exact_approved_workflow_passes() -> None: @pytest.mark.parametrize( "mutated", [ - EXPECTED_WORKFLOW.replace(" name: source\n", " name: source\n permissions: write-all\n", 1), - EXPECTED_WORKFLOW.replace(" runs-on: ubuntu-26.04\n", " continue-on-error: true\n runs-on: ubuntu-26.04\n", 1), - EXPECTED_WORKFLOW.replace(" branches: [main]\n\npermissions:", ' branches: [main]\n tags: ["*"]\n\npermissions:', 1), + EXPECTED_WORKFLOW.replace( + " name: source\n", + " name: source\n permissions: write-all\n", + 1, + ), + EXPECTED_WORKFLOW.replace( + " runs-on: ubuntu-26.04\n", + " continue-on-error: true\n runs-on: ubuntu-26.04\n", + 1, + ), + EXPECTED_WORKFLOW.replace( + " branches: [main]\n\npermissions:", + ' branches: [main]\n tags: ["*"]\n\npermissions:', + 1, + ), EXPECTED_WORKFLOW.replace("runs-on: ubuntu-26.04", "runs-on: self-hosted"), EXPECTED_WORKFLOW.replace("persist-credentials: false", "persist-credentials: true"), - EXPECTED_WORKFLOW.replace("actions/checkout@", "actions/checkout@0000000000000000000000000000000000000000 # replaced\n # original "), - EXPECTED_WORKFLOW.replace("python tools/check_source.py", "python tools/check_source.py || true"), + EXPECTED_WORKFLOW.replace( + "actions/checkout@", + "actions/checkout@0000000000000000000000000000000000000000 # replaced\n # original ", + ), + EXPECTED_WORKFLOW.replace( + "python tools/check_source.py", "python tools/check_source.py || true" + ), EXPECTED_WORKFLOW + "\n# unexpected extra content\n", ], ) def test_workflow_rejects_any_effective_or_unexpected_change(mutated: str) -> None: with pytest.raises(CheckFailure): verify_workflow_text(mutated) + + +def test_contract_runtime_import_surface_is_declared() -> None: + import ast + import sys + + external: set[str] = set() + for relative in ( + "src/actools/contracts/__init__.py", + "src/actools/contracts/canonical.py", + "src/actools/contracts/configuration.py", + "src/actools/contracts/errors.py", + ): + tree = ast.parse((ROOT / relative).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + top = alias.name.split(".")[0] + if top not in sys.stdlib_module_names: + external.add(top) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + top = node.module.split(".")[0] + if top not in sys.stdlib_module_names: + external.add(top) + assert external == {"yaml", "jsonschema", "rfc8785"} + + +def test_installed_verifier_uses_fresh_hash_locked_environment( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Regression for CP002-F01: never inherit dependency copies from system site.""" + import tools.check_source as source_check + + builder_kwargs: dict[str, object] = {} + commands: list[list[str]] = [] + + class RecordingBuilder: + def __init__(self, **kwargs: object) -> None: + builder_kwargs.update(kwargs) + + def create(self, target: Path) -> None: + scripts = target / ("Scripts" if source_check.os.name == "nt" else "bin") + scripts.mkdir(parents=True) + + def fake_run(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: + commands.append(command) + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr(source_check.venv, "EnvBuilder", RecordingBuilder) + monkeypatch.setattr(source_check, "run", fake_run) + + env_dir, python, launcher, env = _create_isolated_installed_env(tmp_path) + + assert builder_kwargs == {"with_pip": True, "clear": True} + assert "system_site_packages" not in builder_kwargs + assert env_dir == tmp_path / "installed" + assert launcher.parent == python.parent + assert commands == [ + [ + str(python), + "-m", + "pip", + "install", + "--require-hashes", + "-r", + str(LOCK), + ], + [str(python), "-m", "pip", "check"], + ] + assert env["PYTHONNOUSERSITE"] == "1" + assert "PYTHONPATH" not in env + assert "PYTHONHOME" not in env + assert "VIRTUAL_ENV" not in env diff --git a/tools/check_source.py b/tools/check_source.py index a91e695..5611b94 100644 --- a/tools/check_source.py +++ b/tools/check_source.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Canonical CP-001 source checks used locally and by GitHub Actions.""" +"""Canonical CP-002 source checks used locally and by GitHub Actions.""" from __future__ import annotations @@ -26,6 +26,24 @@ "pip-audit": "2.10.1", "pytest": "9.1.1", "setuptools": "84.0.0", + "PyYAML": "6.0.3", + "jsonschema": "4.26.0", + "rfc8785": "0.1.4", +} +DIRECT_INPUTS = { + "build": "1.6.0", + "pip": "26.2.1", + "pip-audit": "2.10.1", + "pytest": "9.1.1", + "setuptools": "84.0.0", + "pyyaml": "6.0.3", + "jsonschema": "4.26.0", + "rfc8785": "0.1.4", +} +EXPECTED_RUNTIME_REQUIREMENTS = { + "pyyaml": "6.0.3", + "jsonschema": "4.26.0", + "rfc8785": "0.1.4", } CHECKOUT_SHA = "3d3c42e5aac5ba805825da76410c181273ba90b1" SETUP_PYTHON_SHA = "5fda3b95a4ea91299a34e894583c3862153e4b97" @@ -34,9 +52,25 @@ SOURCE_WHEEL_ONLY = "--only-binary=:all:" WORKFLOW = ROOT / ".github" / "workflows" / "source-ci.yml" DIST_INFO = "actools_drupal-0.1.0.dev0.dist-info" +CONTRACT_FILES = { + "actools/contracts/__init__.py", + "actools/contracts/canonical.py", + "actools/contracts/configuration.py", + "actools/contracts/errors.py", + "actools/contracts/schemas/common-1.0.0.schema.json", + "actools/contracts/schemas/configuration-1.0.0.schema.json", + "actools/contracts/schemas/configuration-defaults-1.0.0.json", +} EXPECTED_WHEEL_MEMBERS = { "actools/__init__.py", "actools/cli.py", + "actools/contracts/__init__.py", + "actools/contracts/canonical.py", + "actools/contracts/configuration.py", + "actools/contracts/errors.py", + "actools/contracts/schemas/common-1.0.0.schema.json", + "actools/contracts/schemas/configuration-1.0.0.schema.json", + "actools/contracts/schemas/configuration-defaults-1.0.0.json", f"{DIST_INFO}/licenses/LICENSE", f"{DIST_INFO}/licenses/NOTICE.md", f"{DIST_INFO}/METADATA", @@ -99,7 +133,13 @@ def _bounded(text: str, limit: int = 4000) -> str: return "...[truncated]...\n" + text[-limit:] -def run(command: list[str], *, cwd: Path = ROOT, check: bool = True, **kwargs) -> subprocess.CompletedProcess[str]: +def run( + command: list[str], + *, + cwd: Path = ROOT, + check: bool = True, + **kwargs, +) -> subprocess.CompletedProcess[str]: proc = subprocess.run( command, cwd=cwd, @@ -127,10 +167,7 @@ def requirement_records(text: str) -> list[str]: stripped = raw.strip() if not stripped or stripped.startswith("#"): continue - if current: - current += " " + stripped - else: - current = stripped + current = f"{current} {stripped}".strip() if current else stripped if current.endswith("\\"): current = current[:-1].rstrip() continue @@ -143,11 +180,13 @@ def requirement_records(text: str) -> list[str]: def locked_packages() -> dict[str, str]: expected: dict[str, str] = {} - records = requirement_records(LOCK.read_text(encoding="utf-8")) - for record in records: + for record in requirement_records(LOCK.read_text(encoding="utf-8")): if record.startswith("--"): continue - match = re.match(r"^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?==([^\s]+)(?:\s|$)", record) + match = re.match( + r"^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?==([^\s]+)(?:\s|$)", + record, + ) if not match: raise CheckFailure(f"cannot parse locked distribution identity: {record}") name = _canonical_name(match.group(1)) @@ -173,13 +212,15 @@ def classify_scanner_result( if returncode is None: return ScannerResult("ERROR", False, "scanner return code is unknown") if returncode != 0: - return ScannerResult("VULNERABLE" if returncode == 1 else "ERROR", False, f"scanner exit {returncode}") + return ScannerResult( + "VULNERABLE" if returncode == 1 else "ERROR", + False, + f"scanner exit {returncode}", + ) try: parsed = json.loads(payload) except json.JSONDecodeError as exc: return ScannerResult("ERROR", False, f"invalid scanner JSON: {exc}") - - # pip-audit 2.10.1 JsonFormat emits exactly this manifest envelope. if not isinstance(parsed, dict) or set(parsed) != {"dependencies", "fixes"}: return ScannerResult("ERROR", False, "scanner JSON envelope is invalid") dependencies = parsed.get("dependencies") @@ -196,30 +237,54 @@ def classify_scanner_result( if "skip_reason" in dependency: return ScannerResult("ERROR", False, "scanner skipped one or more dependencies") if set(dependency) != {"name", "version", "vulns"}: - return ScannerResult("ERROR", False, "scanner dependency record has missing or unexpected fields") + return ScannerResult( + "ERROR", + False, + "scanner dependency record has missing or unexpected fields", + ) name = dependency.get("name") version = dependency.get("version") vulns = dependency.get("vulns") - if not isinstance(name, str) or not name.strip() or not isinstance(version, str) or not version.strip(): + if ( + not isinstance(name, str) + or not name.strip() + or not isinstance(version, str) + or not version.strip() + ): return ScannerResult("ERROR", False, "scanner dependency identity is invalid") if not isinstance(vulns, list): return ScannerResult("ERROR", False, "scanner vulnerability field is invalid") canonical = _canonical_name(name) if canonical in observed: - return ScannerResult("ERROR", False, f"scanner duplicated dependency: {canonical}") + return ScannerResult( + "ERROR", False, f"scanner duplicated dependency: {canonical}" + ) observed[canonical] = version if vulns: - return ScannerResult("VULNERABLE", False, f"scanner reported vulnerability for {canonical}") + return ScannerResult( + "VULNERABLE", False, f"scanner reported vulnerability for {canonical}" + ) - expected = {_canonical_name(name): version for name, version in expected_packages.items()} + expected = { + _canonical_name(name): version for name, version in expected_packages.items() + } if observed != expected: missing = sorted(set(expected) - set(observed)) unexpected = sorted(set(observed) - set(expected)) - mismatched = sorted(name for name in set(expected) & set(observed) if expected[name] != observed[name]) - detail = f"scanner coverage mismatch: missing={missing}; unexpected={unexpected}; version_mismatch={mismatched}" - return ScannerResult("ERROR", False, detail) - - return ScannerResult("PASS", True, f"complete clean coverage for {len(expected)} locked distributions") + mismatched = sorted( + name + for name in set(expected) & set(observed) + if expected[name] != observed[name] + ) + return ScannerResult( + "ERROR", + False, + "scanner coverage mismatch: " + f"missing={missing}; unexpected={unexpected}; version_mismatch={mismatched}", + ) + return ScannerResult( + "PASS", True, f"complete clean coverage for {len(expected)} locked distributions" + ) def verify_environment() -> None: @@ -254,31 +319,45 @@ def verify_lock() -> None: if not hashes: raise CheckFailure(f"requirement lacks SHA-256 hash: {record}") - direct = { - "build": "1.6.0", - "pip": "26.2.1", - "pip-audit": "2.10.1", - "pytest": "9.1.1", - "setuptools": "84.0.0", - } packages = locked_packages() - for package, version in direct.items(): + for package, version in DIRECT_INPUTS.items(): if packages.get(package) != version: - raise CheckFailure(f"direct source input is absent or changed in lock: {package}=={version}") + raise CheckFailure( + f"direct source/runtime input is absent or changed in lock: {package}=={version}" + ) - source_records = requirement_records((ROOT / "requirements" / "ci.in").read_text(encoding="utf-8")) + source_records = requirement_records( + (ROOT / "requirements" / "ci.in").read_text(encoding="utf-8") + ) source_options = [record for record in source_records if record.startswith("--")] if source_options != [SOURCE_WHEEL_ONLY]: - raise CheckFailure("requirements/ci.in must contain only the activated wheel-only artifact directive") - source_requirements = {record for record in source_records if not record.startswith("--")} - expected_source = {f"{package}=={version}" for package, version in direct.items()} + raise CheckFailure( + "requirements/ci.in must contain only the activated wheel-only artifact directive" + ) + source_requirements = { + record for record in source_records if not record.startswith("--") + } + expected_source = { + f"{package}=={version}" + for package, version in { + "build": "1.6.0", + "pip": "26.2.1", + "pip-audit": "2.10.1", + "pytest": "9.1.1", + "setuptools": "84.0.0", + "PyYAML": "6.0.3", + "jsonschema": "4.26.0", + "rfc8785": "0.1.4", + }.items() + } if source_requirements != expected_source: - raise CheckFailure("requirements/ci.in does not exactly match the activated direct source-tool contract") + raise CheckFailure( + "requirements/ci.in does not exactly match the activated direct source/runtime contract" + ) def verify_workflow_text(text: str) -> None: - normalised = text.replace("\r\n", "\n") - if normalised != EXPECTED_WORKFLOW: + if text.replace("\r\n", "\n") != EXPECTED_WORKFLOW: raise CheckFailure("source CI workflow differs from the exact approved CP-001 structure") @@ -294,6 +373,20 @@ def _safe_wheel_member(name: str) -> None: raise CheckFailure(f"unsafe wheel member path: {name!r}") +def _metadata_runtime_requirements(metadata: object) -> dict[str, str]: + values = metadata.get_all("Requires-Dist") or [] # type: ignore[attr-defined] + observed: dict[str, str] = {} + for value in values: + match = re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9._-]*)==([^ ;]+)", value) + if not match: + raise CheckFailure(f"wheel runtime requirement is not an exact pin: {value}") + name = _canonical_name(match.group(1)) + if name in observed: + raise CheckFailure(f"wheel duplicates runtime requirement: {name}") + observed[name] = match.group(2) + return observed + + def verify_wheel_inventory(wheel: Path) -> str: with zipfile.ZipFile(wheel) as archive: names = archive.namelist() @@ -305,15 +398,17 @@ def verify_wheel_inventory(wheel: Path) -> str: if actual != EXPECTED_WHEEL_MEMBERS: missing = sorted(EXPECTED_WHEEL_MEMBERS - actual) unexpected = sorted(actual - EXPECTED_WHEEL_MEMBERS) - raise CheckFailure(f"wheel inventory mismatch: missing={missing}; unexpected={unexpected}") + raise CheckFailure( + f"wheel inventory mismatch: missing={missing}; unexpected={unexpected}" + ) metadata = BytesParser().parsebytes(archive.read(f"{DIST_INFO}/METADATA")) if metadata.get("Name") != "actools-drupal" or metadata.get("Version") != "0.1.0.dev0": raise CheckFailure("wheel distribution identity/version is incorrect") if metadata.get("Requires-Python") not in {">=3.14,<3.15", "<3.15,>=3.14"}: raise CheckFailure("wheel Requires-Python metadata is incorrect") - if metadata.get_all("Requires-Dist"): - raise CheckFailure("wheel unexpectedly declares runtime dependencies") + if _metadata_runtime_requirements(metadata) != EXPECTED_RUNTIME_REQUIREMENTS: + raise CheckFailure("wheel runtime dependency metadata is not the exact CP-002 contract") if metadata.get("License-Expression") != "MIT": raise CheckFailure("wheel license expression is not MIT") @@ -330,44 +425,102 @@ def verify_wheel_inventory(wheel: Path) -> str: raise CheckFailure("wheel was not generated by the activated setuptools version") for leaf in ("LICENSE", "NOTICE.md"): - packaged = archive.read(f"{DIST_INFO}/licenses/{leaf}") - if packaged != (ROOT / leaf).read_bytes(): + if archive.read(f"{DIST_INFO}/licenses/{leaf}") != (ROOT / leaf).read_bytes(): raise CheckFailure(f"wheel packaged {leaf} differs from repository source") + for member in CONTRACT_FILES: + source = ROOT / "src" / member + if archive.read(member) != source.read_bytes(): + raise CheckFailure(f"wheel packaged contract differs from repository source: {member}") return hashlib.sha256(wheel.read_bytes()).hexdigest() def verify_negative_fixtures() -> None: - deliberate = run([sys.executable, "tests/fixtures/deliberate_failure.py"], check=False) + deliberate = run( + [sys.executable, "tests/fixtures/deliberate_failure.py"], check=False + ) marker = "CP-001 intentional negative-test fixture" if deliberate.returncode != 1 or marker not in deliberate.stderr: raise CheckFailure("deliberate failure fixture did not fail for its intended marker") invalid = run( - [sys.executable, "-m", "build", "--wheel", "--no-isolation", "tests/fixtures/invalid_package"], + [ + sys.executable, + "-m", + "build", + "--wheel", + "--no-isolation", + "tests/fixtures/invalid_package", + ], check=False, ) backend_marker = "cp001_intentionally_missing_backend" combined = invalid.stdout + "\n" + invalid.stderr if invalid.returncode == 0 or backend_marker not in combined: - raise CheckFailure("invalid-package fixture did not fail for the intentionally missing backend") + raise CheckFailure( + "invalid-package fixture did not fail for the intentionally missing backend" + ) def _clean_subprocess_env() -> dict[str, str]: - env = {key: value for key, value in os.environ.items() if key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"}} + env = { + key: value + for key, value in os.environ.items() + if key not in {"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"} + } env["PYTHONNOUSERSITE"] = "1" return env -def verify_installed_cli(wheel: Path, directory: Path, wheel_sha256: str) -> None: +def _create_isolated_installed_env( + directory: Path, +) -> tuple[Path, Path, Path, dict[str, str]]: + """Create a fresh verifier venv and hash-install the exact locked closure.""" env_dir = directory / "installed" + # Keep this verifier independent of both the invoking project venv and any + # interpreter-global site-packages. Runtime dependencies are installed from + # the same generated, hash-locked closure that the canonical source gate uses. venv.EnvBuilder(with_pip=True, clear=True).create(env_dir) scripts = "Scripts" if os.name == "nt" else "bin" python = env_dir / scripts / ("python.exe" if os.name == "nt" else "python") launcher = env_dir / scripts / ("actools.exe" if os.name == "nt" else "actools") env = _clean_subprocess_env() - run([str(python), "-m", "pip", "install", "--no-index", "--no-deps", str(wheel)], cwd=directory, env=env) + run( + [ + str(python), + "-m", + "pip", + "install", + "--require-hashes", + "-r", + str(LOCK), + ], + cwd=directory, + env=env, + ) + run([str(python), "-m", "pip", "check"], cwd=directory, env=env) + return env_dir, python, launcher, env + + +def verify_installed_cli(wheel: Path, directory: Path, wheel_sha256: str) -> None: + env_dir, python, launcher, env = _create_isolated_installed_env(directory) + + # The candidate itself is installed only from the wheel already built and + # inventoried above. Dependencies cannot be resolved or replaced here. + run( + [ + str(python), + "-m", + "pip", + "install", + "--no-index", + "--no-deps", + str(wheel), + ], + cwd=directory, + env=env, + ) if not launcher.is_file(): raise CheckFailure("installed actools console launcher is missing") @@ -375,40 +528,110 @@ def verify_installed_cli(wheel: Path, directory: Path, wheel_sha256: str) -> Non human = run([str(launcher), "version"], cwd=directory, env=env) if human.stdout != expected or human.stderr: raise CheckFailure("installed launcher human version output is not exact/plain") - machine = run([str(launcher), "version", "--format", "json"], cwd=directory, env=env) + machine = run( + [str(launcher), "version", "--format", "json"], cwd=directory, env=env + ) if machine.stdout != '{"program":"actools","version":"0.1.0.dev0"}\n' or machine.stderr: raise CheckFailure("installed launcher JSON version output is not exact") root_help = run([str(launcher), "--help"], cwd=directory, env=env) version_help = run([str(launcher), "version", "--help"], cwd=directory, env=env) - if "version" not in root_help.stdout or "install" in root_help.stdout.lower() or "--format" not in version_help.stdout: + if ( + "version" not in root_help.stdout + or "install" in root_help.stdout.lower() + or "--format" not in version_help.stdout + ): raise CheckFailure("installed launcher help surface is incorrect") - unsupported = run([str(launcher), "install"], cwd=directory, check=False, stdin=subprocess.DEVNULL, env=env) + unsupported = run( + [str(launcher), "install"], + cwd=directory, + check=False, + stdin=subprocess.DEVNULL, + env=env, + ) if unsupported.returncode != 3 or unsupported.stderr != "actools: error: invalid invocation\n": raise CheckFailure("installed launcher unsupported invocation contract is incorrect") - probe_code = ( - "import actools, importlib.metadata, json, pathlib; " - "m=importlib.metadata.metadata('actools-drupal'); " - "print(json.dumps({'module':str(pathlib.Path(actools.__file__).resolve())," - "'version':importlib.metadata.version('actools-drupal')," - "'requires':m.get_all('Requires-Dist') or []}, sort_keys=True))" - ) + probe_code = """ +import actools +import importlib +import importlib.metadata +import json +import pathlib +import sys +from actools.contracts.configuration import load_configuration +from importlib import resources +m = importlib.metadata.metadata('actools-drupal') +runtime = {} +for distribution, module_name in (('PyYAML', 'yaml'), ('jsonschema', 'jsonschema'), ('rfc8785', 'rfc8785')): + dist = importlib.metadata.distribution(distribution) + module = importlib.import_module(module_name) + runtime[distribution] = { + 'version': dist.version, + 'distribution_root': str(pathlib.Path(dist.locate_file('')).resolve()), + 'module': str(pathlib.Path(module.__file__).resolve()), + } +raw = b'{"schema_version":"1.0.0","profile":"single-site-production","installation":{"id":"install-main"},"site":{"id":"site-main","domain":"example.test"},"environment":{"id":"production-main"},"host":{"management_endpoint":"admin.example.test:22","filesystem_authority_id":"fs-auth:main"},"secrets":{"database_credentials":"secret://database/application"},"references":{"policy":"policy://single-site-production/1.0.0","release":"release://actools/0.1.0-dev"}}' +resolved = load_configuration(raw, syntax='json') +schema = resources.files('actools.contracts').joinpath('schemas', 'configuration-1.0.0.schema.json').read_bytes() +print(json.dumps({ + 'prefix': str(pathlib.Path(sys.prefix).resolve()), + 'module': str(pathlib.Path(actools.__file__).resolve()), + 'version': importlib.metadata.version('actools-drupal'), + 'requires': sorted(m.get_all('Requires-Dist') or []), + 'runtime': runtime, + 'dialect': json.loads(schema.decode('utf-8'))['$schema'], + 'cache_mode': resolved.configuration['drupal']['cache']['mode'], + 'origin': resolved.origins['/drupal/cache/mode']['origin'], +}, sort_keys=True)) +""" probe = run([str(python), "-I", "-c", probe_code], cwd=directory, env=env) info = json.loads(probe.stdout) + env_root = env_dir.resolve() + if Path(info["prefix"]).resolve() != env_root: + raise CheckFailure("installed probe did not execute in the fresh virtual environment") module_path = Path(info["module"]).resolve() - if not module_path.is_relative_to(env_dir.resolve()) or module_path.is_relative_to(ROOT.resolve()): + if not module_path.is_relative_to(env_root) or module_path.is_relative_to(ROOT.resolve()): raise CheckFailure("installed import did not originate from the fresh virtual environment") - if info["version"] != "0.1.0.dev0" or info["requires"] != []: - raise CheckFailure("installed distribution identity/version/runtime dependency metadata is incorrect") + if info["version"] != "0.1.0.dev0": + raise CheckFailure("installed distribution version metadata is incorrect") + if info["requires"] != sorted(["PyYAML==6.0.3", "jsonschema==4.26.0", "rfc8785==0.1.4"]): + raise CheckFailure("installed runtime dependency metadata is incorrect") + + runtime_expected = {"PyYAML": "6.0.3", "jsonschema": "4.26.0", "rfc8785": "0.1.4"} + runtime = info.get("runtime") + if not isinstance(runtime, dict) or set(runtime) != set(runtime_expected): + raise CheckFailure("installed runtime dependency probe is incomplete") + for distribution, expected_version in runtime_expected.items(): + record = runtime[distribution] + if not isinstance(record, dict) or record.get("version") != expected_version: + raise CheckFailure(f"installed runtime dependency version is incorrect: {distribution}") + for field in ("distribution_root", "module"): + candidate = Path(record.get(field, "")).resolve() + if not candidate.is_relative_to(env_root) or candidate.is_relative_to(ROOT.resolve()): + raise CheckFailure( + f"installed runtime dependency escaped the fresh verification environment: " + f"{distribution} {field}" + ) + if ( + info["dialect"] != "https://json-schema.org/draft/2020-12/schema" + or info["cache_mode"] != "database" + or info["origin"] != "release_default" + ): + raise CheckFailure("installed CP-002 contract/schema/default probe failed") if hashlib.sha256(wheel.read_bytes()).hexdigest() != wheel_sha256: raise CheckFailure("wheel changed between inventory and installed-launcher checks") + def run_audit() -> None: expected = locked_packages() with tempfile.TemporaryDirectory(prefix="actools-audit-") as tmp: audit_input = Path(tmp) / "requirements.txt" - lines = [line for line in LOCK.read_text(encoding="utf-8").splitlines() if line.strip() != LOCK_WHEEL_ONLY] + lines = [ + line + for line in LOCK.read_text(encoding="utf-8").splitlines() + if line.strip() != LOCK_WHEEL_ONLY + ] audit_input.write_text("\n".join(lines) + "\n", encoding="utf-8") audit = run( [ @@ -435,8 +658,8 @@ def run_audit() -> None: ) if not result.passing: raise CheckFailure( - f"dependency vulnerability check did not pass: {result.status}: {result.detail}\n" - f"{_bounded(audit.stderr)}" + "dependency vulnerability check did not pass: " + f"{result.status}: {result.detail}\n{_bounded(audit.stderr)}" ) @@ -444,21 +667,38 @@ def main() -> int: try: verify_environment() verify_lock() + run([sys.executable, "-m", "pip", "check"]) verify_workflow() run([sys.executable, "-m", "pytest", "-q"]) verify_negative_fixtures() - with tempfile.TemporaryDirectory(prefix="actools-cp001-") as tmp: + with tempfile.TemporaryDirectory(prefix="actools-cp002-") as tmp: work = Path(tmp) out = work / "dist" out.mkdir() - run([sys.executable, "-m", "build", "--wheel", "--no-isolation", "--outdir", str(out), "."]) + run( + [ + sys.executable, + "-m", + "build", + "--wheel", + "--no-isolation", + "--outdir", + str(out), + ".", + ] + ) wheels = list(out.glob("*.whl")) if len(wheels) != 1: raise CheckFailure(f"expected one wheel, found {len(wheels)}") wheel_sha256 = verify_wheel_inventory(wheels[0]) verify_installed_cli(wheels[0], work, wheel_sha256) run_audit() - except (CheckFailure, OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc: + except ( + CheckFailure, + OSError, + subprocess.SubprocessError, + json.JSONDecodeError, + ) as exc: print(f"SOURCE-CI: FAIL: {exc}", file=sys.stderr) return 1 print(f"SOURCE-CI: WHEEL-SHA256 {wheel_sha256}") From 5f3bfbec057c104dd7875608a5c9f7d4449bcb3c Mon Sep 17 00:00:00 2001 From: actools-pl Date: Sat, 12 Sep 2026 07:56:27 +0000 Subject: [PATCH 2/3] CP-002: address CPD-12 IR1 findings --- CHANGELOG.md | 5 +- ...iguration-contract-and-canonicalization.md | 4 +- docs/development/source-ci.md | 8 +- docs/reference/configuration.md | 4 +- requirements/ci.lock | 2 +- src/actools/contracts/configuration.py | 28 +- .../schemas/common-1.0.0.schema.json | 6 +- tests/test_configuration_contract.py | 241 ++++++++++++++++++ tests/test_package_contents.py | 35 +++ tools/check_source.py | 35 +++ 10 files changed, 347 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5927f7c..d754fa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,13 @@ ### Changed - The Python distribution now declares exactly three runtime dependencies: `PyYAML==6.0.3`, `jsonschema==4.26.0`, and `rfc8785==0.1.4`. -- Source/package checks now expect the `actools.contracts` subpackage and its three packaged JSON contract resources, and installed-wheel verification uses a fresh isolated venv whose dependency closure is hash-installed from the actual generated `requirements/ci.lock` before the candidate wheel is installed with dependency resolution disabled; the exact CP-001 CLI and unchanged Source CI workflow authority are preserved. +- Source/package checks now expect the `actools.contracts` subpackage and its three packaged JSON contract resources, and installed-wheel verification uses a fresh isolated venv whose dependency closure is hash-installed from the actual generated `requirements/ci.lock` before the candidate wheel is installed with dependency resolution disabled; lock verification also fails closed unless the generated header records the exact accepted normalized compile command without `--no-index` and the frozen dependency/hash body is unchanged. The exact CP-001 CLI and unchanged Source CI workflow authority are preserved. +- CP-002 strict validation now rejects scoped/control-bearing endpoints, requires true end-of-input for owned identifiers/references, keeps endpoint port parsing total under extreme input, closes the reviewed YAML implicit-type lexical gaps, and enforces direct-mapping depth limits before recursive copying. ### Known limits - This remains a development package, not a working Drupal installer or production-qualified release. - CP-002 introduces no CLI configuration command, wizard, filesystem publication, host discovery, journal, plan/apply operation, privileged execution, secret loading/generation or network/server effect. -- The CP-002 `requirements/ci.lock` must be resolver-generated and verified by the Human-Git operator under the accepted Ubuntu Server 26.04.1 / CPython 3.14.7 / pip 26.2.1 / pip-tools 7.6.1 contract. A coding environment that cannot reproduce that environment must not fabricate the lock. +- The historical r2 coding delivery omitted `requirements/ci.lock` by design. Every CP-002 review candidate must instead contain a resolver-generated lock and retained Human-Git receipts from the accepted Ubuntu Server 26.04.1 / CPython 3.14.7 / pip 26.2.1 / pip-tools 7.6.1 contract. A correction coding environment that cannot reproduce that environment must leave lock regeneration to Human-Git rather than fabricate or hand-edit it. - JCS vector SHA-256 values in provenance require independent Human-Git receipt recomputation before the final candidate review. - No CP-002 result by itself qualifies servers, Drupal behavior, restore, signing, release support or any product-wide gate. diff --git a/docs/adr/0002-configuration-contract-and-canonicalization.md b/docs/adr/0002-configuration-contract-and-canonicalization.md index 3abb49a..ab4cf8e 100644 --- a/docs/adr/0002-configuration-contract-and-canonicalization.md +++ b/docs/adr/0002-configuration-contract-and-canonicalization.md @@ -1,6 +1,6 @@ # ADR-0002 — Strict configuration contract and RFC 8785 canonicalization -Status: **candidate for independent CP-002 review; not accepted, merged or published by this coding delivery.** +Status: **implemented in a local CP-002 candidate; CPD-12 correction/review, acceptance, merge and publication remain pending.** ## Context @@ -62,6 +62,6 @@ The distribution still has version `0.1.0.dev0` and the CP-001 CLI remains untou Configuration validity is not installation/production readiness. No host, Drupal, backup, restore, release-signing or product-wide gate is established by this ADR. -The final CP-002 review subject must contain a real resolver-generated `requirements/ci.lock` produced under the accepted Ubuntu Server 26.04.1 x86_64 / CPython 3.14.7 / pip 26.2.1 / pip-tools 7.6.1 contract. This coder environment did not provide that accepted generation environment, so the lock remains an operator-generated final-candidate item rather than a hand-written artifact. +A CP-002 review candidate is complete only when `requirements/ci.lock` has been resolver-generated under the accepted Ubuntu Server 26.04.1 x86_64 / CPython 3.14.7 / pip 26.2.1 / pip-tools 7.6.1 contract and the corresponding local receipts are retained. The historical r2 coding delivery intentionally omitted the lock; Human-Git subsequently generated it for the reviewed local candidate. Any correction that changes the generated header must regenerate the lock in that exact environment rather than hand-editing it, while preserving the frozen dependency/hash body. Hosted Source CI remains unexecuted until publication is separately authorized. The vector receipt values are present for deterministic source testing, but the packet-required Human-Git independent digest recomputation is still an evidence obligation before final review. diff --git a/docs/development/source-ci.md b/docs/development/source-ci.md index 16339ef..de779b6 100644 --- a/docs/development/source-ci.md +++ b/docs/development/source-ci.md @@ -30,22 +30,22 @@ setuptools==84.0.0 The final `requirements/ci.lock` is a resolver result, never a hand-written prediction. Generate it only with CPython 3.14.7, `pip==26.2.1` and `pip-tools==7.6.1` on the accepted Ubuntu Server 26.04.1 x86_64 reference, with pip configuration disabled, explicit PyPI index input, `--allow-unsafe`, `--generate-hashes`, backtracking resolution, no hash reuse and the wheel-only input policy. Record input and generated-lock SHA-256 values from actual bytes. -The accepted generation command shape is: +The accepted normalized generation command is: ```text pip-compile --no-config --index-url=https://pypi.org/simple --no-emit-index-url --allow-unsafe --generate-hashes --resolver=backtracking --no-reuse-hashes --no-annotate --no-strip-extras --output-file=requirements/ci.lock requirements/ci.in ``` -Run it only from the disposable accepted generation environment after confirming `python --version`, `python -m pip --version`, and `pip-compile --version`; do not substitute a different platform or tool version merely to obtain a lock. +Set `CUSTOM_COMPILE_COMMAND` to that exact one-line command before invoking the same pinned pip-compile option set. This makes pip-tools' generated header record the truthful normalized command rather than reconstructing an incomplete or false command line. The canonical checker rejects any generated header that does not contain that exact command or that claims `--no-index`. -This coding delivery intentionally leaves `requirements/ci.lock` operator-generated/pending because its sandbox is not that accepted environment. A stale CP-001 lock is not a CP-002 final candidate. +Run generation only from the disposable accepted environment after confirming `python --version`, `python -m pip --version`, and `pip-compile --version`; do not substitute a different platform or tool version merely to obtain a lock. The historical r2 coding delivery intentionally omitted the lock because its sandbox was not the accepted environment. A review candidate must contain the exact-environment generated lock and its local generation/install/test receipts; a source tree that already contains such a lock must not be described as if lock generation were still pending. Hosted Source CI remains unexecuted until publication is separately authorized. ## Canonical checker assertions `tools/check_source.py` retains the CP-001 fail-closed workflow/scanner/CLI policy and evolves package truth for CP-002: 1. exact CPython 3.14.7 and all eight direct installed versions; -2. a hash-locked resolver closure whose direct pins exactly match `requirements/ci.in`, plus a successful `python -m pip check`; +2. a hash-locked resolver closure whose direct pins exactly match `requirements/ci.in`, whose generated header records the exact normalized `CUSTOM_COMPILE_COMMAND` without `--no-index`, whose frozen dependency/hash body remains unchanged, plus a successful `python -m pip check`; 3. byte-for-byte unchanged Source CI workflow shape, including only PR/push to `main`, `contents: read`, immutable action pins, `persist-credentials: false`, Ubuntu 26.04 and Python 3.14.7; 4. the complete test suite, including strict parser/default/origin/JCS adversarial tests and all unchanged CP-001 CLI tests; 5. the two CP-001 controlled negative build fixtures, still reason-bound; diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 533680b..3ea17fd 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -24,7 +24,7 @@ YAML is a convenience syntax for the same JSON-compatible model, not a general Y Accepted plain non-string forms are exactly lowercase `null`, `true`, `false`, and JSON-number lexical forms. Quoted scalars stay strings. Ordinary safe unquoted text stays string. -Rejected forms include anchors/aliases, tags, merge keys, directives, multiple documents, duplicate/non-string keys, `~`, implicit empty nulls, `yes/no/on/off` variants, date/timestamp/time implicit forms, hexadecimal/octal/binary/sexagesimal/underscore numbers, `.nan`/`.inf`, leading-zero numeric forms, leading-plus/trailing-dot/dot-leading numeric extensions, and block scalar styles. +Rejected forms include anchors/aliases, tags, merge keys, directives, multiple documents, duplicate/non-string keys, `~`, implicit empty nulls, `yes/no/on/off` variants, date/timestamp/time implicit forms (including one-digit month/day timestamp spellings recognized by the YAML resolver family), hexadecimal/octal/binary/sexagesimal/underscore numbers, `.nan`/`.inf`, leading-zero numeric forms, leading-plus/trailing-dot/dot-leading numeric extensions, trailing-dot mantissas combined with signed exponents such as `1.e+2`, and block scalar styles. Quoted equivalents remain ordinary strings. ## Resource limits @@ -45,7 +45,7 @@ Aggregate-node accounting counts every container/scalar value and every mapping- Draft 2020-12 format assertions are active. The finite checker set contains only: - `actools-domain`: lowercase DNS-style multi-label domain; -- `actools-endpoint`: DNS/IPv4 `host:port` or bracketed IPv6 `[address]:port`; +- `actools-endpoint`: DNS/IPv4 `host:port` or bracketed **unscoped** IPv6 `[address]:port`; whitespace/control-bearing endpoint text and IPv6 `%zone` identifiers are not supported in contract 1.0.0; ports are ASCII decimal `1..65535` with 1–5 digits and no leading zero; - `actools-filesystem-authority-id`: `fs-auth:`; - `actools-secret-reference`: `secret:///`. diff --git a/requirements/ci.lock b/requirements/ci.lock index 959f0c2..43b203d 100644 --- a/requirements/ci.lock +++ b/requirements/ci.lock @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # -# pip-compile --allow-unsafe --generate-hashes --index-url=https://pypi.org/simple --no-annotate --no-emit-index-url --no-index --no-strip-extras --output-file=requirements/ci.lock requirements/ci.in +# pip-compile --no-config --index-url=https://pypi.org/simple --no-emit-index-url --allow-unsafe --generate-hashes --resolver=backtracking --no-reuse-hashes --no-annotate --no-strip-extras --output-file=requirements/ci.lock requirements/ci.in # --only-binary :all: diff --git a/src/actools/contracts/configuration.py b/src/actools/contracts/configuration.py index d440012..fb039d9 100644 --- a/src/actools/contracts/configuration.py +++ b/src/actools/contracts/configuration.py @@ -19,10 +19,10 @@ _DOMAIN_LABEL = re.compile('[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\Z') _FS_AUTHORITY = re.compile('fs-auth:[a-z][a-z0-9-]{1,62}\\Z') _SECRET_REFERENCE = re.compile('secret://[a-z][a-z0-9-]{0,62}/[a-z][a-z0-9-]{0,62}\\Z') -_DATE_LIKE = re.compile('\\d{4}-\\d{2}-\\d{2}(?:[Tt ].*)?\\Z') +_DATE_LIKE = re.compile('(?:\\d{4}-\\d{2}-\\d{2}(?:[Tt ].*)?|\\d{4}-\\d{1,2}-\\d{1,2}[Tt ].*)\\Z') _TIME_LIKE = re.compile('\\d{1,2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:?\\d{2})?\\Z') _SEXAGESIMAL = re.compile('[-+]?\\d+(?::[0-5]?\\d)+(?:\\.\\d+)?\\Z') -_NUMERIC_EXTENSION = re.compile('(?:[-+]?0[xX][0-9a-fA-F_]+|[-+]?0[oO][0-7_]+|[-+]?0[bB][01_]+|[-+]?0[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|[-+]?\\.[0-9]+(?:[eE][+-]?[0-9]+)?|[-+]?[0-9]+\\.|\\+[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\\Z') +_NUMERIC_EXTENSION = re.compile('(?:[-+]?0[xX][0-9a-fA-F_]+|[-+]?0[oO][0-7_]+|[-+]?0[bB][01_]+|[-+]?0[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|[-+]?\\.[0-9]+(?:[eE][+-]?[0-9]+)?|[-+]?[0-9]+\\.(?:[eE][+-]?[0-9]+)?|\\+[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\\Z') _AMBIGUOUS_WORDS = {'yes', 'no', 'on', 'off', 'y', 'n'} _FORMAT_CHECKER = FormatChecker() _FORMAT_CHECKER.checkers.clear() @@ -47,7 +47,9 @@ def _is_domain(value: object) -> bool: return len(labels) >= 2 and all((_DOMAIN_LABEL.fullmatch(label) for label in labels)) def _is_endpoint(value: object) -> bool: - if not isinstance(value, str) or not value: + if not isinstance(value, str) or not value or len(value) > 320: + return False + if any(ch.isspace() or ord(ch) < 0x20 or ord(ch) == 0x7f for ch in value): return False if value.startswith('['): close = value.find(']') @@ -55,6 +57,8 @@ def _is_endpoint(value: object) -> bool: return False host = value[1:close] port_text = value[close + 2:] + if '%' in host: + return False try: if ipaddress.ip_address(host).version != 6: return False @@ -69,9 +73,18 @@ def _is_endpoint(value: object) -> bool: except ValueError: if not _is_domain(host): return False - if not port_text.isascii() or not port_text.isdigit() or (len(port_text) > 1 and port_text.startswith('0')): + if ( + not 1 <= len(port_text) <= 5 + or not port_text.isascii() + or not port_text.isdigit() + or (len(port_text) > 1 and port_text.startswith('0')) + ): + return False + try: + port = int(port_text) + except (ValueError, OverflowError): return False - return 1 <= int(port_text) <= 65535 + return 1 <= port <= 65535 def _is_fs_authority(value: object) -> bool: return isinstance(value, str) and _FS_AUTHORITY.fullmatch(value) is not None @@ -693,8 +706,9 @@ def canonical_resolution_bytes(self) -> bytes: return canonical_json_bytes(self.resolution_envelope()) def resolve_configuration(value: Mapping[str, Any]) -> ResolvedConfiguration: - operator = copy.deepcopy(dict(value)) - _validate_data_limits(operator) + operator_input = dict(value) + _validate_data_limits(operator_input) + operator = copy.deepcopy(operator_input) _prevalidate_identity(operator) resolved = copy.deepcopy(operator) applied = _apply_defaults(resolved) diff --git a/src/actools/contracts/schemas/common-1.0.0.schema.json b/src/actools/contracts/schemas/common-1.0.0.schema.json index 26d0ea7..bdea19e 100644 --- a/src/actools/contracts/schemas/common-1.0.0.schema.json +++ b/src/actools/contracts/schemas/common-1.0.0.schema.json @@ -2,12 +2,12 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://schemas.actools.invalid/common-1.0.0.schema.json", "$defs": { - "identifier": {"type":"string","minLength":2,"maxLength":63,"pattern":"^[a-z][a-z0-9-]{1,62}$"}, + "identifier": {"type":"string","minLength":2,"maxLength":63,"pattern":"^[a-z][a-z0-9-]{1,62}(?![\\s\\S])"}, "domain": {"type":"string","maxLength":253,"format":"actools-domain"}, "endpoint": {"type":"string","maxLength":320,"format":"actools-endpoint"}, "filesystemAuthorityId": {"type":"string","maxLength":71,"format":"actools-filesystem-authority-id"}, "secretReference": {"type":"string","maxLength":160,"format":"actools-secret-reference"}, - "policyReference": {"type":"string","maxLength":160,"pattern":"^policy://[a-z][a-z0-9-]{0,62}/[A-Za-z0-9._-]{1,64}$"}, - "releaseReference": {"type":"string","maxLength":160,"pattern":"^release://[a-z][a-z0-9-]{0,62}/[A-Za-z0-9._-]{1,64}$"} + "policyReference": {"type":"string","maxLength":160,"pattern":"^policy://[a-z][a-z0-9-]{0,62}/[A-Za-z0-9._-]{1,64}(?![\\s\\S])"}, + "releaseReference": {"type":"string","maxLength":160,"pattern":"^release://[a-z][a-z0-9-]{0,62}/[A-Za-z0-9._-]{1,64}(?![\\s\\S])"} } } diff --git a/tests/test_configuration_contract.py b/tests/test_configuration_contract.py index 470af37..ab6e76b 100644 --- a/tests/test_configuration_contract.py +++ b/tests/test_configuration_contract.py @@ -423,3 +423,244 @@ def test_extreme_numeric_lexemes_fail_with_bounded_actools_reasons(): with pytest.raises(ConfigurationError) as exc: parse_yaml_bytes(b"value: " + huge_exponent + b"\n") assert exc.value.reason == "nonfinite_number" + + +def _encoded_document(value: dict, syntax: str) -> bytes: + # JSON flow syntax is also valid YAML; using it here keeps all mutated + # control-bearing strings explicitly quoted for the YAML-path assertions. + return json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + + +@pytest.mark.parametrize("syntax", ["json", "yaml"]) +@pytest.mark.parametrize( + "endpoint", + [ + "[fe80::1%eth0]:22", + "[fe80::1%bad\nzone]:22", + "[fe80::1%bad\rzone]:22", + "[fe80::1%bad\tzone]:22", + "[fe80::1%bad\x1bzone]:22", + "admin.example.test :22", + "admin.example.test:\t22", + ], +) +def test_ir01_scoped_or_control_bearing_endpoint_is_rejected_end_to_end( + syntax: str, endpoint: str +) -> None: + value = minimal() + value["host"]["management_endpoint"] = endpoint + with pytest.raises(ConfigurationError) as exc: + load_configuration(_encoded_document(value, syntax), syntax=syntax) + assert (exc.value.path, exc.value.reason) == ( + "/host/management_endpoint", + "invalid_format", + ) + assert endpoint not in str(exc.value) + assert len(str(exc.value)) < 160 + + +@pytest.mark.parametrize("syntax", ["json", "yaml"]) +@pytest.mark.parametrize( + "endpoint", + [ + "admin.example.test:22", + "192.0.2.10:1", + "[2001:db8::1]:65535", + ], +) +def test_ir01_unscoped_endpoint_forms_remain_valid(syntax: str, endpoint: str) -> None: + value = minimal() + value["host"]["management_endpoint"] = endpoint + resolved = load_configuration(_encoded_document(value, syntax), syntax=syntax) + assert resolved.configuration["host"]["management_endpoint"] == endpoint + + +_OWNED_SCALAR_CASES = [ + (("installation", "id"), "install-main"), + (("site", "id"), "site-main"), + (("environment", "id"), "production-main"), + (("references", "policy"), "policy://single-site-production/1.0.0"), + (("references", "release"), "release://actools/0.1.0-dev"), +] + + +def _set_path(value: dict, path: tuple[str, ...], replacement: str) -> None: + current = value + for part in path[:-1]: + current = current[part] + current[path[-1]] = replacement + + +@pytest.mark.parametrize("syntax", ["json", "yaml"]) +@pytest.mark.parametrize("path,valid", _OWNED_SCALAR_CASES) +@pytest.mark.parametrize("suffix", ["\n", "\r", "\t", "\nextra"]) +def test_ir02_owned_ids_and_references_require_true_end_of_string( + syntax: str, path: tuple[str, ...], valid: str, suffix: str +) -> None: + value = minimal() + rejected = valid + suffix + _set_path(value, path, rejected) + with pytest.raises(ConfigurationError) as exc: + load_configuration(_encoded_document(value, syntax), syntax=syntax) + expected_path = "/" + "/".join(path) + assert (exc.value.path, exc.value.reason) == (expected_path, "invalid_value_syntax") + assert rejected not in str(exc.value) + + + + +def test_ir02_schema_patterns_use_ecmascript_compatible_true_end_construction() -> None: + common = json.loads( + (ROOT / "src/actools/contracts/schemas/common-1.0.0.schema.json").read_text( + encoding="utf-8" + ) + ) + for name in ("identifier", "policyReference", "releaseReference"): + pattern = common["$defs"][name]["pattern"] + assert "\\Z" not in pattern + assert pattern.endswith(r"(?![\s\S])") + +@pytest.mark.parametrize("syntax", ["json", "yaml"]) +def test_ir02_normal_owned_ids_and_references_still_validate(syntax: str) -> None: + resolved = load_configuration(_encoded_document(minimal(), syntax), syntax=syntax) + assert resolved.configuration["installation"]["id"] == "install-main" + assert resolved.configuration["references"]["policy"].endswith("/1.0.0") + + +@pytest.mark.parametrize( + "port_text,valid", + [ + ("0", False), + ("1", True), + ("65535", True), + ("65536", False), + ("100000", False), + ("01", False), + ("١", False), + ("9" * 4301, False), + ("9" * 10000, False), + ], +) +def test_ir03_endpoint_predicate_is_total_for_extreme_ports( + port_text: str, valid: bool +) -> None: + from actools.contracts.configuration import _is_endpoint + + assert _is_endpoint("127.0.0.1:" + port_text) is valid + + +@pytest.mark.parametrize("syntax", ["json", "yaml"]) +@pytest.mark.parametrize( + "port_text", + ["0", "65536", "100000", "01", "١", "9" * 4301, "9" * 10000], +) +def test_ir03_invalid_ports_fail_with_owned_bounded_configuration_error( + syntax: str, port_text: str +) -> None: + value = minimal() + endpoint = "127.0.0.1:" + port_text + value["host"]["management_endpoint"] = endpoint + with pytest.raises(ConfigurationError) as exc: + load_configuration(_encoded_document(value, syntax), syntax=syntax) + assert exc.value.path == "/host/management_endpoint" + assert exc.value.reason in {"invalid_format", "value_out_of_range"} + assert endpoint not in str(exc.value) + assert len(str(exc.value)) < 160 + + +@pytest.mark.parametrize("syntax", ["json", "yaml"]) +@pytest.mark.parametrize("port_text", ["1", "65535"]) +def test_ir03_port_boundaries_remain_valid(syntax: str, port_text: str) -> None: + value = minimal() + value["host"]["management_endpoint"] = "127.0.0.1:" + port_text + resolved = load_configuration(_encoded_document(value, syntax), syntax=syntax) + assert resolved.configuration["host"]["management_endpoint"].endswith( + ":" + port_text + ) + + +def _pyyaml_plain_resolver_tags(text: str) -> set[str]: + # Test oracle only. Production scalar meaning stays in _plain_yaml_scalar(). + from yaml.resolver import Resolver + + candidates = list(Resolver.yaml_implicit_resolvers.get(text[0], [])) + candidates.extend(Resolver.yaml_implicit_resolvers.get(None, [])) + return {tag for tag, pattern in candidates if pattern.match(text)} + + +@pytest.mark.parametrize( + "scalar,reason,oracle_tag", + [ + ("1.e+2", "yaml_numeric_extension", "tag:yaml.org,2002:float"), + ("1.e-2", "yaml_numeric_extension", "tag:yaml.org,2002:float"), + ("+1.e+2", "yaml_numeric_extension", "tag:yaml.org,2002:float"), + ("-1.e+2", "yaml_numeric_extension", "tag:yaml.org,2002:float"), + ("+1.e-2", "yaml_numeric_extension", "tag:yaml.org,2002:float"), + ("-1.e-2", "yaml_numeric_extension", "tag:yaml.org,2002:float"), + ("2026-9-11T12:00:00Z", "yaml_ambiguous_scalar", "tag:yaml.org,2002:timestamp"), + ("2026-09-1T12:00:00Z", "yaml_ambiguous_scalar", "tag:yaml.org,2002:timestamp"), + ("2026-9-1T12:00:00Z", "yaml_ambiguous_scalar", "tag:yaml.org,2002:timestamp"), + ], +) +def test_ir04_pyyaml_ambiguity_oracle_is_rejected_but_quoted_form_is_string( + scalar: str, reason: str, oracle_tag: str +) -> None: + assert oracle_tag in _pyyaml_plain_resolver_tags(scalar) + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(f"value: {scalar}\n".encode("utf-8")) + assert exc.value.reason == reason + quoted = json.dumps(scalar).encode("utf-8") + assert parse_yaml_bytes(b"value: " + quoted + b"\n") == {"value": scalar} + + +def _nested_list(levels: int) -> object: + value: object = 0 + for _ in range(levels): + value = [value] + return value + + +def test_ir05_direct_mapping_depth_is_checked_before_recursive_copy() -> None: + boundary = minimal() + boundary["unexpected"] = _nested_list(31) + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(boundary) + assert exc.value.reason == "unknown_property" + assert "platform" not in boundary + + over_limit = minimal() + deep_value = _nested_list(32) + over_limit["unexpected"] = deep_value + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(over_limit) + assert exc.value.reason == "container_depth_limit" + assert over_limit["unexpected"] is deep_value + assert "platform" not in over_limit + + far_over_limit = minimal() + far_deep_value = _nested_list(1500) + far_over_limit["unexpected"] = far_deep_value + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(far_over_limit) + assert exc.value.reason == "container_depth_limit" + assert far_over_limit["unexpected"] is far_deep_value + assert "platform" not in far_over_limit + + +def test_ir05_direct_mapping_cycle_fails_owned_without_mutation() -> None: + cyclic: list[object] = [] + cyclic.append(cyclic) + value = minimal() + value["unexpected"] = cyclic + with pytest.raises(ConfigurationError) as exc: + resolve_configuration(value) + assert exc.value.reason == "container_depth_limit" + assert value["unexpected"] is cyclic + assert cyclic[0] is cyclic + assert "platform" not in value + + +def test_ir04_non_resolved_one_digit_plain_date_remains_ordinary_text() -> None: + scalar = "2026-9-11" + assert _pyyaml_plain_resolver_tags(scalar) == set() + assert parse_yaml_bytes(f"value: {scalar}\n".encode("utf-8")) == {"value": scalar} diff --git a/tests/test_package_contents.py b/tests/test_package_contents.py index 47c4cfe..685c26f 100644 --- a/tests/test_package_contents.py +++ b/tests/test_package_contents.py @@ -11,11 +11,15 @@ LOCK, CheckFailure, DIST_INFO, + EXPECTED_LOCK_BODY_SHA256, + EXPECTED_LOCK_COMPILE_COMMAND, EXPECTED_RUNTIME_REQUIREMENTS, EXPECTED_WHEEL_MEMBERS, EXPECTED_WORKFLOW, _create_isolated_installed_env, + lock_body_sha256, requirement_records, + verify_lock_header, verify_wheel_inventory, verify_workflow_text, ) @@ -66,6 +70,37 @@ def test_lock_records_are_pinned_and_hashed() -> None: assert all("==" in record and "--hash=sha256:" in record for record in packages) +def test_lock_header_policy_accepts_only_truthful_normalized_command() -> None: + exact = ( + "#\n# This file is autogenerated by pip-compile with Python 3.14\n" + "# by the following command:\n#\n" + f"# {EXPECTED_LOCK_COMPILE_COMMAND}\n#\n" + "--only-binary :all:\n" + ) + verify_lock_header(exact) + + false = exact.replace( + f"# {EXPECTED_LOCK_COMPILE_COMMAND}", + "# pip-compile --allow-unsafe --generate-hashes --no-index " + "--output-file=requirements/ci.lock requirements/ci.in", + ) + with pytest.raises(CheckFailure): + verify_lock_header(false) + + +def test_current_lock_header_and_body_match_frozen_contract() -> None: + text = (ROOT / "requirements" / "ci.lock").read_text(encoding="utf-8") + verify_lock_header(text) + assert lock_body_sha256(text) == EXPECTED_LOCK_BODY_SHA256 + + +def test_current_lock_dependency_hash_body_is_still_frozen_before_regeneration() -> None: + text = (ROOT / "requirements" / "ci.lock").read_text(encoding="utf-8") + assert lock_body_sha256(text) == ( + "ceabc2eb1fa81c35470bec0ab8a107a0c7c06291b6c47595d4402c01013d1a18" + ) + + def _valid_members() -> dict[str, bytes | str]: schema_dir = ROOT / "src/actools/contracts/schemas" metadata = ( diff --git a/tools/check_source.py b/tools/check_source.py index 5611b94..04b35d4 100644 --- a/tools/check_source.py +++ b/tools/check_source.py @@ -50,6 +50,15 @@ LOCK = ROOT / "requirements" / "ci.lock" LOCK_WHEEL_ONLY = "--only-binary :all:" SOURCE_WHEEL_ONLY = "--only-binary=:all:" +EXPECTED_LOCK_COMPILE_COMMAND = ( + "pip-compile --no-config --index-url=https://pypi.org/simple " + "--no-emit-index-url --allow-unsafe --generate-hashes " + "--resolver=backtracking --no-reuse-hashes --no-annotate " + "--no-strip-extras --output-file=requirements/ci.lock requirements/ci.in" +) +EXPECTED_LOCK_BODY_SHA256 = ( + "ceabc2eb1fa81c35470bec0ab8a107a0c7c06291b6c47595d4402c01013d1a18" +) WORKFLOW = ROOT / ".github" / "workflows" / "source-ci.yml" DIST_INFO = "actools_drupal-0.1.0.dev0.dist-info" CONTRACT_FILES = { @@ -303,8 +312,34 @@ def verify_environment() -> None: raise CheckFailure("tool version mismatch: " + "; ".join(mismatches)) + +def verify_lock_header(text: str) -> None: + marker_index = text.find(LOCK_WHEEL_ONLY) + if marker_index < 0: + raise CheckFailure("CI lock is missing the canonical wheel-only directive") + header = text[:marker_index] + command_lines = [ + line for line in header.splitlines() if line.startswith("# pip-compile") + ] + expected = f"# {EXPECTED_LOCK_COMPILE_COMMAND}" + if command_lines != [expected]: + raise CheckFailure("CI lock generator header does not record the exact accepted command") + if re.search(r"(? str: + marker_index = text.find(LOCK_WHEEL_ONLY) + if marker_index < 0: + raise CheckFailure("CI lock is missing the canonical wheel-only directive") + return hashlib.sha256(text[marker_index:].encode("utf-8")).hexdigest() + + def verify_lock() -> None: text = LOCK.read_text(encoding="utf-8") + verify_lock_header(text) + if lock_body_sha256(text) != EXPECTED_LOCK_BODY_SHA256: + raise CheckFailure("CI lock dependency/hash body differs from the frozen CP-002 contract") records = requirement_records(text) options = [record for record in records if record.startswith("--")] if options != [LOCK_WHEEL_ONLY]: From f0d2d59e6edb0b7a1ccac8254bd0099788bb2ad1 Mon Sep 17 00:00:00 2001 From: actools-pl Date: Sat, 12 Sep 2026 19:32:53 +0000 Subject: [PATCH 3/3] CP-002: close IR1-01 YAML regression (DLV2) --- src/actools/contracts/configuration.py | 2 +- tests/test_configuration_contract.py | 55 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/actools/contracts/configuration.py b/src/actools/contracts/configuration.py index fb039d9..f4a89dc 100644 --- a/src/actools/contracts/configuration.py +++ b/src/actools/contracts/configuration.py @@ -19,7 +19,7 @@ _DOMAIN_LABEL = re.compile('[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\Z') _FS_AUTHORITY = re.compile('fs-auth:[a-z][a-z0-9-]{1,62}\\Z') _SECRET_REFERENCE = re.compile('secret://[a-z][a-z0-9-]{0,62}/[a-z][a-z0-9-]{0,62}\\Z') -_DATE_LIKE = re.compile('(?:\\d{4}-\\d{2}-\\d{2}(?:[Tt ].*)?|\\d{4}-\\d{1,2}-\\d{1,2}[Tt ].*)\\Z') +_DATE_LIKE = re.compile('(?:\\d{4}-\\d{2}-\\d{2}(?:[Tt ].*)?|[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(?:[Tt]| +)[0-9]{1,2}:[0-9]{2}:[0-9]{2}(?:\\.[0-9]*)?(?: *(?:Z|[-+][0-9]{1,2}(?::[0-9]{2})?))?)\\Z') _TIME_LIKE = re.compile('\\d{1,2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:?\\d{2})?\\Z') _SEXAGESIMAL = re.compile('[-+]?\\d+(?::[0-5]?\\d)+(?:\\.\\d+)?\\Z') _NUMERIC_EXTENSION = re.compile('(?:[-+]?0[xX][0-9a-fA-F_]+|[-+]?0[oO][0-7_]+|[-+]?0[bB][01_]+|[-+]?0[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|[-+]?\\.[0-9]+(?:[eE][+-]?[0-9]+)?|[-+]?[0-9]+\\.(?:[eE][+-]?[0-9]+)?|\\+[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\\Z') diff --git a/tests/test_configuration_contract.py b/tests/test_configuration_contract.py index ab6e76b..4718079 100644 --- a/tests/test_configuration_contract.py +++ b/tests/test_configuration_contract.py @@ -664,3 +664,58 @@ def test_ir04_non_resolved_one_digit_plain_date_remains_ordinary_text() -> None: scalar = "2026-9-11" assert _pyyaml_plain_resolver_tags(scalar) == set() assert parse_yaml_bytes(f"value: {scalar}\n".encode("utf-8")) == {"value": scalar} + +@pytest.mark.parametrize( + "scalar", + [ + "2026-9-11TBD", + "2026-9-11 Tomorrow", + "2026-9-11T12:00:00Z-notes", + "2026-9-11", + "1.0.0", + ], +) +def test_ir1_01_safe_date_prefixed_plain_text_remains_exact_string(scalar: str) -> None: + assert _pyyaml_plain_resolver_tags(scalar) == set() + assert parse_yaml_bytes(f"value: {scalar}\n".encode("utf-8")) == {"value": scalar} + quoted = json.dumps(scalar).encode("utf-8") + assert parse_yaml_bytes(b"value: " + quoted + b"\n") == {"value": scalar} + + +@pytest.mark.parametrize( + "scalar", + [ + "2026-9-11T12:00:00Z", + "2026-09-1T12:00:00Z", + "2026-9-1T12:00:00Z", + "2026-9-11t12:00:00Z", + "2026-9-11 12:00:00Z", + ], +) +def test_ir1_01_true_timestamp_ambiguity_remains_rejected(scalar: str) -> None: + assert "tag:yaml.org,2002:timestamp" in _pyyaml_plain_resolver_tags(scalar) + with pytest.raises(ConfigurationError) as exc: + parse_yaml_bytes(f"value: {scalar}\n".encode("utf-8")) + assert exc.value.reason == "yaml_ambiguous_scalar" + quoted = json.dumps(scalar).encode("utf-8") + assert parse_yaml_bytes(b"value: " + quoted + b"\n") == {"value": scalar} + +@pytest.mark.parametrize( + "scalar,expected", + [ + ("0", 0), + ("-0", 0), + ("0.0", 0.0), + ("-0.1", -0.1), + ("0e0", 0.0), + ("0E+2", 0.0), + ("1E-3", 0.001), + ("-1.5e-2", -0.015), + ], +) +def test_ir1_01_supported_json_number_lexemes_remain_supported( + scalar: str, expected: int | float +) -> None: + assert parse_yaml_bytes(f"value: {scalar}\n".encode("utf-8")) == { + "value": expected + }