diff --git a/.agents/skills/cryptad-architecture/SKILL.md b/.agents/skills/cryptad-architecture/SKILL.md index 4dfba090953..9ba90b1d4ea 100644 --- a/.agents/skills/cryptad-architecture/SKILL.md +++ b/.agents/skills/cryptad-architecture/SKILL.md @@ -240,6 +240,25 @@ Use this skill when you need to: platform, FCP, and HTTP boundary suites also require `package-info.java` in the production packages they own. +## Release-tooling boundary + +- `tools/release-certification/cryptad_certification/engines/` owns side-effect-free policy and + evidence evaluation. Stable 1.0 GA remains in `stable_1_0_ga*`; later routine maintenance and + security hotfixes share the `stable_1_0_maintenance*` engine family and one closed policy. +- `tools/release-certification/protected/stable_maintenance_publication.py` owns protected-boundary + materialization, exact-state revalidation, publication receipt verification, and successor + activation. Do not import a publication client into the certification engine. +- `tools/release-certification/publication-backend/` is the separately built, attested provider + wheel. Hosted publication jobs load it only from the authenticated installation directory; the + candidate checkout is not a provider source. +- `.github/workflows/stable-1.0-maintenance-*.yml` owns the protected input, Windows package, + backend-wheel, candidate-freeze, authorization, publication, independent-verification, and + activation orchestration. These workflows validate refs and exact artifacts but never create or + merge release/hotfix branches. +- `build-logic/src/main/kotlin/cryptad/PortableArchiveNormalizer.kt` and the distribution/runtime + convention plugins own deterministic portable archive construction. The independent Python + archive gate verifies those bytes; neither layer is a substitute for the other. + ## Architecture overview (by package) ### Core network layer (`network.crypta.node`) - Node coordination: `Node.java` diff --git a/.agents/skills/cryptad-core-updater/SKILL.md b/.agents/skills/cryptad-core-updater/SKILL.md index b502e74c678..20f45ad30bf 100644 --- a/.agents/skills/cryptad-core-updater/SKILL.md +++ b/.agents/skills/cryptad-core-updater/SKILL.md @@ -94,3 +94,34 @@ Use this skill when working on: - UOM wire compatibility keeps legacy field/type strings where required: - field payload names such as `"mainJarKey"`, `"mainJarVersion"`, `"mainJarFileLength"` - message type strings `"CryptadUOMRequestMainJar"` / `"CryptadUOMSendingMainJar"` + +## Stable 1.0 maintenance descriptor publication + +For a Stable 1.0 maintenance or security-hotfix candidate, `stable-maintenance` generates +deterministic `core-info.json` bytes bound to the exact frozen packages. Require the canonical +integer `version`, public release page, sorted supported `.` package keys, public CHK or +store URLs, and authenticated package sizes. Every required candidate package must appear exactly +once; do not add a misleading local SHA-256 field, placeholder, private insert URI, or local path. + +Include the descriptor digest in checksums, provenance, authorization, and the publication plan. +The update USK private insert URI/key is a protected secret supplied only at the publication +boundary. After insertion, fetch through the public request URI, compare exact descriptor bytes and +referenced package identities, and record a separate updater publication receipt. Conflict or an +unavailable public observation is not idempotent success and must not be overwritten. +Bind the plan and receipt to the exact public fetch URI and USK edition, not only to the descriptor +and package-map digests. The published descriptor's CHK/store references must use canonical public +destinations whose resolved addresses are global. A receipt for a different URI, edition, package +target, or fetched byte sequence cannot activate a successor baseline. +The protected workflow loads the reviewed provider named by +`CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND` and exposes +`CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT` only to the CoreUpdater target operation. Never reuse the +catalog or maintenance-state protected input for update insertion, and never serialize the private +input into the descriptor, plan, receipt, logs, or uploaded artifacts. +The provider's public deployment-service verification receives the authenticated CoreUpdater plan +and descriptor inside its closed `verificationInputs` set. It must construct a complete updater +receipt from those exact records; it must not rely on an undocumented service-side copy. The +protected adapter independently validates `verificationStatus`, public fetch URI, edition, +descriptor bytes, and every referenced package before accepting that receipt. + +Use `AppEnv` for platform/package-key mapping and follow +`docs/stable-1.0-maintenance-release-and-hotfix-path.md`. diff --git a/.agents/skills/cryptad-git-workflow/SKILL.md b/.agents/skills/cryptad-git-workflow/SKILL.md index 2cea0c43ea2..7ce113e8e88 100644 --- a/.agents/skills/cryptad-git-workflow/SKILL.md +++ b/.agents/skills/cryptad-git-workflow/SKILL.md @@ -48,6 +48,12 @@ Use this skill whenever you: GitHub Release only after exact-RC validation and environment approval. It never merges the release branch. Continue to use explicit release-manager-approved `--no-ff` merges into `main` and `develop`. +- The protected Stable 1.0 maintenance workflow applies the same boundary to later routine + `release/` and security `hotfix/` candidates: it may create or verify + the annotated tag and exact public state, but it never creates or merges branches. + Follow `docs/stable-1.0-maintenance-release-and-hotfix-path.md` for the protected sequence and + verify that the eventual `main` merge contains the tagged shipped commit; a `--no-ff` merge gives + `main` a distinct merge-commit tip. - PR policy: require green CI and at least one approval. - **Always ask before creating a GitHub pull request.** Do not open PRs without explicit approval. @@ -70,6 +76,9 @@ For Stable 1.0, also verify: `publication-verification-failed` without recovery code mutating it. - [ ] No test, local default, pull-request workflow, or validate-only run can create a tag, Release, branch, public catalog update, update descriptor, or network insert. +- [ ] For a later Stable 1.0 release, `stable-maintenance` authenticated the immutable GA root and + latest published predecessor, and the protected receipt verifies exact candidate bytes before + the manual no-squash, `--no-ff` merge-back. ## Git identity policy (must follow) ### GitHub operation identity diff --git a/.agents/skills/cryptad-hotfix-workflow/SKILL.md b/.agents/skills/cryptad-hotfix-workflow/SKILL.md index a6988e5fc8d..893c95dc115 100644 --- a/.agents/skills/cryptad-hotfix-workflow/SKILL.md +++ b/.agents/skills/cryptad-hotfix-workflow/SKILL.md @@ -24,7 +24,9 @@ Build: 3 ## Rules - Hotfix branches are `hotfix/` and must be based on `main`. - If the hotfix changes the shipped build, bump the integer `version` in `build.gradle.kts` to the next build number. -- Tag the hotfix as `v` on the hotfix branch. +- Tag an ordinary hotfix as `v` on the hotfix branch. For a Stable 1.0 security + hotfix, the protected maintenance workflow creates or verifies the annotated tag after + authorization; do not tag it manually. - Merge `hotfix/*` into `main` and back-merge into `develop` using **no squash** and `--no-ff`. - Do not rebase/squash hotfix merges. @@ -41,11 +43,15 @@ git checkout -b hotfix/ 2) Apply the fix, run tests/CI per repo conventions. - If the hotfix changes the shipped build, set `version = ` in `build.gradle.kts`. -3) Tag the hotfix on the hotfix branch: +3) For an ordinary hotfix, tag the hotfix on the hotfix branch: ```sh git tag v ``` +For a Stable 1.0 security hotfix, do not run that command. Validate the exact candidate with +`stable-maintenance` and let the protected maintenance workflow create or idempotently verify the +annotated tag. The workflow never merges the hotfix branch. + 4) Merge to `main` (no squash; preserve branch context): ```sh git checkout main @@ -60,7 +66,7 @@ git pull git merge --no-ff hotfix/ ``` -6) Push branches and tag: +6) Push branches and tag for an ordinary hotfix: ```sh git push origin main develop hotfix/ git push origin v @@ -71,6 +77,49 @@ git push origin v ## Checklist - [ ] `build.gradle.kts` version is the intended integer build number (if shipped). - [ ] CI green on `hotfix/`. -- [ ] Tag `v` created. +- [ ] Ordinary hotfix: tag `v` created. Stable 1.0 security hotfix: protected + publication receipt verifies the annotated tag. - [ ] Merged to `main` with `--no-ff` (no squash), then back-merged to `develop` with `--no-ff`. - [ ] Branches and tag pushed. + +## Stable 1.0 security hotfix path + +A Stable 1.0 hotfix uses `policies.releaseClass=security-hotfix` on +`hotfix/` based on the currently published `main` state. Run the side-effect-free +command against a copied, completed maintenance manifest: + +```bash +python3 tools/release-certification/certify.py stable-maintenance \ + --manifest build/stable-1.0-maintenance.json +``` + +The critical-security policy requires an incident/advisory id, qualified severity and affected +scope, release-manager authorization, exact candidate identity, full non-waivable compatibility, +packaging, updater, upgrade, rollback, migration, backup, signing, and redaction gates, and a +deadline-bound follow-up obligation for any shortened observation window. It is not a generic skip +or waiver. Declare a nonempty `affectedPackageKeys` subset even when publishing the complete +package matrix. A narrowed matrix requires a passing unaffected-target proof and must equal that +affected set exactly; a complete matrix uses `unaffectedPackageProofStatus=not-applicable`. Close +the obligation later with the side-effect-free `close-hotfix-follow-up` mode; do not rebuild or +mutate the published hotfix. The aggregate interval and every obligated row must independently +meet the normal duration and freshness policy, must have completed by the validation time, and must +bind both the original hotfix predecessor and the immutable GA identity where the scenario is a +direct-GA upgrade. When another authorized hotfix carries an open or overdue +obligation, closure evidence and authorization remain bound to the originally obligated build and +bytes. The latest activated baseline, receipt, and pointer separately authenticate where that +obligation is currently carried; do not substitute the superseding hotfix's evidence or +authorization. Authenticate the original candidate freeze against the predecessor observation +recorded in that freeze, not against a later baseline carrying the obligation. Protected pointer +activation uses a fresh activation-only authorization after environment approval; it does not alter +the published hotfix authorization or bytes. + +Follow `docs/stable-1.0-maintenance-release-and-hotfix-path.md`. The protected workflow publishes +but never merges; explicit no-squash, `--no-ff` merges into `main` and `develop` remain required. +Before approval, confirm the protected environment has exact producer identities in +`CRYPTAD_STABLE_MAINTENANCE_INPUT_SIGNER_WORKFLOW` and +`CRYPTAD_STABLE_MAINTENANCE_WINDOWS_SIGNER_WORKFLOW` and a reviewed +`CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND`. A hotfix does not weaken those provenance or +publication-boundary requirements. Pin the two producer identities to the checked-in +`.github/workflows/stable-1.0-maintenance-input-producer.yml` and +`.github/workflows/stable-1.0-maintenance-windows-package-producer.yml` workflows at the exact +candidate commit; do not substitute a generic artifact upload or a URL-only producer. diff --git a/.agents/skills/cryptad-interop-performance-gates/SKILL.md b/.agents/skills/cryptad-interop-performance-gates/SKILL.md index 2365b630f95..8c2fa330189 100644 --- a/.agents/skills/cryptad-interop-performance-gates/SKILL.md +++ b/.agents/skills/cryptad-interop-performance-gates/SKILL.md @@ -1,6 +1,6 @@ --- name: cryptad-interop-performance-gates -description: "Maintain Cryptad's Hyphanet interop, performance regression, release-certification evidence gates, production beta pipeline, Stable 1.0 RC freeze, and exact-byte GA promotion under tools/interop, tools/perf, tools/release-certification, CI, and release-readiness documentation." +description: "Maintain Cryptad's Hyphanet interop, performance regression, release-certification evidence gates, production beta pipeline, Stable 1.0 RC/GA flow, and later exact-byte maintenance or security-hotfix path under tools/interop, tools/perf, tools/release-certification, CI, and release-readiness documentation." --- # Cryptad interop and performance gates @@ -19,6 +19,10 @@ related CI jobs, or release-gate documentation. - Stable 1.0 RC execution and freeze: `docs/stable-1.0-rc-execution-and-release-freeze.md` - Stable 1.0 RC validation and GA promotion: `docs/stable-1.0-rc-validation-and-ga-promotion.md` +- Stable 1.0 maintenance and security hotfix path: + `docs/stable-1.0-maintenance-release-and-hotfix-path.md` +- Stable maintenance publication provider protocol: + `tools/release-certification/publication-backend/README.md` - Multi-node beta soak and upgrade drill: `docs/multi-node-beta-soak-and-upgrade-drill.md` - Ecosystem RC certification gate: `docs/ecosystem-rc-certification-gate.md` - Release certification tooling: `tools/release-certification/README.md` @@ -423,3 +427,64 @@ python3 tools/release-certification/certify.py stable-ga \ idempotent only after the same checks pass; mismatches produce a verified failure receipt. - Release notes should mention interop, performance, or certification gate changes only when they affect release readiness, operator confidence, app/platform behavior, or packager workflows. + +## Stable 1.0 maintenance evidence + +After GA, `python3 tools/release-certification/certify.py stable-maintenance` is the canonical +routine-maintenance and security-hotfix gate. It authenticates the GA root and latest predecessor, +then requires candidate-bound, fresh production live-network, Hyphanet interop, performance, +multi-node, sandbox, security, upgrade/recovery, and support evidence under the current policy +windows. Fixture, simulated-only, skipped, stale, dirty, test-signing, or wrong-candidate evidence +cannot satisfy production gates. + +Routine maintenance uses the complete production windows and target matrix. A policy-qualified +critical security hotfix may shorten only the named prepublication observation windows; it still +passes every non-waivable gate and emits a deadline-bound full-window follow-up obligation. Closing +that obligation is side-effect-free and cannot change the published bytes. Follow +`docs/stable-1.0-maintenance-release-and-hotfix-path.md` and keep protected publication separate +from evidence production. Configure `STABLE_CATALOG_TRUSTED_KEYS_BASE64` on +`stable-1.0-maintenance-evidence` with the public-key-only production catalog registry. The freeze +must verify the exact catalog and detached signature under the declared key id, record the registry +SHA-256, and delete the decoded registry without publishing public-key bytes or embedding raw +signature content in JSON. The exact detached signature sidecar remains a frozen public asset. +Keep the publication provider's immutable source, wheel, signer, and +entry-point identity pins in repository-level Actions variables so the evidence-scoped independent +verifier and both publication environments authenticate the same backend without exposing any +publication-only target secret to the verifier. +Materialize target credentials before backend construction, then permanently remove their names +from both the adapter's environment snapshot and ambient process environment. Backend imports, +observations, and untargeted publication calls must see no catalog, CoreUpdater, or maintenance +state secret; deliver each opaque input only to its closed target operation. +Before authorization, expand and canonicalize every concrete publication-object URI—including +artifact-base children and the detached catalog-signature sibling—and reject aliases across +GitHub Release, artifacts, catalog primary/mirrors/rollback/signature, and CoreUpdater roles. +The canonical maintenance provider verifies but does not populate the public artifact base. +Pre-stage every planned object independently, then require an exact matching artifact-base prefix +before the tag is the first permitted mutation. An absent, partial, or mismatched artifact base +must fail protected preflight; it is not a resumable empty publication state. +Supplied maintenance publication receipts must bind the nested GitHub Release identity—including +release id, integer-build tag, and canonical public page URI—to the exact authorized target; a +passing operation, notes digest, and aggregate public observation are not sufficient. +Before authorization, require the GitHub Release page to be exactly +`https://github.com/crypta-network/cryptad/releases/tag/v`. The protected provider owns that +fixed repository and must compare the deterministic `Cryptad v` title as well as the tag, +commit, page, notes, draft/prerelease state, and assets when verifying exact existing state. +Protected phase-ZIP intake must allowlist the complete extracted file tree, not only the +`protected-inputs/` subtree: the canonical phase manifest and files beneath explicitly referenced +directory inputs are the only survivors, and unrelated root-level or sibling files are blockers. +The latest-baseline activation job must retain its pre-adapter mutation-boundary marker on every +outcome. Its workflow audit conservatively reports that side effects may have occurred once that +marker exists and carries the observed pointer digest from an activation receipt when available; +never describe a missing receipt after that boundary as proof that the pointer was unchanged. +The deployment provider's `verify-publication` call must remain self-contained: send every closed, +digest-bound candidate, lineage, baseline, evidence, provenance, CoreUpdater, and nullable follow-up +record needed to construct the receipt, successor baseline, and history entry. Do not introduce an +undocumented service-side candidate store or treat service construction as producer +authentication; the protected adapter must independently validate every returned record. + +Use this focused offline check while changing the maintenance engine, schemas, workflows, or +provider, followed by the broader suites appropriate to the touched integration: + +```bash +python3 tools/release-certification/certify.py stable-maintenance --self-test +``` diff --git a/.agents/skills/cryptad-packaging/SKILL.md b/.agents/skills/cryptad-packaging/SKILL.md index 0e5b870e581..5de40adf953 100644 --- a/.agents/skills/cryptad-packaging/SKILL.md +++ b/.agents/skills/cryptad-packaging/SKILL.md @@ -1,6 +1,6 @@ --- name: cryptad-packaging -description: "Build and troubleshoot distributions and installers, including Stable 1.0 deterministic RC product archives and exact-byte GA promotion (assembleCryptadDist, jpackage, Windows wrapper assets, Flatpak, and Linux DEB/RPM behavior)." +description: "Build and troubleshoot distributions and installers, including Stable 1.0 deterministic RC archives, exact-byte GA promotion, and built-once maintenance/hotfix packages (assembleCryptadDist, jpackage, Windows wrapper assets, Flatpak, and Linux DEB/RPM behavior)." metadata: area: packaging domain: cryptad @@ -123,6 +123,21 @@ Use this skill when working on: - `distZipCryptad` / `distTarCryptad` → `build/distributions/cryptad-v.(zip|tar.gz)` - `distJlinkCryptad` → `build/distributions/cryptad-jlink-v.(zip|tar.gz)` - Both include Windows launchers and binaries. +- These general Gradle archive tasks normalize member order, timestamps, ownership, modes, and + gzip/ZIP metadata inside the Java 25 Gradle build. They do not require Python; the Python release + certification command independently verifies the resulting archive bytes. +- Archive modes come only from deterministic member-path roles, never host execute-access checks: + ordinary files are `0644`, directories are `0755`, and only Unix/JRE launchers, jlink helper + executables, and the shipped wrapper native libraries receive `0755`. +- The Python maintenance archive rewriter and independent hygiene gate must apply that same + member-path policy. They must not preserve a source archive's execute bits or accept both `0644` + and `0755` indiscriminately for regular files. +- Treat canonical member names as extraction identities. Reject raw `.` or empty components, + trailing-slash file/directory aliases, duplicate canonical paths at every nested level, POSIX, + drive-qualified, and UNC absolute paths, escaping symlink targets, and special files. Require + closed gzip headers, only necessary canonical PAX extensions, empty ZIP archive/member comments + and extra fields, and explicit Unix type/mode metadata for every ZIP member. Bound a nested + member before reading or decompressing its bytes. ## Stable 1.0 RC and GA archives @@ -165,7 +180,9 @@ We ship Gradle tasks that build a desktop app image and (on macOS/Linux) native ### Metadata and entrypoint - Name: `Crypta`, Vendor: `crypta.network`, App ID: `network.crypta.cryptad` - Main entry: `network.crypta.launcher.Launcher` -- Versioning: jpackage requires numeric `--app-version`; we use the project version integer. +- Versioning: jpackage requires numeric `--app-version`. Linux and macOS use the project version + integer. Windows maps that same integer to the MSI-safe technical form `1.0.` at the app + image, launcher-configuration, and EXE stages; this does not change Cryptad's project version. ### Icons and resources - macOS: `src/jpackage/macos/cryptad.icns` @@ -242,3 +259,89 @@ flatpak run network.crypta.cryptad//v1 Notes: - Flatpak packaging files live under `tools/flatpak/`. - Spotless is scoped to `src/**`; `.spotlessignore` at repo root prevents scanning Flatpak scratch dirs. + +## Stable 1.0 maintenance candidate packaging + +Unlike GA promotion, a later Stable 1.0 release introduces new bytes. Build and freeze the product +archive and all declared packages exactly once through `stable-maintenance`; normalize and inspect +archives with the established RC rules, then bind every filename, size, mode, digest, signature, +source commit, and installer result into candidate checksums and provenance. Any changed byte after +authorization requires a new candidate and authorization. + +After each protected package build, require `HEAD` to remain the exact candidate commit and reject +any staged or unstaged change to a tracked path before producer metadata or attestations are +created. Ignore untracked Gradle outputs for that source-drift decision. The aggregate freeze must +also prove that the exact `release.sourceCommit` in the authenticated latest predecessor baseline +is an ancestor of the candidate; branch-base ancestry alone is insufficient. + +The normal matrix covers the portable distribution and supported Linux DEB/RPM, macOS DMG, and +Windows EXE targets. A local Linux or macOS run must not claim that target passed. Dispatch the +checked-in protected Windows producer at the exact candidate commit; it runs +`jpackageInstallerWindowsExeCryptad` once on the hosted Windows runner, Authenticode-signs and +verifies the final amd64 PE, rechecks tracked source state, and attests both the EXE and its receipt. +The app-image prerequisite, rewritten launcher configuration, and EXE task all map integer build +`` to MSI ProductVersion `1.0.` so the release number uses MSI's 16-bit build +component; protected Windows releases fail closed above build 65535. +The hosted Ubuntu maintenance producer must install the distribution `rpm` package and verify both +`rpm` and `rpmbuild` before invoking `jpackageInstallerLinuxAll`; the package task intentionally +skips RPM creation when that external toolchain is absent. +Every security hotfix declares a nonempty affected-package subset. A full-matrix hotfix records +`unaffectedPackageProofStatus=not-applicable`; a narrowed matrix must equal the affected set +exactly and records `unaffectedPackageProofStatus=pass` only when advisory evidence proves the +omitted targets do not ship the vulnerable code. A narrowed hotfix without a DMG must not attach +the authenticated macOS notarization receipt to any selected non-DMG package. + +The protected maintenance package producer uses keyless GitHub/Sigstore attestations to +cryptographically sign every exact staged DEB, RPM, portable archive, and DMG without changing its +bytes. It must immediately verify each subject against the exact maintenance workflow and source +commit, emit a separate redaction-safe per-asset verification receipt, and upload those receipts +with the producer artifact. The freeze boundary independently repeats `gh attestation verify` and +uses the matching per-asset receipt digest for `signingReceiptDigest`; a generic producer receipt, +checksum, `productionSigning` Boolean, or build attestation cannot stand in for this check. This +path uses the workflow's OIDC and attestation permissions and has no private-key or passphrase +input. Never add signing secret material to the workflow command line, logs, receipts, or retained +artifacts. + +For the protected macOS maintenance freeze, import the reviewed Developer ID Application identity +into an ephemeral keychain and run `jpackageInstallerCryptad` with +`-PmacSigningKeyUserName=`. After enrichment has copied `cryptad-dist` and rewritten +the launcher configuration, that opt-in property makes `signFinalMacAppImageCryptad` replace the +jpackage signatures in explicit inside-out order and sign the app root last. Preserve the existing +identifier and entitlement metadata on the jpackage JVM, framework, and app-root code while +switching to Developer ID `codesign --options runtime --timestamp`; fail closed if that metadata +is absent. Select nested signable files by their thin/universal Mach-O magic, not by POSIX execute +bits or filename suffixes: the enriched portable distribution also contains executable Linux ELF +and Windows PE files, which are app resources on macOS and must be covered only by the final app +root seal. Sign recognized nested native bundles after their contained Mach-O code. Do not use +recursive `--deep` signing to replace nested signatures. Do not attempt JDK +25's rejected +`--type app-image --app-image` combination. The installer task runs only after that boundary and +retains jpackage's mac signing flags, but those flags do not prove that jpackage signed the outer +DMG container. Ordinary local packaging remains unchanged when the property is absent. Explicitly +Developer-ID-sign and verify the exact DMG after jpackage and before notarization submission, then +staple and verify those resulting bytes again before computing the frozen digest and copying the +DMG into the frozen asset set. Also verify the app, stapling ticket, and Gatekeeper assessment. Do +not freeze a DMG based only on declared signing metadata. + +The workflow variable is `CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION`. Keep the P12 and notary values +only in the evidence environment secrets +`CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64`, +`CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION_P12_PASSWORD`, +`CRYPTAD_MACOS_NOTARY_APPLE_ID`, `CRYPTAD_MACOS_NOTARY_APP_PASSWORD`, and +`CRYPTAD_MACOS_NOTARY_TEAM_ID`. Never place those values in Gradle properties files, workflow +inputs, command output, receipts, or retained artifacts. + +Publication copies the frozen assets; it never reruns Gradle, jpackage, signing, notarization, or +archive creation. Follow `docs/stable-1.0-maintenance-release-and-hotfix-path.md`. + +Focused cross-platform argument checks are: + +```bash +./gradlew verifyMacAppImageSigningArguments verifyWindowsExeInstallerArguments +``` + +When portable archive logic changes, build the affected `distZipCryptad`, `distTarCryptad`, and +`distJlinkCryptad` tasks, then run +`python3 tools/release-certification/certify.py stable-maintenance --self-test` so the independent +Python hygiene rules are exercised against the Java normalizer contract. These checks do not +replace protected signing, notarization, multi-OS packaging, or publication. diff --git a/.agents/skills/cryptad-platform-apps/SKILL.md b/.agents/skills/cryptad-platform-apps/SKILL.md index 6f973c04011..bb18d68cc7e 100644 --- a/.agents/skills/cryptad-platform-apps/SKILL.md +++ b/.agents/skills/cryptad-platform-apps/SKILL.md @@ -1,6 +1,6 @@ --- name: cryptad-platform-apps -description: "Work on Cryptad's app platform: Platform API v1/contract, signed app bundles/catalogs and mirrors, app review, production security, Trust Graph/Social Inbox RCs, app update/data/backup/services/UI/SDK, sandbox and support evidence, production beta gates, Stable 1.0 RC freeze and exact-byte GA maintenance baseline, legacy plugin freeze, and legacy admin retirement routing." +description: "Work on Cryptad's app platform: Platform API v1/contract, signed app bundles/catalogs and mirrors, app review, production security, Trust Graph/Social Inbox RCs, app update/data/backup/services/UI/SDK, sandbox and support evidence, production beta gates, Stable 1.0 RC/GA and later maintenance compatibility, legacy plugin freeze, and legacy admin retirement routing." --- # Cryptad platform apps @@ -60,6 +60,8 @@ Load only the docs needed for the change: - Stable 1.0 RC execution and freeze: `docs/stable-1.0-rc-execution-and-release-freeze.md` - Stable 1.0 RC validation and GA promotion: `docs/stable-1.0-rc-validation-and-ga-promotion.md` +- Stable 1.0 maintenance and security hotfix path: + `docs/stable-1.0-maintenance-release-and-hotfix-path.md` - Multi-node beta soak and upgrade drill: `docs/multi-node-beta-soak-and-upgrade-drill.md` ## Ownership map @@ -233,6 +235,10 @@ Load only the docs needed for the change: permission-disclosure, and design-system checks. - Signed catalogs and bundles must verify before install/update. Unsigned live-node installs require the explicit development-only escape hatch. +- At a release boundary, verify the exact detached catalog sidecar and the independently frozen + signer id. `crypta-app catalog verify --catalog-signature-file --expected-key-id ` + prevents another key in a broad trusted registry from satisfying that binding. Do not replace + this with a digest-only check or infer signer identity from the registry. - Trusted app-review receipts are independent reviewer evidence. Do not treat publisher advisory `review.status`, app signing keys, or catalog signing keys as reviewer trust. - App-store submission packages are review inputs, not install approvals. Keep package bodies, @@ -459,3 +465,27 @@ python3 tools/release-certification/certify.py self-test all Run a candidate-bound component only after copying the appropriate example manifest to `build/`, replacing every placeholder, and arranging any required v2 migration or attached evidence with the same finalized `release.id`. + +## Stable 1.0 maintenance compatibility + +Use `stable-maintenance` after GA for both routine maintenance and critical security hotfixes. It +compares Platform API, stable catalog, first-party apps, content profiles, security/support state, +limitations, and legacy boundaries against both the immutable GA baseline and the immediate +predecessor. Preserve original Platform API deprecation clocks and frozen v1 canonicalization and +signature rules. Reject app removal/id substitution, channel or support downgrade, untrusted +signers/reviewers, unexplained permission expansion, or missing migration, rollback, backup, and +restore evidence. If catalog bytes, signature, or signing-key identity changes, require the edition +or revision to advance. Validate known-limitations added/resolved/unchanged ids as a disjoint, +canonical partition of the authenticated predecessor membership; retain the sorted current ids in +each successor baseline so a later release cannot replace membership with an unauthenticated +digest. + +Treat app support as a closed ordered commitment: promotion is allowed, downgrade or an unknown +level is not. Compare app versions with the canonical dotted-numeric `AppUpdateService` semantics. +A version cannot regress below GA or the predecessor, and changed bundle bytes require a strict +version increase so an installed node can discover the patch. + +Bind all app/catalog/update and durable-state scenarios to the exact candidate and predecessor +digests. A security hotfix cannot waive these gates; only policy-listed observation windows may be +shortened, creating a follow-up obligation. Follow +`docs/stable-1.0-maintenance-release-and-hotfix-path.md`. diff --git a/.agents/skills/cryptad-release-workflow/SKILL.md b/.agents/skills/cryptad-release-workflow/SKILL.md index df74643445f..c2df3da0655 100644 --- a/.agents/skills/cryptad-release-workflow/SKILL.md +++ b/.agents/skills/cryptad-release-workflow/SKILL.md @@ -2,8 +2,8 @@ name: cryptad-release-workflow description: | Cut and stabilize a Cryptad release branch using integer build-number versioning, v-number tags, - no-squash --no-ff merges into main and develop, and the protected Stable 1.0 RC/GA path when the - release targets that product milestone. + no-squash --no-ff merges into main and develop, and the protected Stable 1.0 RC/GA or later + maintenance path when the release targets that product milestone. --- # Release workflow (release/) @@ -34,6 +34,9 @@ Build: 2 - For Stable 1.0, freeze with `stable-rc` and promote that exact product with `stable-ga`. Do not rebuild, re-sign, rewrite the catalog, or create the GA tag/Release outside the explicitly protected publication operation. +- After Stable 1.0 GA, use `stable-maintenance` for both routine maintenance and security hotfix + candidates. It builds and freezes one new integer-build candidate; the protected maintenance + workflow publishes only those authorized bytes and never merges the branch. - Use `docs/cryptad-release-workflow-and-runbook.md` as the detailed release-readiness source of truth. Current release gates include the release certification report, first-party app staging/signing/verification, first-party beta catalog and trusted app-review receipt smoke, @@ -153,9 +156,10 @@ git checkout -b release/ git tag v ``` -For Stable 1.0 GA, do not run that command manually. The explicitly authorized protected -publication job creates or idempotently verifies the annotated `v` tag at the exact -authorized commit. It does not merge the release branch. +For Stable 1.0 GA or a later Stable 1.0 maintenance release, do not run that command manually. The +applicable explicitly authorized protected publication job creates or idempotently verifies the +annotated `v` tag at the exact authorized commit. It does not merge the release +branch. 5) Merge forward to `main` (no squash; preserve branch context): ```sh @@ -232,6 +236,73 @@ GitHub Release creation. insert URIs, raw trusted reviewer public key bytes, provider app data, raw signatures, raw incident artifacts, raw fetched content, command lines containing secrets, CI secret values, and unsanitized local paths. + +## Stable 1.0 maintenance releases after GA + +For a later routine Stable 1.0 release, copy +`tools/release-certification/manifests/stable-1.0-maintenance.example.json`, replace every +placeholder, select `policies.releaseClass=maintenance`, and run: + +```bash +python3 tools/release-certification/certify.py stable-maintenance \ + --manifest build/stable-1.0-maintenance.json +``` + +The command is side-effect-free. It authenticates the immutable GA v1 baseline and complete GA +receipt, authenticates the latest published predecessor, freezes one new candidate, enforces +compatibility and evidence gates, and prepares an authorization or closes a hotfix follow-up. The +protected `.github/workflows/stable-1.0-maintenance-release.yml` workflow revalidates current public +state, creates or verifies the annotated tag, publishes exact bytes, and activates a successor v2 +baseline only after receipt verification. It never creates or merges `release/`. +Publication retries may continue only after an exact matching target prefix; non-prefix partial +state is a conflict. Latest-baseline activation uses a fresh, activation-only authorization issued +inside the protected activation environment and bound to the verified receipt, successor, history, +original authorization digest, and expected pointer. Do not extend or replace the immutable public +publication authorization merely because an activation approval wait crossed its expiry. +The evidence environment must configure exact producer identities in +`CRYPTAD_STABLE_MAINTENANCE_INPUT_SIGNER_WORKFLOW` and +`CRYPTAD_STABLE_MAINTENANCE_WINDOWS_SIGNER_WORKFLOW`. Configure the reviewed publication-backend +source commit, wheel digest, signer workflow, and entry point as repository-level Actions variables +so they are visible to the evidence-scoped independent verifier and both publication environments; +never scope those four immutable identity pins only to a publication environment. Missing producer +authentication or publication infrastructure is a hard stop, not permission to accept a path or +URL as identity. +Use the checked-in protected input and Windows package producer workflow identities. The input +producer retrieves only the exact-digest, public-safe phase ZIP through the evidence environment's +secret locator. It must reject every non-global DNS result, connect only to the validated numeric +addresses, verify the connected peer, and retain the original hostname for TLS SNI and certificate +verification before sending any bearer credential. The Windows producer builds once, +Authenticode-signs and verifies the amd64 EXE, rechecks tracked source state, and attests both the +EXE and its receipt. + +Run `.github/workflows/stable-1.0-maintenance-release.yml` through its four closed operations: +`freeze-candidate`, `prepare-authorization`, `validate-authorization`, then `publish`. Only freeze +may build candidate assets. Prepare consumes the exact attested freeze plus post-freeze evidence; +authorization validation consumes the exact prepared bundle plus one exact authorization JSON; and +publish consumes only that authorized bundle. The macOS producer must Developer-ID-sign, notarize, +staple, and verify its DMG before the freeze record is written. Publication jobs authenticate and +install the pinned provider wheel on the clean runner and recheck the remote release/hotfix ref and +authorization before mutation. Never collapse the four operations into one run or accept a +replacement candidate/evidence/authorization input at publication time. +Record the exact producing run id, artifact name, and Actions artifact digest at every handoff. +Freeze additionally requires the protected Windows producer coordinates and exact EXE SHA-256; +publish additionally requires the reviewed publication-backend producer coordinates. A path, +artifact name, run id, or digest by itself is not a complete producer identity. + +Configure `LEUMOR_GITHUB_TOKEN` on both maintenance publication environments. The protected +workflow must verify that token's `/user` login is exactly `leumor` and give it, rather than the +job-scoped Actions token, to tag, GitHub Release, and release-asset mutations. Keep the Actions +token read-only and use it only to authenticate workflow artifacts and attestations. + +The canonical provider verifies a separately pre-staged artifact base and uses a closed deployment +service for catalog, CoreUpdater, publication verification, and latest-pointer state. Its +`verify-publication` request carries the digest-bound records needed to build the receipt, +successor, and history; do not provision an undocumented service-side candidate copy. Follow +`tools/release-certification/publication-backend/README.md` for the exact protocol and accepted +public-HTTPS endpoint forms. + +Follow `docs/stable-1.0-maintenance-release-and-hotfix-path.md`. Preserve manual no-squash, +`--no-ff` merges into `main` and `develop` after the release-manager-approved publication flow. - [ ] Tag `v` created. - [ ] Merged to `main` with `--no-ff` (no squash), then back-merged to `develop` with `--no-ff`. - [ ] Branches and tag pushed. diff --git a/.agents/skills/cryptad-write-release-notes/SKILL.md b/.agents/skills/cryptad-write-release-notes/SKILL.md index 684fd6966b5..0d649aa665d 100644 --- a/.agents/skills/cryptad-write-release-notes/SKILL.md +++ b/.agents/skills/cryptad-write-release-notes/SKILL.md @@ -18,8 +18,11 @@ This skill covers how to draft or review a Cryptad GitHub release body and match - Repo-root `docs/cryptad-release-workflow-and-runbook.md` - Repo-root `docs/stable-1.0-rc-execution-and-release-freeze.md` for Stable RC notes - Repo-root `docs/stable-1.0-rc-validation-and-ga-promotion.md` for Stable GA notes +- Repo-root `docs/stable-1.0-maintenance-release-and-hotfix-path.md` for later Stable 1.0 notes - Repo-root `docs/templates/stable-1.0-rc-release-notes.md` or `docs/templates/stable-1.0-ga-release-notes.md` when that generated contract is in scope +- Repo-root `docs/templates/stable-1.0-maintenance-release-notes.md` for a maintenance or + security-hotfix generated-note contract - Repo-root `build.gradle.kts` ## Process @@ -162,3 +165,20 @@ If the release is still on `release/` and untagged, say so clearly For Stable RC or GA, update the template or authoritative source data and rerun the corresponding certification command. Do not manually patch generated output inside a release workspace. + +## Stable 1.0 maintenance and security-hotfix notes + +Generate maintenance notes through `stable-maintenance`, using integer build/tag identity and the +authenticated predecessor. Include the release class, user-visible fixes, Platform API +compatibility statement, catalog/app delta, migration/backup/rollback guidance, package matrix, +CoreUpdater availability, known-limitations delta, support guidance, and checksum/provenance links. +A security hotfix also names the public-safe advisory and accurately describes any outstanding +full-window follow-up without exposing embargoed details or private evidence. + +Treat the rendered note digest as part of candidate authorization. Reject placeholders, unsafe +Markdown/control text, private URIs, tokens, raw content/data, and local paths. Before a verified +publication-complete receipt, describe the notes and assets as prepared or authorized, never as +published. Link only to the planned public checksum/provenance assets. The public checksum file +does not enumerate internal candidate, lineage, comparison, evidence, follow-up, activation, or +history records; those belong only to the audit checksum inventory. Follow +`docs/stable-1.0-maintenance-release-and-hotfix-path.md`. diff --git a/.agents/skills/cryptad-writing-guides/SKILL.md b/.agents/skills/cryptad-writing-guides/SKILL.md index 65fe0c8f381..db31423a4f2 100644 --- a/.agents/skills/cryptad-writing-guides/SKILL.md +++ b/.agents/skills/cryptad-writing-guides/SKILL.md @@ -27,3 +27,9 @@ Use this skill when the task is mostly about words rather than code. - Prefer concrete commands, file paths, build numbers, and platform names over broad claims. - Keep validation, authorization, and publication distinct in Stable release prose. Never write that GA was published unless a verified publication receipt proves the public state. +- For later Stable 1.0 maintenance or security-hotfix prose, use + `docs/stable-1.0-maintenance-release-and-hotfix-path.md` as the canonical runbook. Preserve + integer builds and `v` tags, distinguish the immutable GA v1 root from successor v2 + baselines, state the hosted Windows, macOS, Linux, protected-input, and publication-provider + boundaries honestly, and never claim publication, latest-baseline activation, or follow-up + closure without the applicable independently verified receipt. diff --git a/.agents/skills/cryptad-writing-guides/references/release-notes-guide.md b/.agents/skills/cryptad-writing-guides/references/release-notes-guide.md index ae1a2570557..1ee2b266000 100644 --- a/.agents/skills/cryptad-writing-guides/references/release-notes-guide.md +++ b/.agents/skills/cryptad-writing-guides/references/release-notes-guide.md @@ -25,6 +25,9 @@ changelogs: - `stable-rc` renders `docs/templates/stable-1.0-rc-release-notes.md` from the authenticated freeze. - `stable-ga` renders `docs/templates/stable-1.0-ga-release-notes.md` from the authenticated RC, post-freeze validation, authorization, exact limitations, and maintenance baseline. +- `stable-maintenance` renders `docs/templates/stable-1.0-maintenance-release-notes.md` from the + authenticated GA root, latest predecessor, frozen integer-build candidate, compatibility delta, + evidence, package/updater state, limitations delta, and release class. - Required template tokens have a fixed order and cardinality. Missing, duplicated, unknown, or reordered tokens are release blockers. - Before verified publication, do not claim that the tag, GitHub Release, update descriptor, @@ -33,6 +36,9 @@ changelogs: render as literal audited text, not active injected headings, links, or images. - Publish only the planned public assets. The public checksum file must not reference internal authorization, validation, redaction, or input files that are absent from the Release assets. +- Maintenance notes must describe a shortened hotfix window and its outstanding follow-up + obligation accurately. Do not claim closure until the side-effect-free closure record is + authenticated through the current successor lineage. - Preserve the generated note digest through protected publication and verify it in the receipt. When the template needs editorial changes, edit the checked-in template and rerun the command. diff --git a/.github/actions/setup-stable-maintenance-publication-backend/action.yml b/.github/actions/setup-stable-maintenance-publication-backend/action.yml new file mode 100644 index 00000000000..1e90ad8e564 --- /dev/null +++ b/.github/actions/setup-stable-maintenance-publication-backend/action.yml @@ -0,0 +1,168 @@ +name: Set up Stable maintenance publication backend +description: Authenticate and install one reviewed publication-provider wheel without network dependency resolution. + +inputs: + run-id: + description: Exact protected producer workflow run id. + required: true + artifact-name: + description: Exact Actions artifact name containing one wheel. + required: true + artifact-digest: + description: Exact sha256: Actions artifact digest. + required: true + source-commit: + description: Exact reviewed provider source commit. + required: true + wheel-sha256: + description: Exact sha256: wheel digest. + required: true + signer-workflow: + description: Exact repository-qualified protected producer workflow. + required: true + entrypoint: + description: Reviewed module.path:factory entrypoint supplied by the protected environment. + required: true + github-token: + description: Token used only for run, artifact, and attestation authentication. + required: true + +outputs: + python-path: + description: Canonical path containing the authenticated installed provider. + value: ${{ steps.materialize.outputs.python-path }} + site-directory: + description: Canonical authenticated site root enforced by the publication adapter. + value: ${{ steps.materialize.outputs.site-directory }} + entrypoint: + description: Authenticated provider entrypoint. + value: ${{ steps.materialize.outputs.entrypoint }} + +runs: + using: composite + steps: + - name: Authenticate and install reviewed provider + id: materialize + shell: bash + env: + INPUT_RUN_ID: ${{ inputs.run-id }} + INPUT_ARTIFACT_NAME: ${{ inputs.artifact-name }} + INPUT_ARTIFACT_DIGEST: ${{ inputs.artifact-digest }} + INPUT_SOURCE_COMMIT: ${{ inputs.source-commit }} + INPUT_WHEEL_SHA256: ${{ inputs.wheel-sha256 }} + INPUT_SIGNER_WORKFLOW: ${{ inputs.signer-workflow }} + INPUT_ENTRYPOINT: ${{ inputs.entrypoint }} + GH_TOKEN: ${{ inputs.github-token }} + run: | + set -euo pipefail + canonical_artifact="stable-1.0-maintenance-publication-backend" + canonical_signer="crypta-network/cryptad/.github/workflows/stable-1.0-maintenance-publication-backend-producer.yml" + canonical_entrypoint="cryptad_stable_maintenance_backend:factory" + if [[ ! "$INPUT_RUN_ID" =~ ^[1-9][0-9]*$ \ + || "$INPUT_ARTIFACT_NAME" != "$canonical_artifact" \ + || ! "$INPUT_ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ \ + || ! "$INPUT_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ \ + || ! "$INPUT_WHEEL_SHA256" =~ ^sha256:[0-9a-f]{64}$ \ + || "$INPUT_SIGNER_WORKFLOW" != "$canonical_signer" \ + || "$INPUT_ENTRYPOINT" != "$canonical_entrypoint" ]]; then + echo "::error::Publication-provider identity is incomplete or unsafe." + exit 1 + fi + expected_workflow_path="${INPUT_SIGNER_WORKFLOW#crypta-network/cryptad/}" + run_json="$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_RUN_ID")" + jq -e \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg commit "$INPUT_SOURCE_COMMIT" \ + --arg workflow_path "$expected_workflow_path" ' + .repository.full_name == $repository + and .head_sha == $commit + and .path == $workflow_path + and .event == "workflow_dispatch" + and .status == "completed" + and .conclusion == "success"' <<< "$run_json" >/dev/null + artifacts="$(gh api --paginate --method GET \ + "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_RUN_ID/artifacts" \ + -f per_page=100 | jq -s '{artifacts: [.[].artifacts[]]}')" + jq -e --arg name "$INPUT_ARTIFACT_NAME" --arg digest "$INPUT_ARTIFACT_DIGEST" ' + [.artifacts[] | select(.name == $name and .expired == false and .digest == $digest)] + | length == 1' <<< "$artifacts" >/dev/null + download="build/stable-maintenance-publication-backend/download" + site="build/stable-maintenance-publication-backend/site-packages" + rm -rf "${download%/download}" + mkdir -p "$download" "$site" + download="$(cd "$download" && pwd -P)" + site="$(cd "$site" && pwd -P)" + gh run download "$INPUT_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "$INPUT_ARTIFACT_NAME" --dir "$download" + if [[ -n "$(find "$download" -type l -print -quit)" \ + || -n "$(find "$download" -mindepth 1 ! -type f ! -type d -print -quit)" ]]; then + echo "::error::Publication-provider artifact contains a link or special entry." + exit 1 + fi + wheels=() + while IFS= read -r path; do wheels+=("$path"); done < <( + find "$download" -type f -name '*.whl' -print | LC_ALL=C sort + ) + if [[ "${#wheels[@]}" -ne 1 \ + || "$(find "$download" -type f ! -name '*.whl' | wc -l)" -ne 0 ]]; then + echo "::error::Publication-provider artifact must contain exactly one wheel and no other files." + exit 1 + fi + wheel="${wheels[0]}" + actual_wheel_digest="sha256:$(python3 - "$wheel" <<'PY' + import hashlib + import sys + from pathlib import Path + + print(hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()) + PY + )" + if [[ "$actual_wheel_digest" != "$INPUT_WHEEL_SHA256" ]]; then + echo "::error::Publication-provider wheel differs from the reviewed digest." + exit 1 + fi + python3 -I -S - "$wheel" <<'PY' + import stat + import sys + import zipfile + from pathlib import PurePosixPath + + with zipfile.ZipFile(sys.argv[1]) as archive: + entries = archive.infolist() + names = [entry.filename for entry in entries] + if names != sorted(names) or len(names) != len(set(names)): + raise SystemExit("publication-provider wheel members are ambiguous") + for entry in entries: + path = PurePosixPath(entry.filename) + mode = entry.external_attr >> 16 + if path.is_absolute() or ".." in path.parts or "\\" in entry.filename: + raise SystemExit("publication-provider wheel contains an unsafe path") + if stat.S_IFMT(mode) != stat.S_IFREG or stat.S_IMODE(mode) != 0o644: + raise SystemExit("publication-provider wheel contains a noncanonical member") + PY + gh attestation verify "$wheel" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$INPUT_SIGNER_WORKFLOW" \ + --source-digest "$INPUT_SOURCE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + python3 -m pip install --no-index --no-deps --no-compile --target "$site" "$wheel" \ + >/dev/null + python3 -I -S - "$site" "$INPUT_ENTRYPOINT" <<'PY' + import importlib + import sys + from pathlib import Path + + site = Path(sys.argv[1]).resolve() + sys.path.insert(0, str(site)) + importlib.invalidate_caches() + module_name, factory_name = sys.argv[2].split(":", 1) + module = importlib.import_module(module_name) + origin = Path(module.__file__).resolve() + if not origin.is_relative_to(site): + raise SystemExit("publication-provider module did not load from the authenticated wheel") + if not callable(getattr(module, factory_name, None)): + raise SystemExit("publication-provider factory is missing") + PY + echo "python-path=$site" >> "$GITHUB_OUTPUT" + echo "site-directory=$site" >> "$GITHUB_OUTPUT" + echo "entrypoint=$INPUT_ENTRYPOINT" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/stable-1.0-maintenance-input-producer.yml b/.github/workflows/stable-1.0-maintenance-input-producer.yml new file mode 100644 index 00000000000..254fa4e44c8 --- /dev/null +++ b/.github/workflows/stable-1.0-maintenance-input-producer.yml @@ -0,0 +1,609 @@ +name: Stable 1.0 Maintenance Protected Input Producer +run-name: >- + Stable 1.0 protected ${{ inputs.phase }} inputs ${{ inputs.release_id }} build ${{ inputs.build_version }} + +on: + workflow_dispatch: + inputs: + release_id: + description: Path-safe Stable 1.0 maintenance release ID. + required: true + type: string + build_version: + description: Positive integer Cryptad build. + required: true + type: string + release_class: + description: Closed Stable 1.0 release class. + required: true + type: choice + options: + - maintenance + - security-hotfix + candidate_commit: + description: Exact candidate commit; dispatch this workflow at the same ref. + required: true + type: string + phase: + description: Consumer phase for this one immutable public-safe input bundle. + required: true + type: choice + options: + - freeze-candidate + - prepare-authorization + - validate-authorization + input_bundle_sha256: + description: Exact sha256: of the reviewed ZIP at the protected environment URI. + required: true + type: string + +concurrency: + group: >- + stable-1-0-maintenance-input-${{ inputs.release_id }}-${{ inputs.build_version }}-${{ inputs.phase }} + cancel-in-progress: false + +permissions: {} + +jobs: + authenticate-stage-and-attest: + name: Authenticate, validate, and attest public-safe inputs + runs-on: ubuntu-latest + environment: stable-1.0-maintenance-evidence + timeout-minutes: 60 + permissions: + contents: read + attestations: write + artifact-metadata: write + id-token: write + + steps: + - name: Check out exact candidate + uses: actions/checkout@v6 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Validate dispatch identity and pristine source + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_BUNDLE_SHA256: ${{ inputs.input_bundle_sha256 }} + run: | + set -euo pipefail + if [[ ! "$INPUT_RELEASE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ \ + || ! "$INPUT_BUILD_VERSION" =~ ^[1-9][0-9]*$ \ + || ! "$INPUT_CANDIDATE_COMMIT" =~ ^[0-9a-f]{40}$ \ + || ! "$INPUT_BUNDLE_SHA256" =~ ^sha256:[0-9a-f]{64}$ \ + || "$GITHUB_SHA" != "$INPUT_CANDIDATE_COMMIT" ]]; then + echo "::error::Protected input producer identity is malformed or dispatched at the wrong SHA." + exit 1 + fi + expected_branch="release/$INPUT_BUILD_VERSION" + if [[ "$INPUT_RELEASE_CLASS" == security-hotfix ]]; then + expected_branch="hotfix/$INPUT_BUILD_VERSION" + fi + if [[ "$GITHUB_REF" != "refs/heads/$expected_branch" ]]; then + echo "::error::The producer must be dispatched from the exact release/hotfix branch." + exit 1 + fi + remote_commit="$(git ls-remote --exit-code origin "refs/heads/$expected_branch" \ + | awk 'NR == 1 {print $1}')" + if [[ "$remote_commit" != "$INPUT_CANDIDATE_COMMIT" \ + || "$(git rev-parse 'HEAD^{commit}')" != "$INPUT_CANDIDATE_COMMIT" ]]; then + echo "::error::The expected release/hotfix branch does not point at this input producer." + exit 1 + fi + git diff-index --quiet --cached HEAD -- + git diff-files --quiet -- + + - name: Fetch exact reviewed bundle without exposing its protected locator + env: + INPUT_BUNDLE_SHA256: ${{ inputs.input_bundle_sha256 }} + PROTECTED_BUNDLE_URL: ${{ secrets.CRYPTAD_STABLE_MAINTENANCE_INPUT_BUNDLE_URL }} + PROTECTED_BUNDLE_BEARER_TOKEN: ${{ secrets.CRYPTAD_STABLE_MAINTENANCE_INPUT_BUNDLE_BEARER_TOKEN }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/stable-maintenance-input" + python3 - <<'PY' + import hashlib + import http.client + import ipaddress + import os + import socket + import ssl + import urllib.error + import urllib.parse + import urllib.request + from pathlib import Path + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise urllib.error.HTTPError(req.full_url, code, "redirect forbidden", headers, fp) + + def canonical_hostname(value): + return value.rstrip(".").encode("idna").decode("ascii").lower() + + + def resolve_global_endpoints(hostname, port): + try: + rows = socket.getaddrinfo( + hostname, + port, + type=socket.SOCK_STREAM, + proto=socket.IPPROTO_TCP, + ) + except OSError as exc: + raise SystemExit("protected bundle locator cannot be resolved") from exc + endpoints = [] + seen = set() + for family, socktype, proto, _canonname, sockaddr in rows: + try: + address = ipaddress.ip_address(sockaddr[0]) + except ValueError as exc: + raise SystemExit( + "protected bundle locator returned a malformed address" + ) from exc + if not address.is_global: + raise SystemExit("protected bundle locator does not resolve publicly") + endpoint = (family, socktype, proto, sockaddr) + if endpoint not in seen: + seen.add(endpoint) + endpoints.append(endpoint) + if not endpoints: + raise SystemExit("protected bundle locator does not resolve publicly") + return tuple(endpoints) + + + def connect_to_pinned(endpoints, timeout, source_address): + errors = [] + for family, socktype, proto, sockaddr in endpoints: + connection = socket.socket(family, socktype, proto) + try: + if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: + connection.settimeout(timeout) + if source_address: + connection.bind(source_address) + connection.connect(sockaddr) + expected_peer = ipaddress.ip_address(sockaddr[0]) + actual_peer = ipaddress.ip_address(connection.getpeername()[0]) + if actual_peer != expected_peer or not actual_peer.is_global: + raise OSError("connected peer does not match the validated address") + return connection + except OSError as exc: + errors.append(exc) + connection.close() + raise OSError("could not connect to any validated public address") from ( + errors[-1] if errors else None + ) + + + class PinnedHTTPSConnection(http.client.HTTPSConnection): + def __init__( + self, + host, + *, + pinned_endpoints, + tls_hostname, + tls_port, + context, + **kwargs, + ): + super().__init__(host, context=context, **kwargs) + self._pinned_endpoints = pinned_endpoints + self._tls_hostname = tls_hostname + self._tls_port = tls_port + + def connect(self): + if self._tunnel_host is not None: + raise OSError("HTTPS tunnels are forbidden for protected bundle retrieval") + if ( + canonical_hostname(self.host) != canonical_hostname(self._tls_hostname) + or self.port != self._tls_port + ): + raise OSError("HTTPS connection target differs from the validated locator") + raw_socket = connect_to_pinned( + self._pinned_endpoints, self.timeout, self.source_address + ) + try: + self.sock = self._context.wrap_socket( + raw_socket, server_hostname=self._tls_hostname + ) + except BaseException: + raw_socket.close() + raise + + + class PinnedHTTPSHandler(urllib.request.HTTPSHandler): + def __init__(self, *, endpoints, hostname, port, context): + super().__init__(context=context) + self._endpoints = endpoints + self._hostname = hostname + self._port = port + self._context = context + + def https_open(self, request): + def connection_factory(host, **kwargs): + return PinnedHTTPSConnection( + host, + pinned_endpoints=self._endpoints, + tls_hostname=self._hostname, + tls_port=self._port, + context=self._context, + **kwargs, + ) + + return self.do_open(connection_factory, request) + + + def main(): + url = os.environ.get("PROTECTED_BUNDLE_URL", "") + expected = os.environ["INPUT_BUNDLE_SHA256"] + parsed = urllib.parse.urlsplit(url) + try: + port = parsed.port or 443 + except ValueError as exc: + raise SystemExit("protected bundle locator is missing or unsafe") from exc + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise SystemExit("protected bundle locator is missing or unsafe") + endpoints = resolve_global_endpoints(parsed.hostname, port) + + headers = { + "Accept": "application/zip", + "User-Agent": "cryptad-maintenance-input/1", + } + token = os.environ.get("PROTECTED_BUNDLE_BEARER_TOKEN", "") + if token: + if "\r" in token or "\n" in token: + raise SystemExit("protected bundle credential is malformed") + headers["Authorization"] = "Bearer " + token + request = urllib.request.Request(url, headers=headers, method="GET") + tls_context = ssl.create_default_context() + tls_context.set_alpn_protocols(["http/1.1"]) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + NoRedirect(), + PinnedHTTPSHandler( + endpoints=endpoints, + hostname=parsed.hostname, + port=port, + context=tls_context, + ), + ) + destination = ( + Path(os.environ["RUNNER_TEMP"]) + / "stable-maintenance-input" + / "bundle.zip" + ) + digest = hashlib.sha256() + total = 0 + try: + with opener.open(request, timeout=60) as response, destination.open( + "wb" + ) as output: + if response.status != 200 or response.geturl() != url: + raise OSError( + "protected bundle fetch was not one exact HTTPS response" + ) + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > 2 * 1024 * 1024 * 1024: + raise OSError("protected bundle exceeds the two-GiB bound") + digest.update(chunk) + output.write(chunk) + except (OSError, urllib.error.URLError) as exc: + destination.unlink(missing_ok=True) + raise SystemExit("protected bundle fetch failed") from exc + if total == 0 or "sha256:" + digest.hexdigest() != expected: + destination.unlink(missing_ok=True) + raise SystemExit( + "protected bundle digest does not match the reviewed dispatch" + ) + + + if __name__ == "__main__": + main() + PY + + - name: Extract safely and validate the exact phase layout + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_PHASE: ${{ inputs.phase }} + run: | + set -euo pipefail + PYTHONPATH=tools/release-certification python3 - <<'PY' + import hashlib + import json + import os + import re + import shutil + import stat + import zipfile + from pathlib import Path, PurePosixPath + + from cryptad_certification.schema_validation import validate_schema + from cryptad_certification.engines.stable_1_0_maintenance_core import ( + _maintenance_public_redaction_findings, + ) + from cryptad_certification.engines.stable_1_0_rc_core import placeholder_findings + + archive = Path(os.environ["RUNNER_TEMP"]) / "stable-maintenance-input" / "bundle.zip" + output = Path("build/stable-maintenance-protected-input-producer") + shutil.rmtree(output, ignore_errors=True) + output.mkdir(parents=True) + seen = set() + total = 0 + with zipfile.ZipFile(archive) as bundle: + members = bundle.infolist() + if not members or len(members) > 10_000: + raise SystemExit("protected bundle has an invalid member count") + for member in members: + raw = member.filename + path = PurePosixPath(raw) + parts = path.parts + mode = member.external_attr >> 16 + if ( + "\\" in raw + or path.is_absolute() + or not parts + or any(part in {"", ".", ".."} for part in parts) + or re.match(r"^[A-Za-z]:", parts[0]) + or raw in seen + or member.flag_bits & 0x1 + or (mode and stat.S_ISLNK(mode)) + or ( + mode + and not member.is_dir() + and not stat.S_ISREG(mode) + ) + or member.file_size > 512 * 1024 * 1024 + ): + raise SystemExit("protected bundle contains an unsafe member") + seen.add(raw) + total += member.file_size + if total > 2 * 1024 * 1024 * 1024: + raise SystemExit("protected bundle expands beyond the two-GiB bound") + target = output.joinpath(*parts) + if member.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with bundle.open(member) as source, target.open("xb") as destination: + shutil.copyfileobj(source, destination, length=1024 * 1024) + target.chmod(0o644) + + def load_json(path): + raw = path.read_bytes() + + def reject_duplicates(pairs): + value = {} + for key, item in pairs: + if key in value: + raise SystemExit(f"duplicate JSON key in {path.name}") + value[key] = item + return value + value = json.loads(raw.decode("utf-8"), object_pairs_hook=reject_duplicates) + canonical = ( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + if raw != canonical: + raise SystemExit(f"noncanonical JSON bytes in {path.name}") + return value + + files = sorted(path for path in output.rglob("*") if path.is_file()) + if not files or any(path.is_symlink() for path in output.rglob("*")): + raise SystemExit("protected input output is missing or unsafe") + unsafe = re.compile( + rb"(?i)(BEGIN [A-Z ]*PRIVATE KEY|authorization\s*:\s*bearer|" + rb"private[ _-]*insert[ _-]*uri|cookie\s*:|/home/runner/|/Users/runner/|" + rb"[A-Za-z]:\\(?:Users|Windows)\\|REPLACE_ME|\.invalid(?:/|\b))" + ) + for path in files: + if unsafe.search(path.read_bytes()): + raise SystemExit("protected input contains a secret, placeholder, or runner path") + if path.suffix.lower() == ".json": + value = load_json(path) + if _maintenance_public_redaction_findings(value) or placeholder_findings(value): + raise SystemExit("protected JSON input failed the maintenance public scan") + + phase = os.environ["INPUT_PHASE"] + if phase == "validate-authorization": + expected = output / "stable-1.0-maintenance-authorization.json" + if files != [expected]: + raise SystemExit("authorization phase must contain exactly one canonical JSON") + value = load_json(expected) + errors = validate_schema(value, "stable-1.0-maintenance-authorization-v1.schema.json") + if errors: + raise SystemExit("authorization input failed its closed schema") + expected_role = ( + "stable-maintenance-release-manager" + if os.environ["INPUT_RELEASE_CLASS"] == "maintenance" + else "stable-security-release-manager" + ) + if ( + value.get("releaseId") != os.environ["INPUT_RELEASE_ID"] + or value.get("buildVersion") != os.environ["INPUT_BUILD_VERSION"] + or value.get("releaseClass") != os.environ["INPUT_RELEASE_CLASS"] + or value.get("role") != expected_role + ): + raise SystemExit("authorization identity does not match the dispatch") + else: + manifest_path = output / "stable-1.0-maintenance.json" + protected = output / "protected-inputs" + if not manifest_path.is_file() or not protected.is_dir(): + raise SystemExit("phase bundle lacks the canonical manifest/input layout") + manifest = load_json(manifest_path) + expected_branch = ( + "release/" if os.environ["INPUT_RELEASE_CLASS"] == "maintenance" else "hotfix/" + ) + os.environ["INPUT_BUILD_VERSION"] + expected_mode = "prepare-authorization" if phase == "prepare-authorization" else "validate-only" + if ( + manifest.get("release", {}).get("id") != os.environ["INPUT_RELEASE_ID"] + or manifest.get("release", {}).get("version") != os.environ["INPUT_BUILD_VERSION"] + or manifest.get("release", {}).get("profile") != "stable-review" + or manifest.get("policies", {}).get("releaseClass") != os.environ["INPUT_RELEASE_CLASS"] + or manifest.get("policies", {}).get("candidateSourceBranch") != expected_branch + or manifest.get("policies", {}).get("candidateSourceCommit") != os.environ["INPUT_CANDIDATE_COMMIT"] + or manifest.get("policies", {}).get("candidateSourceRef") != "commit:" + os.environ["INPUT_CANDIDATE_COMMIT"] + or manifest.get("commands", {}).get("stable-maintenance", {}).get("mode") != expected_mode + or manifest.get("execution", {}).get("allowDirtyWorkspace") is not False + or manifest.get("execution", {}).get("fixtureEvidence") is not False + or manifest.get("execution", {}).get("allowTestSigningInProduction") is not False + ): + raise SystemExit("protected manifest identity or side-effect-free mode is invalid") + inputs = manifest.get("inputs", {}) + def staged_input(key): + value = inputs.get(key) + if not isinstance(value, str): + return None + canonical_policy = ( + "tools/release-certification/stable-1.0-maintenance-policy.json" + ) + if key == "maintenancePolicy": + if value != canonical_policy: + raise SystemExit( + "maintenancePolicy is not the exact checked-in policy path" + ) + checkout = Path.cwd().resolve(strict=True) + expected = Path(canonical_policy).absolute() + try: + resolved = expected.resolve(strict=True) + except OSError: + raise SystemExit( + "maintenancePolicy is missing from the authenticated checkout" + ) from None + try: + relative = resolved.relative_to(checkout) + except ValueError: + raise SystemExit( + "maintenancePolicy is unsafe in the authenticated checkout" + ) from None + if ( + relative.as_posix() != canonical_policy + or expected.is_symlink() + or not resolved.is_file() + ): + raise SystemExit( + "maintenancePolicy is unsafe in the authenticated checkout" + ) + return resolved + prefix = "build/protected-inputs/" + if not value.startswith(prefix): + raise SystemExit(f"{key} does not use the canonical protected-input root") + relative = PurePosixPath(value.removeprefix(prefix)) + if any(part in {"", ".", ".."} for part in relative.parts): + raise SystemExit(f"{key} has an unsafe protected-input path") + return output.joinpath("protected-inputs", *relative.parts) + if phase == "prepare-authorization" and "stableMaintenanceAuthorization" in inputs: + raise SystemExit("authorization preparation cannot contain an authorization") + allowed_files = {manifest_path} + allowed_directories = [] + for key in sorted(inputs): + candidate = staged_input(key) + if candidate is None or not candidate.exists(): + continue + if candidate.is_dir(): + allowed_directories.append(candidate) + elif candidate.is_file(): + allowed_files.add(candidate) + else: + raise SystemExit(f"{key} is not a safe regular input") + for candidate in files: + if candidate in allowed_files: + continue + if any(directory in candidate.parents for directory in allowed_directories): + continue + raise SystemExit("phase bundle contains an unreferenced input") + if phase == "freeze-candidate": + forbidden = { + "maintenanceEvidence", "stableMaintenanceAuthorization", + "stableMaintenancePublicationReceipt", "coreUpdatePublicationReceipt", + } + for key in forbidden: + path = staged_input(key) if key in inputs else None + if path is not None and path.exists(): + raise SystemExit(f"freeze phase supplied forbidden material: {key}") + assets = staged_input("maintenanceCandidateAssets") + if assets is None: + raise SystemExit("freeze phase lacks a candidate asset path") + if not assets.is_dir() or assets.is_symlink(): + raise SystemExit("freeze phase lacks its catalog/signature candidate asset directory") + candidate_path = staged_input("maintenanceCandidate") + if candidate_path is None or not candidate_path.is_file(): + raise SystemExit("freeze phase lacks its maintenance candidate declaration") + candidate_value = load_json(candidate_path) + catalog = candidate_value.get("stableCatalog", {}) + expected_assets = { + catalog.get("fileName"): ( + catalog.get("digest"), catalog.get("sizeBytes") + ), + catalog.get("signatureFileName"): ( + catalog.get("signatureDigest"), + catalog.get("signatureSizeBytes"), + ), + } + asset_entries = list(assets.iterdir()) + if ( + len(expected_assets) != 2 + or {path.name for path in asset_entries} != set(expected_assets) + or any(path.is_symlink() or not path.is_file() for path in asset_entries) + ): + raise SystemExit( + "freeze phase asset directory is not the exact catalog/signature pair" + ) + for path in asset_entries: + expected_digest, expected_size = expected_assets[path.name] + actual_digest = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + if actual_digest != expected_digest or path.stat().st_size != expected_size: + raise SystemExit( + "freeze phase catalog/signature bytes do not match the candidate" + ) + PY + + - name: Recheck immutable checkout after intake validation + env: + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + run: | + set -euo pipefail + test "$(git rev-parse 'HEAD^{commit}')" = "$INPUT_CANDIDATE_COMMIT" + git diff-index --quiet --cached HEAD -- + git diff-files --quiet -- + + - name: Attest exact phase subject + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: >- + build/stable-maintenance-protected-input-producer/${{ + inputs.phase == 'validate-authorization' + && 'stable-1.0-maintenance-authorization.json' + || 'stable-1.0-maintenance.json' + }} + + - name: Upload exact protected input artifact + id: upload + uses: actions/upload-artifact@v6 + with: + name: stable-1-0-maintenance-input-${{ inputs.phase }}-${{ inputs.release_id }}-${{ inputs.build_version }}-${{ github.run_id }}-${{ github.run_attempt }} + path: build/stable-maintenance-protected-input-producer/ + if-no-files-found: error + include-hidden-files: false + retention-days: 30 diff --git a/.github/workflows/stable-1.0-maintenance-publication-backend-producer.yml b/.github/workflows/stable-1.0-maintenance-publication-backend-producer.yml new file mode 100644 index 00000000000..749829c338c --- /dev/null +++ b/.github/workflows/stable-1.0-maintenance-publication-backend-producer.yml @@ -0,0 +1,163 @@ +name: Stable 1.0 maintenance publication backend producer + +on: + workflow_dispatch: + inputs: + source_commit: + description: Exact reviewed main-branch commit containing the provider source + required: true + type: string + +concurrency: + group: stable-1.0-maintenance-publication-backend-${{ inputs.source_commit }} + cancel-in-progress: false + +permissions: {} + +jobs: + build-and-attest: + name: Build and attest deterministic provider wheel + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: stable-1.0-maintenance-evidence + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + + steps: + - name: Validate reviewed source identity + env: + INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ ! "$INPUT_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Provider source commit must be an exact lowercase Git commit." + exit 1 + fi + main_commit="$(gh api --method GET \ + "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" + if [[ "$main_commit" != "$INPUT_SOURCE_COMMIT" ]]; then + echo "::error::Provider source commit is not the current reviewed main commit." + exit 1 + fi + + - name: Check out exact reviewed provider source + uses: actions/checkout@v6 + with: + ref: ${{ inputs.source_commit }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Build and independently compare deterministic wheels + env: + INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }} + run: | + set -euo pipefail + if [[ "$GITHUB_SHA" != "$INPUT_SOURCE_COMMIT" \ + || "$(git rev-parse HEAD)" != "$INPUT_SOURCE_COMMIT" \ + || -n "$(git status --porcelain --untracked-files=no)" ]]; then + echo "::error::Provider checkout is not the exact clean reviewed commit." + exit 1 + fi + builder="tools/release-certification/publication-backend/build_wheel.py" + python3 "$builder" --out build/publication-backend-first + python3 "$builder" --out build/publication-backend-second + first_rows=() + second_rows=() + while IFS= read -r path; do first_rows+=("$path"); done < <( + find build/publication-backend-first -maxdepth 1 -type f -name '*.whl' -print | LC_ALL=C sort + ) + while IFS= read -r path; do second_rows+=("$path"); done < <( + find build/publication-backend-second -maxdepth 1 -type f -name '*.whl' -print | LC_ALL=C sort + ) + if [[ "${#first_rows[@]}" -ne 1 || "${#second_rows[@]}" -ne 1 ]]; then + echo "::error::Each provider build must emit exactly one wheel." + exit 1 + fi + first="${first_rows[0]}" + second="${second_rows[0]}" + if ! cmp --silent "$first" "$second"; then + echo "::error::Publication-provider wheel build was not deterministic." + exit 1 + fi + mkdir -p build/stable-maintenance-publication-backend + cp "$first" build/stable-maintenance-publication-backend/ + python3 -I -S - build/stable-maintenance-publication-backend/*.whl <<'PY' + import importlib + import os + import stat + import sys + import zipfile + from pathlib import PurePosixPath + + wheel = sys.argv[1] + with zipfile.ZipFile(wheel) as archive: + names = [item.filename for item in archive.infolist()] + if names != sorted(names) or len(names) != len(set(names)): + raise SystemExit("wheel members are not uniquely sorted") + for item in archive.infolist(): + path = PurePosixPath(item.filename) + mode = item.external_attr >> 16 + if path.is_absolute() or ".." in path.parts or "\\" in item.filename: + raise SystemExit("wheel member path is unsafe") + if stat.S_IFMT(mode) != stat.S_IFREG or stat.S_IMODE(mode) != 0o644: + raise SystemExit("wheel member mode is not canonical") + sys.path.insert(0, wheel) + module = importlib.import_module("cryptad_stable_maintenance_backend") + if not callable(getattr(module, "factory", None)): + raise SystemExit("canonical provider factory is missing") + os.environ["GITHUB_REPOSITORY"] = "crypta-network/cryptad" + os.environ["GITHUB_TOKEN"] = "producer-test-token" + backend = module.factory() + required = ( + "observe_public_state", + "publish_target", + "verify_publication", + "observe_latest_pointer", + "activate_latest", + ) + if any(not callable(getattr(backend, name, None)) for name in required): + raise SystemExit("canonical provider protocol is incomplete") + PY + + - name: Attest exact provider wheel + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: build/stable-maintenance-publication-backend/*.whl + + - name: Upload exact provider wheel + id: upload + uses: actions/upload-artifact@v6 + with: + name: stable-1.0-maintenance-publication-backend + path: build/stable-maintenance-publication-backend/*.whl + if-no-files-found: error + include-hidden-files: false + retention-days: 90 + + - name: Record public-safe provider identity + env: + INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }} + ARTIFACT_DIGEST: ${{ steps.upload.outputs.artifact-digest }} + run: | + set -euo pipefail + wheel="$(find build/stable-maintenance-publication-backend -maxdepth 1 -type f -name '*.whl' -print)" + wheel_digest="sha256:$(sha256sum "$wheel" | cut -d' ' -f1)" + { + echo '### Stable maintenance publication backend' + echo + echo "- source commit: \`$INPUT_SOURCE_COMMIT\`" + echo "- artifact: \`stable-1.0-maintenance-publication-backend\`" + echo "- artifact digest: \`$ARTIFACT_DIGEST\`" + echo "- wheel digest: \`$wheel_digest\`" + echo '- entrypoint: `cryptad_stable_maintenance_backend:factory`' + echo '- signer workflow: `crypta-network/cryptad/.github/workflows/stable-1.0-maintenance-publication-backend-producer.yml`' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/stable-1.0-maintenance-release.yml b/.github/workflows/stable-1.0-maintenance-release.yml new file mode 100644 index 00000000000..33cd1ef5fc6 --- /dev/null +++ b/.github/workflows/stable-1.0-maintenance-release.yml @@ -0,0 +1,3248 @@ +name: Stable 1.0 Maintenance Release +run-name: >- + Stable 1.0 ${{ inputs.release_class }} ${{ inputs.release_id }} build ${{ inputs.build_version }} (${{ inputs.operation }}) + +on: + workflow_dispatch: + inputs: + release_id: + description: Path-safe Stable 1.0 maintenance release ID. + required: true + type: string + build_version: + description: Positive integer Cryptad build; the protected tag is v. + required: true + type: string + release_class: + description: Closed Stable 1.0 release class. + required: true + type: choice + options: + - maintenance + - security-hotfix + candidate_commit: + description: Exact lowercase 40-character source commit to build and freeze. + required: true + type: string + expected_predecessor_pointer_digest: + description: SHA-256 of the latest-published pointer observed when authorization was prepared. + required: true + type: string + protected_inputs_run_id: + description: Protected producer run containing phase-appropriate public-safe inputs; forbidden for publish. + required: false + default: '' + type: string + protected_inputs_artifact_name: + description: Exact protected input artifact name. + required: false + default: '' + type: string + protected_inputs_artifact_digest: + description: Exact sha256: GitHub Actions artifact digest. + required: false + default: '' + type: string + operation: + description: Closed release operation; candidate production, validation/authorization, and publication are separate runs. + required: true + type: choice + options: + - freeze-candidate + - prepare-authorization + - validate-authorization + - publish + frozen_candidate_run_id: + description: Prior freeze-candidate run containing exact built-once bytes; required only for prepare-authorization. + required: false + default: '' + type: string + frozen_candidate_artifact_name: + description: Exact attested frozen-candidate artifact name; required only for prepare-authorization. + required: false + default: '' + type: string + frozen_candidate_artifact_digest: + description: Exact frozen-candidate artifact sha256:; required only for prepare-authorization. + required: false + default: '' + type: string + validated_candidate_run_id: + description: Prior validate-authorization run containing the attested authorized candidate; required only for publish. + required: false + default: '' + type: string + validated_candidate_artifact_name: + description: Prior attested authorized-candidate artifact name; required only for publish. + required: false + default: '' + type: string + validated_candidate_artifact_digest: + description: Prior attested authorized-candidate artifact sha256:; required only for publish. + required: false + default: '' + type: string + prepared_candidate_run_id: + description: Prior prepare-authorization run; required only for validate-authorization. + required: false + default: '' + type: string + prepared_candidate_artifact_name: + description: Exact attested prepared-candidate artifact name; required only for validate-authorization. + required: false + default: '' + type: string + prepared_candidate_artifact_digest: + description: Exact prepared-candidate artifact sha256:; required only for validate-authorization. + required: false + default: '' + type: string + windows_exe_run_id: + description: Authenticated external Windows EXE producer run; required only for freeze-candidate. + required: false + default: '' + type: string + windows_exe_artifact_name: + description: Exact external Windows EXE artifact name; required only for freeze-candidate. + required: false + default: '' + type: string + windows_exe_artifact_digest: + description: External Windows EXE Actions artifact sha256:; required only for freeze-candidate. + required: false + default: '' + type: string + windows_exe_sha256: + description: Exact sha256: digest of the externally produced Windows EXE. + required: false + default: '' + type: string + publication_backend_run_id: + description: Protected producer run containing the reviewed publication-provider wheel; required only for publish. + required: false + default: '' + type: string + publication_backend_artifact_name: + description: Exact publication-provider artifact name; required only for publish. + required: false + default: '' + type: string + publication_backend_artifact_digest: + description: Exact publication-provider artifact sha256:; required only for publish. + required: false + default: '' + type: string + +# A global lock is intentional: different integer builds must not race while both claim the +# latest Stable 1.0 predecessor or wait for protected-environment approval. +concurrency: + group: stable-1-0-maintenance-publication + cancel-in-progress: false + +permissions: {} + +jobs: + validate-dispatch: + name: Validate immutable release identity + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + actions: read + outputs: + expected_branch: ${{ steps.identity.outputs.expected_branch }} + + steps: + - name: Check out the exact candidate commit + uses: actions/checkout@v6 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5 + + - name: Install Gradle properties + uses: ./.github/actions/setup-gradle-properties + + - name: Validate integer build, class, branch, commit, and clean source + id: identity + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_POINTER_DIGEST: ${{ inputs.expected_predecessor_pointer_digest }} + INPUT_PROTECTED_RUN_ID: ${{ inputs.protected_inputs_run_id }} + INPUT_PROTECTED_ARTIFACT_NAME: ${{ inputs.protected_inputs_artifact_name }} + INPUT_PROTECTED_ARTIFACT_DIGEST: ${{ inputs.protected_inputs_artifact_digest }} + INPUT_VALIDATED_RUN_ID: ${{ inputs.validated_candidate_run_id }} + INPUT_VALIDATED_ARTIFACT_NAME: ${{ inputs.validated_candidate_artifact_name }} + INPUT_VALIDATED_ARTIFACT_DIGEST: ${{ inputs.validated_candidate_artifact_digest }} + INPUT_FROZEN_RUN_ID: ${{ inputs.frozen_candidate_run_id }} + INPUT_FROZEN_ARTIFACT_NAME: ${{ inputs.frozen_candidate_artifact_name }} + INPUT_FROZEN_ARTIFACT_DIGEST: ${{ inputs.frozen_candidate_artifact_digest }} + INPUT_PREPARED_RUN_ID: ${{ inputs.prepared_candidate_run_id }} + INPUT_PREPARED_ARTIFACT_NAME: ${{ inputs.prepared_candidate_artifact_name }} + INPUT_PREPARED_ARTIFACT_DIGEST: ${{ inputs.prepared_candidate_artifact_digest }} + INPUT_WINDOWS_RUN_ID: ${{ inputs.windows_exe_run_id }} + INPUT_WINDOWS_ARTIFACT_NAME: ${{ inputs.windows_exe_artifact_name }} + INPUT_WINDOWS_ARTIFACT_DIGEST: ${{ inputs.windows_exe_artifact_digest }} + INPUT_WINDOWS_EXE_SHA256: ${{ inputs.windows_exe_sha256 }} + INPUT_OPERATION: ${{ inputs.operation }} + INPUT_BACKEND_RUN_ID: ${{ inputs.publication_backend_run_id }} + INPUT_BACKEND_ARTIFACT_NAME: ${{ inputs.publication_backend_artifact_name }} + INPUT_BACKEND_ARTIFACT_DIGEST: ${{ inputs.publication_backend_artifact_digest }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ ! "$INPUT_RELEASE_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "::error::release_id is not path-safe." + exit 1 + fi + if [[ ! "$INPUT_BUILD_VERSION" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::build_version must be one canonical positive integer." + exit 1 + fi + if [[ ! "$INPUT_CANDIDATE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::candidate_commit must be an exact lowercase commit." + exit 1 + fi + if [[ ! "$INPUT_POINTER_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::The authorized predecessor pointer digest is malformed." + exit 1 + fi + if [[ "$INPUT_RELEASE_CLASS" == maintenance ]]; then + expected_branch="release/$INPUT_BUILD_VERSION" + elif [[ "$INPUT_RELEASE_CLASS" == security-hotfix ]]; then + expected_branch="hotfix/$INPUT_BUILD_VERSION" + else + echo "::error::Unsupported release class." + exit 1 + fi + if [[ "$GITHUB_REF" != "refs/heads/$expected_branch" ]]; then + echo "::error::Dispatch must run from the exact $expected_branch branch." + exit 1 + fi + if [[ "$(git rev-parse HEAD)" != "$INPUT_CANDIDATE_COMMIT" ]]; then + echo "::error::Checked-out source differs from candidate_commit." + exit 1 + fi + encoded_branch="${expected_branch//\//%2F}" + branch_commit="$(gh api --method GET \ + "repos/$GITHUB_REPOSITORY/git/ref/heads/$encoded_branch" --jq '.object.sha')" + if [[ "$branch_commit" != "$INPUT_CANDIDATE_COMMIT" ]]; then + echo "::error::The release/hotfix branch moved or does not identify the candidate." + exit 1 + fi + if [[ -n "$(git status --porcelain=v1 --untracked-files=all)" ]]; then + echo "::error::Stable maintenance execution requires a clean source tree." + exit 1 + fi + project_version="$(./gradlew -q printVersion)" + if [[ "$project_version" != "$INPUT_BUILD_VERSION" ]]; then + echo "::error::build.gradle.kts integer version differs from the dispatch." + exit 1 + fi + if [[ "$INPUT_OPERATION" == publish ]]; then + if [[ -n "$INPUT_PROTECTED_RUN_ID$INPUT_PROTECTED_ARTIFACT_NAME$INPUT_PROTECTED_ARTIFACT_DIGEST" ]]; then + echo "::error::publish consumes only the authorized artifact and forbids replacement inputs." + exit 1 + fi + elif [[ ! "$INPUT_PROTECTED_RUN_ID" =~ ^[1-9][0-9]*$ \ + || ! "$INPUT_PROTECTED_ARTIFACT_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ \ + || ! "$INPUT_PROTECTED_ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::This operation requires an exact protected input producer identity." + exit 1 + fi + if [[ "$INPUT_OPERATION" == publish ]]; then + if [[ ! "$INPUT_VALIDATED_RUN_ID" =~ ^[1-9][0-9]*$ \ + || ! "$INPUT_VALIDATED_ARTIFACT_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ \ + || ! "$INPUT_VALIDATED_ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::publish requires an exact prior validated-and-authorized artifact." + exit 1 + fi + if [[ ! "$INPUT_BACKEND_RUN_ID" =~ ^[1-9][0-9]*$ \ + || ! "$INPUT_BACKEND_ARTIFACT_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ \ + || ! "$INPUT_BACKEND_ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::publish requires the exact reviewed publication-provider artifact." + exit 1 + fi + if [[ -n "$INPUT_FROZEN_RUN_ID$INPUT_FROZEN_ARTIFACT_NAME$INPUT_FROZEN_ARTIFACT_DIGEST$INPUT_PREPARED_RUN_ID$INPUT_PREPARED_ARTIFACT_NAME$INPUT_PREPARED_ARTIFACT_DIGEST$INPUT_WINDOWS_RUN_ID$INPUT_WINDOWS_ARTIFACT_NAME$INPUT_WINDOWS_ARTIFACT_DIGEST$INPUT_WINDOWS_EXE_SHA256" ]]; then + echo "::error::publish reuses validated frozen bytes and forbids new package inputs." + exit 1 + fi + elif [[ "$INPUT_OPERATION" == prepare-authorization ]]; then + if [[ ! "$INPUT_FROZEN_RUN_ID" =~ ^[1-9][0-9]*$ \ + || ! "$INPUT_FROZEN_ARTIFACT_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ \ + || ! "$INPUT_FROZEN_ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::prepare-authorization requires an exact prior frozen-candidate artifact." + exit 1 + fi + if [[ -n "$INPUT_PREPARED_RUN_ID$INPUT_PREPARED_ARTIFACT_NAME$INPUT_PREPARED_ARTIFACT_DIGEST$INPUT_VALIDATED_RUN_ID$INPUT_VALIDATED_ARTIFACT_NAME$INPUT_VALIDATED_ARTIFACT_DIGEST$INPUT_WINDOWS_RUN_ID$INPUT_WINDOWS_ARTIFACT_NAME$INPUT_WINDOWS_ARTIFACT_DIGEST$INPUT_WINDOWS_EXE_SHA256$INPUT_BACKEND_RUN_ID$INPUT_BACKEND_ARTIFACT_NAME$INPUT_BACKEND_ARTIFACT_DIGEST" ]]; then + echo "::error::prepare-authorization consumes frozen bytes and forbids other phase inputs." + exit 1 + fi + elif [[ "$INPUT_OPERATION" == validate-authorization ]]; then + if [[ ! "$INPUT_PREPARED_RUN_ID" =~ ^[1-9][0-9]*$ \ + || ! "$INPUT_PREPARED_ARTIFACT_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ \ + || ! "$INPUT_PREPARED_ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::validate-authorization requires an exact prepared artifact." + exit 1 + fi + if [[ -n "$INPUT_FROZEN_RUN_ID$INPUT_FROZEN_ARTIFACT_NAME$INPUT_FROZEN_ARTIFACT_DIGEST$INPUT_VALIDATED_RUN_ID$INPUT_VALIDATED_ARTIFACT_NAME$INPUT_VALIDATED_ARTIFACT_DIGEST$INPUT_WINDOWS_RUN_ID$INPUT_WINDOWS_ARTIFACT_NAME$INPUT_WINDOWS_ARTIFACT_DIGEST$INPUT_WINDOWS_EXE_SHA256$INPUT_BACKEND_RUN_ID$INPUT_BACKEND_ARTIFACT_NAME$INPUT_BACKEND_ARTIFACT_DIGEST" ]]; then + echo "::error::validate-authorization accepts only the prepared artifact and exact approval input." + exit 1 + fi + elif [[ "$INPUT_OPERATION" == freeze-candidate ]]; then + if [[ ! "$INPUT_WINDOWS_RUN_ID" =~ ^[1-9][0-9]*$ \ + || ! "$INPUT_WINDOWS_ARTIFACT_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ \ + || ! "$INPUT_WINDOWS_ARTIFACT_DIGEST" =~ ^sha256:[0-9a-f]{64}$ \ + || ! "$INPUT_WINDOWS_EXE_SHA256" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::freeze-candidate requires an authenticated external Windows EXE." + exit 1 + fi + if [[ -n "$INPUT_FROZEN_RUN_ID$INPUT_FROZEN_ARTIFACT_NAME$INPUT_FROZEN_ARTIFACT_DIGEST$INPUT_PREPARED_RUN_ID$INPUT_PREPARED_ARTIFACT_NAME$INPUT_PREPARED_ARTIFACT_DIGEST$INPUT_VALIDATED_RUN_ID$INPUT_VALIDATED_ARTIFACT_NAME$INPUT_VALIDATED_ARTIFACT_DIGEST$INPUT_BACKEND_RUN_ID$INPUT_BACKEND_ARTIFACT_NAME$INPUT_BACKEND_ARTIFACT_DIGEST" ]]; then + echo "::error::freeze-candidate builds once and forbids prior candidate/publication inputs." + exit 1 + fi + else + echo "::error::Unsupported maintenance operation." + exit 1 + fi + echo "expected_branch=$expected_branch" >> "$GITHUB_OUTPUT" + + - name: Run deterministic Stable maintenance self-test + run: python3 tools/release-certification/certify.py stable-maintenance --self-test + + build-packages: + name: Build exact ${{ matrix.package_class }} candidate bytes + if: inputs.operation == 'freeze-candidate' + needs: validate-dispatch + runs-on: ${{ matrix.runner }} + environment: stable-1.0-maintenance-evidence + timeout-minutes: 360 + permissions: + contents: read + actions: read + attestations: write + artifact-metadata: write + id-token: write + strategy: + fail-fast: true + matrix: + include: + - package_class: linux-and-portable + runner: ubuntu-latest + gradle_tasks: >- + assembleCryptadDist distTarCryptad distZipCryptad distJlinkCryptad + jpackageInstallerLinuxAll + - package_class: macos + runner: macos-15-intel + gradle_tasks: jpackageInstallerCryptad + + steps: + - name: Check out exact candidate + uses: actions/checkout@v6 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5 + + - name: Install Gradle properties + uses: ./.github/actions/setup-gradle-properties + + - name: Install Linux RPM packaging tools + if: matrix.package_class == 'linux-and-portable' + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install --yes rpm + command -v rpm >/dev/null + command -v rpmbuild >/dev/null + + - name: Build package set once + if: matrix.package_class == 'linux-and-portable' + env: + GRADLE_TASKS: ${{ matrix.gradle_tasks }} + run: ./gradlew $GRADLE_TASKS + + - name: Build, Developer ID sign, notarize, staple, and verify macOS package once + if: matrix.package_class == 'macos' + env: + GRADLE_TASKS: ${{ matrix.gradle_tasks }} + MACOS_DEVELOPER_ID_APPLICATION: ${{ vars.CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION }} + MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION_P12_PASSWORD }} + MACOS_NOTARY_APPLE_ID: ${{ secrets.CRYPTAD_MACOS_NOTARY_APPLE_ID }} + MACOS_NOTARY_TEAM_ID: ${{ secrets.CRYPTAD_MACOS_NOTARY_TEAM_ID }} + MACOS_NOTARY_APP_PASSWORD: ${{ secrets.CRYPTAD_MACOS_NOTARY_APP_PASSWORD }} + run: | + set -euo pipefail + if [[ ! "$MACOS_DEVELOPER_ID_APPLICATION" =~ ^Developer\ ID\ Application:\ .+\ \([A-Z0-9]{10}\)$ \ + || -z "$MACOS_CERTIFICATE_P12_BASE64" \ + || -z "$MACOS_CERTIFICATE_PASSWORD" \ + || -z "$MACOS_NOTARY_APPLE_ID" \ + || ! "$MACOS_NOTARY_TEAM_ID" =~ ^[A-Z0-9]{10}$ \ + || -z "$MACOS_NOTARY_APP_PASSWORD" ]]; then + echo "::error::Protected Developer ID and notarization inputs are incomplete." + exit 1 + fi + keychain="$RUNNER_TEMP/cryptad-maintenance-signing.keychain-db" + certificate="$RUNNER_TEMP/cryptad-maintenance-signing.p12" + notary_output="$RUNNER_TEMP/cryptad-maintenance-notary.json" + notary_error="$RUNNER_TEMP/cryptad-maintenance-notary.err" + mount_point="$RUNNER_TEMP/cryptad-maintenance-dmg" + mounted_device='' + cleanup() { + if [[ -n "$mounted_device" ]]; then + hdiutil detach "$mounted_device" -force >/dev/null 2>&1 || true + fi + rmdir "$mount_point" >/dev/null 2>&1 || true + security delete-keychain "$keychain" >/dev/null 2>&1 || true + rm -f "$certificate" "$notary_output" "$notary_error" + } + trap cleanup EXIT + keychain_password="$(openssl rand -hex 32)" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + printf '%s' "$MACOS_CERTIFICATE_P12_BASE64" | base64 -D > "$certificate" + security import "$certificate" -k "$keychain" -P "$MACOS_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/productbuild >/dev/null + security set-key-partition-list -S apple-tool:,apple: -s \ + -k "$keychain_password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" + if [[ "$(security find-identity -v -p codesigning "$keychain" \ + | grep -F -c -- "\"$MACOS_DEVELOPER_ID_APPLICATION\"")" -ne 1 ]]; then + echo "::error::The protected keychain does not contain exactly the reviewed Developer ID identity." + exit 1 + fi + xcrun notarytool store-credentials cryptad-maintenance \ + --apple-id "$MACOS_NOTARY_APPLE_ID" \ + --team-id "$MACOS_NOTARY_TEAM_ID" \ + --password "$MACOS_NOTARY_APP_PASSWORD" \ + --keychain "$keychain" >/dev/null + ./gradlew $GRADLE_TASKS \ + -PmacSigningKeyUserName="$MACOS_DEVELOPER_ID_APPLICATION" + required=() + while IFS= read -r path; do required+=("$path"); done < <( + find build/jpackage -maxdepth 1 -type f -name '*.dmg' -print | LC_ALL=C sort + ) + if [[ "${#required[@]}" -ne 1 ]]; then + echo "::error::The signed macOS producer must emit exactly one DMG." + exit 1 + fi + dmg="${required[0]}" + app="build/jpackage/Crypta.app" + codesign --verify --deep --strict --verbose=2 "$app" + codesign --force --timestamp --sign "$MACOS_DEVELOPER_ID_APPLICATION" \ + --keychain "$keychain" "$dmg" + codesign --verify --strict --verbose=2 "$dmg" + if ! xcrun notarytool submit "$dmg" \ + --keychain-profile cryptad-maintenance --keychain "$keychain" \ + --wait --output-format json >"$notary_output" 2>"$notary_error"; then + echo "::error::Apple notarization submission failed." + exit 1 + fi + if [[ "$(jq -er '.status' "$notary_output")" != Accepted ]]; then + echo "::error::Apple did not accept the DMG notarization submission." + exit 1 + fi + submission_id="$(jq -er '.id' "$notary_output")" + xcrun notarytool info "$submission_id" \ + --keychain-profile cryptad-maintenance --keychain "$keychain" \ + --output-format json >"$RUNNER_TEMP/cryptad-maintenance-notary-info.json" 2>"$notary_error" + if [[ "$(jq -er '.status' "$RUNNER_TEMP/cryptad-maintenance-notary-info.json")" != Accepted ]]; then + echo "::error::Apple notarization status could not be independently confirmed." + exit 1 + fi + rm -f "$RUNNER_TEMP/cryptad-maintenance-notary-info.json" + xcrun stapler staple "$dmg" >/dev/null + xcrun stapler validate "$dmg" >/dev/null + codesign --verify --deep --strict --verbose=2 "$app" + codesign --verify --strict --verbose=2 "$dmg" + spctl --assess --type execute --verbose=2 "$app" + spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg" + mkdir "$mount_point" + mounted_device="$(hdiutil attach -readonly -nobrowse -mountpoint "$mount_point" "$dmg" \ + | awk 'END {print $1}')" + if [[ ! "$mounted_device" =~ ^/dev/ ]]; then + echo "::error::The final stapled DMG could not be mounted read-only." + exit 1 + fi + contained_executables=() + while IFS= read -r path; do contained_executables+=("$path"); done < <( + find "$mount_point" -type f -path '*/Crypta.app/Contents/MacOS/Crypta' -print \ + | LC_ALL=C sort + ) + if [[ "${#contained_executables[@]}" -ne 1 ]]; then + echo "::error::The final stapled DMG does not contain exactly one Crypta executable." + exit 1 + fi + contained_executable="${contained_executables[0]}" + app_architectures="$(lipo -archs "$contained_executable")" + if [[ "$app_architectures" != x86_64 ]]; then + echo "::error::The final amd64 DMG contains '$app_architectures' application bytes." + exit 1 + fi + codesign --verify --deep --strict --verbose=2 \ + "$(dirname "$(dirname "$(dirname "$contained_executable")")")" + hdiutil detach "$mounted_device" >/dev/null + mounted_device='' + rmdir "$mount_point" + mkdir -p build/stable-maintenance-macos-verification + jq -n \ + --arg identity "$MACOS_DEVELOPER_ID_APPLICATION" \ + --arg team_id "$MACOS_NOTARY_TEAM_ID" \ + --arg producer_architecture amd64 \ + --arg file_name "$(basename "$dmg")" \ + --arg subject_digest "sha256:$(shasum -a 256 "$dmg" | awk '{print $1}')" \ + --argjson subject_size "$(stat -f '%z' "$dmg")" \ + '{schemaVersion: 1, kind: "stable-1.0-maintenance-macos-package-verification", + signingIdentity: $identity, teamId: $team_id, signingStatus: "pass", + producerArchitecture: $producer_architecture, + fileName: $file_name, subjectDigest: $subject_digest, + subjectSizeBytes: $subject_size, + notarizationStatus: "pass", staplingStatus: "pass", gatekeeperStatus: "pass", + redaction: {status: "pass", findingCount: 0, findings: []}}' \ + > build/stable-maintenance-macos-verification/verification.json + + - name: Reject tracked source or index drift after package build + env: + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + shell: bash + run: | + set -euo pipefail + if [[ "$(git rev-parse HEAD^{commit})" != "$INPUT_CANDIDATE_COMMIT" ]]; then + echo "::error::The package producer checkout moved away from the candidate commit." + exit 1 + fi + if ! git diff-index --quiet --cached HEAD -- \ + || ! git diff-files --quiet --; then + echo "::error::The package build changed tracked source or the Git index." + git status --short --untracked-files=no + exit 1 + fi + + - name: Stage exact package producer output + env: + PACKAGE_CLASS: ${{ matrix.package_class }} + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + EXPECTED_BRANCH: ${{ needs.validate-dispatch.outputs.expected_branch }} + GRADLE_TASKS: ${{ matrix.gradle_tasks }} + shell: bash + run: | + set -euo pipefail + output="build/stable-maintenance-package-output/$PACKAGE_CLASS" + rm -rf "$output" + mkdir -p "$output/assets" + required=() + if [[ "$PACKAGE_CLASS" == linux-and-portable ]]; then + while IFS= read -r path; do required+=("$path"); done < <( + find build/jpackage -maxdepth 1 -type f \ + \( -name '*.deb' -o -name '*.rpm' \) -print | LC_ALL=C sort + ) + if [[ "${#required[@]}" -ne 2 \ + || "$(find build/jpackage -maxdepth 1 -type f -name '*.deb' | wc -l)" -ne 1 \ + || "$(find build/jpackage -maxdepth 1 -type f -name '*.rpm' | wc -l)" -ne 1 ]]; then + echo "::error::The Linux producer must build exactly one DEB and one RPM." + exit 1 + fi + portable=() + while IFS= read -r path; do portable+=("$path"); done < <( + find build/distributions -maxdepth 1 -type f \ + \( -name "cryptad-v$INPUT_BUILD_VERSION.tar.gz" \ + -o -name "cryptad-v$INPUT_BUILD_VERSION.zip" \ + -o -name "cryptad-jlink-v$INPUT_BUILD_VERSION.tar.gz" \ + -o -name "cryptad-jlink-v$INPUT_BUILD_VERSION.zip" \) -print \ + | LC_ALL=C sort + ) + if [[ "${#portable[@]}" -ne 4 ]]; then + echo "::error::The portable producer did not emit the closed four-archive set." + exit 1 + fi + required+=("${portable[@]}") + else + while IFS= read -r path; do required+=("$path"); done < <( + find build/jpackage -maxdepth 1 -type f -name '*.dmg' -print | LC_ALL=C sort + ) + if [[ "${#required[@]}" -ne 1 ]]; then + echo "::error::The macOS producer must build exactly one DMG." + exit 1 + fi + verification="build/stable-maintenance-macos-verification/verification.json" + dmg="${required[0]}" + dmg_digest="sha256:$(shasum -a 256 "$dmg" | awk '{print $1}')" + dmg_size="$(stat -f '%z' "$dmg")" + if [[ -L "$verification" || ! -f "$verification" \ + || "$(jq -er '.signingStatus' "$verification")" != pass \ + || "$(jq -er '.notarizationStatus' "$verification")" != pass \ + || "$(jq -er '.staplingStatus' "$verification")" != pass \ + || "$(jq -er '.gatekeeperStatus' "$verification")" != pass \ + || "$(jq -er '.fileName' "$verification")" != "$(basename "$dmg")" \ + || "$(jq -er '.subjectDigest' "$verification")" != "$dmg_digest" \ + || "$(jq -er '.subjectSizeBytes' "$verification")" != "$dmg_size" ]]; then + echo "::error::The DMG lacks passing protected signing and notarization verification." + exit 1 + fi + cp "$verification" "$output/verification.json" + fi + asset_architectures='{}' + for source in "${required[@]}"; do + if [[ -L "$source" || ! -f "$source" ]]; then + echo "::error::Package producer output is unsafe." + exit 1 + fi + producer_architecture='' + case "$source" in + *.deb) + raw_architecture="$(dpkg-deb --field "$source" Architecture)" + case "$raw_architecture" in + amd64) producer_architecture=amd64 ;; + arm64) producer_architecture=arm64 ;; + *) + echo "::error::Unsupported DEB architecture '$raw_architecture'." + exit 1 + ;; + esac + ;; + *.rpm) + raw_architecture="$(rpm -qp --qf '%{ARCH}' "$source")" + case "$raw_architecture" in + x86_64|amd64) producer_architecture=amd64 ;; + aarch64|arm64) producer_architecture=arm64 ;; + *) + echo "::error::Unsupported RPM architecture '$raw_architecture'." + exit 1 + ;; + esac + ;; + *.dmg) + producer_architecture="$(jq -er '.producerArchitecture' \ + build/stable-maintenance-macos-verification/verification.json)" + ;; + esac + if [[ -n "$producer_architecture" ]]; then + asset_architectures="$(jq -cn \ + --argjson current "$asset_architectures" \ + --arg name "$(basename "$source")" \ + --arg architecture "$producer_architecture" \ + '$current + {($name): $architecture}')" + fi + cp "$source" "$output/assets/$(basename "$source")" + done + if [[ -n "$(find "$output" -type l -print -quit)" \ + || -n "$(find "$output" -mindepth 1 ! -type f ! -type d -print -quit)" ]]; then + echo "::error::Package output contains a link or special entry." + exit 1 + fi + python3 - "$output/assets" "$output/checksums.txt" <<'PY' + import hashlib + import sys + from pathlib import Path + + root = Path(sys.argv[1]) + destination = Path(sys.argv[2]) + rows = [] + for asset in sorted(root.iterdir(), key=lambda path: path.name): + if asset.is_symlink() or not asset.is_file(): + raise SystemExit("unsafe package producer output") + rows.append(f"{hashlib.sha256(asset.read_bytes()).hexdigest()} {asset.name}") + destination.write_text("\n".join(rows) + "\n", encoding="utf-8") + PY + source_tree_digest="sha256:$(git ls-tree -r --full-tree HEAD \ + | shasum -a 256 | awk '{print $1}')" + build_logic_digest="sha256:$(git ls-tree -r --full-tree HEAD -- build-logic \ + | shasum -a 256 | awk '{print $1}')" + wrapper_digest="sha256:$(shasum -a 256 gradle/wrapper/gradle-wrapper.jar | awk '{print $1}')" + verification_digest="sha256:$(shasum -a 256 gradle/verification-metadata.xml | awk '{print $1}')" + java_version="$(java --version 2>&1 | head -n 1 | tr -d '\r')" + java_major="$(python3 - "$java_version" <<'PY' + import re + import sys + + match = re.search(r'(? 0))')" + jq -n \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg package_class "$PACKAGE_CLASS" \ + --arg branch "$EXPECTED_BRANCH" \ + --arg source_ref "commit:$INPUT_CANDIDATE_COMMIT" \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg source_tree_digest "$source_tree_digest" \ + --arg java_version "$java_version" \ + --argjson java_major "$java_major" \ + --arg gradle_version "$gradle_version" \ + --arg wrapper_digest "$wrapper_digest" \ + --arg verification_digest "$verification_digest" \ + --arg build_logic_digest "$build_logic_digest" \ + --argjson build_tasks "$build_tasks_json" \ + --argjson asset_architectures "$asset_architectures" \ + --argjson run_id "$GITHUB_RUN_ID" \ + --argjson run_attempt "$GITHUB_RUN_ATTEMPT" \ + '{ + schemaVersion: 1, + kind: "stable-1.0-maintenance-package-producer", + releaseId: $release_id, + buildVersion: $build, + sourceCommit: $commit, + source: { + branch: $branch, ref: $source_ref, commit: $commit, + sourceTreeDigest: $source_tree_digest + }, + toolchain: { + javaVersion: $java_version, javaMajorVersion: $java_major, + gradleVersion: $gradle_version, gradleWrapperDigest: $wrapper_digest, + dependencyVerificationDigest: $verification_digest, + dependencyVerificationStatus: "pass", buildLogicDigest: $build_logic_digest, + buildTasks: $build_tasks, productionSigning: true, testSigning: false + }, + packageClass: $package_class, + assetArchitectures: $asset_architectures, + workflow: { + provider: "github-actions", repository: $repository, + runId: $run_id, runAttempt: $run_attempt + }, + buildCount: 1, + redaction: {status: "pass", findingCount: 0, findings: []} + }' > "$output/producer.json" + + - name: Cryptographically attest every exact package producer asset + id: attest-package-assets + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: build/stable-maintenance-package-output/${{ matrix.package_class }}/assets/* + + - name: Independently verify and bind every exact package producer attestation + env: + PACKAGE_CLASS: ${{ matrix.package_class }} + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + output="build/stable-maintenance-package-output/$PACKAGE_CLASS" + receipts="$output/signing-receipts" + signer_workflow="crypta-network/cryptad/.github/workflows/stable-1.0-maintenance-release.yml" + rm -rf "$receipts" + mkdir -p "$receipts" + receipt_digests='{}' + asset_count=0 + for asset in "$output"/assets/*; do + if [[ -L "$asset" || ! -f "$asset" ]]; then + echo "::error::The attested package producer subject is unsafe." + exit 1 + fi + gh attestation verify "$asset" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$signer_workflow" \ + --source-digest "$INPUT_CANDIDATE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + name="$(basename "$asset")" + subject_digest="sha256:$(shasum -a 256 "$asset" | awk '{print $1}')" + subject_size="$(wc -c < "$asset" | tr -d '[:space:]')" + receipt="$receipts/$name.signing.json" + jq -n \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg name "$name" \ + --arg digest "$subject_digest" \ + --argjson size "$subject_size" \ + --arg signer_workflow "$signer_workflow" \ + '{ + schemaVersion: 1, + kind: "stable-1.0-maintenance-asset-signing-verification", + releaseId: $release_id, + buildVersion: $build, + sourceCommit: $commit, + fileName: $name, + subjectDigest: $digest, + subjectSizeBytes: $size, + signingMethod: "sigstore-github-attestation", + signerWorkflow: $signer_workflow, + hostedRunnerRequired: true, + cryptographicVerificationStatus: "pass", + redaction: {status: "pass", findingCount: 0, findings: []} + }' > "$receipt" + receipt_digest="sha256:$(shasum -a 256 "$receipt" | awk '{print $1}')" + receipt_digests="$(jq -cn \ + --argjson current "$receipt_digests" \ + --arg name "$name" \ + --arg digest "$receipt_digest" \ + '$current + {($name): $digest}')" + asset_count=$((asset_count + 1)) + done + if [[ "$asset_count" -lt 1 ]]; then + echo "::error::The package producer emitted no attestation subjects." + exit 1 + fi + jq \ + --arg method sigstore-github-attestation \ + --arg signer_workflow "$signer_workflow" \ + --arg source_commit "$INPUT_CANDIDATE_COMMIT" \ + --argjson receipt_digests "$receipt_digests" \ + '.assetSigning = { + method: $method, + signerWorkflow: $signer_workflow, + sourceCommit: $source_commit, + verificationStatus: "pass", + receiptDigests: $receipt_digests + }' "$output/producer.json" > "$output/producer.json.tmp" + mv "$output/producer.json.tmp" "$output/producer.json" + + - name: Upload exact package bytes + uses: actions/upload-artifact@v6 + with: + name: stable-maintenance-package-${{ inputs.release_id }}-${{ inputs.build_version }}-${{ matrix.package_class }} + path: build/stable-maintenance-package-output/${{ matrix.package_class }}/ + if-no-files-found: error + retention-days: 30 + + authenticate-windows-exe: + name: Authenticate external Windows EXE producer + if: inputs.operation == 'freeze-candidate' + needs: validate-dispatch + runs-on: ubuntu-latest + environment: stable-1.0-maintenance-evidence + timeout-minutes: 30 + permissions: + contents: read + actions: read + attestations: read + + steps: + - name: Authenticate and materialize exact Windows EXE + env: + INPUT_RUN_ID: ${{ inputs.windows_exe_run_id }} + INPUT_ARTIFACT_NAME: ${{ inputs.windows_exe_artifact_name }} + INPUT_ARTIFACT_DIGEST: ${{ inputs.windows_exe_artifact_digest }} + INPUT_EXE_SHA256: ${{ inputs.windows_exe_sha256 }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + EXPECTED_BRANCH: ${{ needs.validate-dispatch.outputs.expected_branch }} + INPUT_SIGNER_WORKFLOW: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_WINDOWS_SIGNER_WORKFLOW }} + EXPECTED_WINDOWS_CERTIFICATE_SHA1: ${{ vars.CRYPTAD_WINDOWS_CODE_SIGNING_CERTIFICATE_SHA1 }} + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ ! "$INPUT_SIGNER_WORKFLOW" =~ ^crypta-network/cryptad/\.github/workflows/[A-Za-z0-9._-]+\.ya?ml$ ]]; then + echo "::error::Protected Windows producer signer workflow is not configured safely." + exit 1 + fi + if [[ ! "$EXPECTED_WINDOWS_CERTIFICATE_SHA1" =~ ^[0-9A-F]{40}$ ]]; then + echo "::error::Reviewed Windows signing-certificate identity is not configured safely." + exit 1 + fi + expected_workflow_path="${INPUT_SIGNER_WORKFLOW#crypta-network/cryptad/}" + run_json="$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_RUN_ID")" + jq -e \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg workflow_path "$expected_workflow_path" ' + .repository.full_name == $repository + and .head_sha == $commit + and .path == $workflow_path + and .event == "workflow_dispatch" + and .status == "completed" + and .conclusion == "success"' <<< "$run_json" >/dev/null + artifacts="$(gh api --paginate --method GET \ + "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_RUN_ID/artifacts" \ + -f per_page=100 | jq -s '{artifacts: [.[].artifacts[]]}')" + jq -e --arg name "$INPUT_ARTIFACT_NAME" --arg digest "$INPUT_ARTIFACT_DIGEST" ' + [.artifacts[] | select(.name == $name and .expired == false and .digest == $digest)] + | length == 1' <<< "$artifacts" >/dev/null + download="build/windows-external-producer" + output="build/stable-maintenance-package-output/windows" + rm -rf "$download" "$output" + mkdir -p "$download" "$output/assets" + gh run download "$INPUT_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "$INPUT_ARTIFACT_NAME" --dir "$download" + if [[ -n "$(find "$download" -type l -print -quit)" \ + || -n "$(find "$download" -mindepth 1 ! -type f ! -type d -print -quit)" ]]; then + echo "::error::Windows producer artifact contains a link or special entry." + exit 1 + fi + mapfile -t executables < <(find "$download" -type f -iname '*.exe' -print | sort) + if [[ "${#executables[@]}" -ne 1 ]]; then + echo "::error::Windows producer artifact must contain exactly one EXE." + exit 1 + fi + exe="${executables[0]}" + producer="$download/stable-1.0-maintenance-windows-package-producer.json" + if [[ -L "$producer" || ! -f "$producer" \ + || "$(find "$download" -type f -name 'stable-1.0-maintenance-windows-package-producer.json' | wc -l)" -ne 1 \ + || "$(find "$download" -type f | wc -l)" -ne 2 ]]; then + echo "::error::Windows producer omitted its exact post-build toolchain/source receipt." + exit 1 + fi + exe_digest="sha256:$(sha256sum "$exe" | awk '{print $1}')" + exe_size="$(stat --format='%s' "$exe")" + expected_signer_digest="sha256:$(printf '%s' "$EXPECTED_WINDOWS_CERTIFICATE_SHA1" \ + | sha256sum | awk '{print $1}')" + jq -e --arg release "$INPUT_RELEASE_ID" --arg build "$INPUT_BUILD_VERSION" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" --arg branch "$EXPECTED_BRANCH" \ + --arg exe_name "$(basename "$exe")" --arg exe_digest "$exe_digest" \ + --argjson exe_size "$exe_size" --arg signer_digest "$expected_signer_digest" ' + .kind == "stable-1.0-maintenance-external-package-producer" + and .releaseId == $release + and .buildVersion == $build + and .sourceCommit == $commit + and .source.branch == $branch + and .source.ref == ("commit:" + $commit) + and .source.commit == $commit + and (.source.sourceTreeDigest | test("^sha256:[0-9a-f]{64}$")) + and .toolchain.javaMajorVersion >= 25 + and .toolchain.dependencyVerificationStatus == "pass" + and .toolchain.productionSigning == true + and .toolchain.testSigning == false + and .authenticode.status == "pass" + and .authenticode.verificationPolicy == "windows-authenticode" + and .authenticode.timestampStatus == "pass" + and .authenticode.signerCertificateIdentityDigest == $signer_digest + and .assetDigest == $exe_digest + and .assetSizeBytes == $exe_size + and .assetArchitectures[$exe_name] == "amd64" + and .redaction.status == "pass"' "$producer" >/dev/null + python3 - "$exe" <<'PY' + import struct + import sys + from pathlib import Path + + path = Path(sys.argv[1]) + with path.open("rb") as stream: + if stream.read(2) != b"MZ": + raise SystemExit("Windows producer output lacks an MZ header") + stream.seek(0x3C) + offset_bytes = stream.read(4) + if len(offset_bytes) != 4: + raise SystemExit("Windows producer output lacks a PE offset") + pe_offset = struct.unpack(" path.stat().st_size: + raise SystemExit("Windows producer output has an out-of-bounds PE offset") + stream.seek(pe_offset) + if stream.read(4) != b"PE\0\0" or stream.read(2) != b"\x64\x86": + raise SystemExit("Windows producer output is not an amd64 PE image") + PY + if [[ "$exe_digest" != "$INPUT_EXE_SHA256" ]]; then + echo "::error::Windows EXE digest differs from the reviewed producer identity." + exit 1 + fi + gh attestation verify "$exe" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$INPUT_SIGNER_WORKFLOW" \ + --source-digest "$INPUT_CANDIDATE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + gh attestation verify "$producer" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$INPUT_SIGNER_WORKFLOW" \ + --source-digest "$INPUT_CANDIDATE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + cp "$exe" "$output/assets/$(basename "$exe")" + cp "$producer" "$output/producer.json" + ( + cd "$output/assets" + sha256sum -- * > ../checksums.txt + ) + jq -n \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg artifact_name "$INPUT_ARTIFACT_NAME" \ + --arg artifact_digest "$INPUT_ARTIFACT_DIGEST" \ + --arg repository "$GITHUB_REPOSITORY" \ + --argjson run_id "$INPUT_RUN_ID" ' + { + schemaVersion: 1, + kind: "stable-1.0-maintenance-external-package-producer", + releaseId: $release_id, + buildVersion: $build, + sourceCommit: $commit, + packageClass: "windows-exe", + producer: { + provider: "github-actions", repository: $repository, + runId: $run_id, artifactName: $artifact_name, + artifactDigest: $artifact_digest, attestationVerified: true + }, + buildCount: 1, + redaction: {status: "pass", findingCount: 0, findings: []} + }' > "$output/authentication.json" + + - name: Upload authenticated Windows package + uses: actions/upload-artifact@v6 + with: + name: stable-maintenance-package-${{ inputs.release_id }}-${{ inputs.build_version }}-windows + path: build/stable-maintenance-package-output/windows/ + if-no-files-found: error + retention-days: 30 + + freeze-and-validate: + name: Freeze exact bytes or validate a prior frozen Stable maintenance candidate + needs: + - validate-dispatch + - build-packages + - authenticate-windows-exe + if: >- + always() + && needs.validate-dispatch.result == 'success' + && ( + (inputs.operation == 'freeze-candidate' + && needs.build-packages.result == 'success' + && needs.authenticate-windows-exe.result == 'success') + || + (inputs.operation != 'freeze-candidate' + && needs.build-packages.result == 'skipped' + && needs.authenticate-windows-exe.result == 'skipped') + ) + runs-on: ubuntu-latest + environment: stable-1.0-maintenance-evidence + timeout-minutes: 120 + permissions: + contents: read + actions: read + attestations: read + outputs: + artifact_name: ${{ steps.stage.outputs.artifact_name }} + artifact_digest: ${{ steps.upload.outputs.artifact-digest }} + + steps: + - name: Check out exact candidate + uses: actions/checkout@v6 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5 + + - name: Install Gradle properties + uses: ./.github/actions/setup-gradle-properties + + - name: Download packages built once in this run + if: inputs.operation == 'freeze-candidate' + uses: actions/download-artifact@v8 + with: + pattern: stable-maintenance-package-${{ inputs.release_id }}-${{ inputs.build_version }}-* + path: build/stable-maintenance-packages + merge-multiple: false + digest-mismatch: error + + - name: Authenticate prior exact frozen or validated candidate + if: inputs.operation != 'freeze-candidate' + env: + INPUT_RUN_ID: ${{ inputs.operation == 'publish' && inputs.validated_candidate_run_id || inputs.operation == 'validate-authorization' && inputs.prepared_candidate_run_id || inputs.frozen_candidate_run_id }} + INPUT_ARTIFACT_NAME: ${{ inputs.operation == 'publish' && inputs.validated_candidate_artifact_name || inputs.operation == 'validate-authorization' && inputs.prepared_candidate_artifact_name || inputs.frozen_candidate_artifact_name }} + INPUT_ARTIFACT_DIGEST: ${{ inputs.operation == 'publish' && inputs.validated_candidate_artifact_digest || inputs.operation == 'validate-authorization' && inputs.prepared_candidate_artifact_digest || inputs.frozen_candidate_artifact_digest }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_OPERATION: ${{ inputs.operation }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + run_json="$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_RUN_ID")" + jq -e --arg repository "$GITHUB_REPOSITORY" --arg commit "$INPUT_CANDIDATE_COMMIT" ' + .repository.full_name == $repository + and .head_sha == $commit + and .path == ".github/workflows/stable-1.0-maintenance-release.yml" + and .event == "workflow_dispatch" + and .status == "completed" + and .conclusion == "success"' <<< "$run_json" >/dev/null + artifacts="$(gh api --paginate --method GET \ + "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_RUN_ID/artifacts" \ + -f per_page=100 | jq -s '{artifacts: [.[].artifacts[]]}')" + jq -e --arg name "$INPUT_ARTIFACT_NAME" --arg digest "$INPUT_ARTIFACT_DIGEST" ' + [.artifacts[] | select(.name == $name and .expired == false and .digest == $digest)] + | length == 1' <<< "$artifacts" >/dev/null + rm -rf build/prior-validated-candidate + mkdir -p build/prior-validated-candidate + gh run download "$INPUT_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "$INPUT_ARTIFACT_NAME" --dir build/prior-validated-candidate + subject="build/prior-validated-candidate/freeze/stable-1.0-maintenance-candidate-freeze.json" + if [[ "$INPUT_OPERATION" != prepare-authorization ]]; then + subject="build/prior-validated-candidate/component/artifacts/legacy/stable-1.0-maintenance-candidate.json" + fi + if [[ -L "$subject" || ! -f "$subject" ]]; then + echo "::error::Prior candidate artifact is missing its canonical frozen identity." + exit 1 + fi + gh attestation verify "$subject" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "crypta-network/cryptad/.github/workflows/stable-1.0-maintenance-release.yml" \ + --source-digest "$INPUT_CANDIDATE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + if [[ -d build/prior-validated-candidate/packages ]]; then + cp -R build/prior-validated-candidate/packages build/stable-maintenance-packages + else + echo "::error::Prior candidate artifact does not contain the exact frozen package set." + exit 1 + fi + + - name: Authenticate and materialize public-safe protected inputs + if: inputs.operation != 'publish' + env: + INPUT_RUN_ID: ${{ inputs.protected_inputs_run_id }} + INPUT_ARTIFACT_NAME: ${{ inputs.protected_inputs_artifact_name }} + INPUT_ARTIFACT_DIGEST: ${{ inputs.protected_inputs_artifact_digest }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_SIGNER_WORKFLOW: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_INPUT_SIGNER_WORKFLOW }} + INPUT_OPERATION: ${{ inputs.operation }} + INPUT_PRIOR_RUN_ID: ${{ inputs.operation == 'validate-authorization' && inputs.prepared_candidate_run_id || inputs.frozen_candidate_run_id }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ ! "$INPUT_SIGNER_WORKFLOW" =~ ^crypta-network/cryptad/\.github/workflows/[A-Za-z0-9._-]+\.ya?ml$ ]]; then + echo "::error::Protected input signer workflow is not configured safely." + exit 1 + fi + expected_workflow_path="${INPUT_SIGNER_WORKFLOW#crypta-network/cryptad/}" + run_json="$(gh api --method GET "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_RUN_ID")" + jq -e \ + --arg repository "$GITHUB_REPOSITORY" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg workflow_path "$expected_workflow_path" ' + .repository.full_name == $repository + and .head_sha == $commit + and .path == $workflow_path + and .event == "workflow_dispatch" + and .status == "completed" + and .conclusion == "success"' <<< "$run_json" >/dev/null + if [[ "$INPUT_OPERATION" == prepare-authorization \ + || "$INPUT_OPERATION" == validate-authorization ]]; then + prior_run_json="$(gh api --method GET \ + "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_PRIOR_RUN_ID")" + prior_completed="$(jq -er '.updated_at' <<< "$prior_run_json")" + protected_created="$(jq -er '.created_at' <<< "$run_json")" + if [[ "$protected_created" < "$prior_completed" \ + || "$protected_created" == "$prior_completed" ]]; then + echo "::error::Protected evidence/approval inputs were not produced after the prior immutable phase." + exit 1 + fi + fi + artifacts="$(gh api --paginate --method GET \ + "repos/$GITHUB_REPOSITORY/actions/runs/$INPUT_RUN_ID/artifacts" \ + -f per_page=100 | jq -s '{artifacts: [.[].artifacts[]]}')" + jq -e --arg name "$INPUT_ARTIFACT_NAME" --arg digest "$INPUT_ARTIFACT_DIGEST" ' + [.artifacts[] | select(.name == $name and .expired == false and .digest == $digest)] + | length == 1' <<< "$artifacts" >/dev/null + root="build/protected-inputs-artifact" + rm -rf "$root" build/protected-inputs + mkdir -p "$root" build/protected-inputs + gh run download "$INPUT_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "$INPUT_ARTIFACT_NAME" --dir "$root" + if [[ -n "$(find "$root" -type l -print -quit)" \ + || -n "$(find "$root" -mindepth 1 ! -type f ! -type d -print -quit)" ]]; then + echo "::error::Protected input artifact contains a link or special entry." + exit 1 + fi + if [[ "$INPUT_OPERATION" == validate-authorization ]]; then + authorization="$root/stable-1.0-maintenance-authorization.json" + if [[ -L "$authorization" || ! -f "$authorization" \ + || "$(find "$root" -mindepth 1 -maxdepth 1 -type f | wc -l)" -ne 1 \ + || -n "$(find "$root" -mindepth 1 -maxdepth 1 -type d -print -quit)" ]]; then + echo "::error::The approval producer must contain only the exact authorization JSON." + exit 1 + fi + gh attestation verify "$authorization" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$INPUT_SIGNER_WORKFLOW" \ + --source-digest "$INPUT_CANDIDATE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + else + manifest="$root/stable-1.0-maintenance.json" + if [[ ! -f "$manifest" || ! -d "$root/protected-inputs" ]]; then + echo "::error::Protected producer did not materialize the canonical manifest layout." + exit 1 + fi + gh attestation verify "$manifest" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$INPUT_SIGNER_WORKFLOW" \ + --source-digest "$INPUT_CANDIDATE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + cp "$manifest" build/stable-1.0-maintenance.json + cp -R "$root/protected-inputs/." build/protected-inputs/ + fi + + - name: Build the trusted Stable catalog verifier + if: inputs.operation == 'freeze-candidate' + run: | + set -euo pipefail + ./gradlew :platform-devtools:installDist + git diff-index --quiet --cached HEAD -- + git diff-files --quiet -- + + - name: Generate the canonical exact-byte freeze record + if: inputs.operation == 'freeze-candidate' + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + STABLE_CATALOG_TRUSTED_KEYS_BASE64: ${{ secrets.STABLE_CATALOG_TRUSTED_KEYS_BASE64 }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ -z "$STABLE_CATALOG_TRUSTED_KEYS_BASE64" ]]; then + echo "::error::Protected Stable catalog trust material is unavailable." + exit 1 + fi + trusted_keys_dir="$RUNNER_TEMP/stable-maintenance-catalog-trust-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + trusted_keys="$trusted_keys_dir/trusted-catalog-keys.properties" + rm -rf "$trusted_keys_dir" + install -d -m 700 "$trusted_keys_dir" + if ! printf '%s' "$STABLE_CATALOG_TRUSTED_KEYS_BASE64" \ + | base64 --decode > "$trusted_keys"; then + echo "::error::Protected Stable catalog trust material is malformed." + exit 1 + fi + chmod 600 "$trusted_keys" + unset STABLE_CATALOG_TRUSTED_KEYS_BASE64 + export STABLE_CATALOG_TRUSTED_KEYS_FILE="$trusted_keys" + trap 'rm -rf "$trusted_keys_dir"' EXIT + PYTHONPATH=tools/release-certification python3 - <<'PY' + import hashlib + import json + import os + import shutil + import stat + import subprocess + from datetime import datetime, timezone + from pathlib import Path + + from cryptad_certification.engines.stable_1_0_maintenance_core import ( + select_candidate_dmg_for_freeze, + stable_catalog_verification_identity, + ) + from cryptad_certification.schema_validation import validate_schema + + def read_json(path: Path): + def reject_duplicates(pairs): + result = {} + for key, value in pairs: + if key in result: + raise SystemExit(f"duplicate JSON key in {path.name}") + result[key] = value + return result + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=reject_duplicates) + + def canonical_bytes(value): + return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode() + + def file_digest(path: Path): + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + def semantic_digest(value): + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + protected_root = Path("build/protected-inputs").resolve(strict=True) + + def protected_input(value, *, directory=False): + raw = Path(value) + if raw.is_absolute() or raw.is_symlink(): + raise SystemExit("candidate selection escapes protected inputs") + try: + resolved = raw.resolve(strict=True) + resolved.relative_to(protected_root) + except (OSError, ValueError): + raise SystemExit("candidate selection escapes protected inputs") from None + expected_type = resolved.is_dir() if directory else resolved.is_file() + if not expected_type: + raise SystemExit("candidate selection has the wrong protected input type") + return resolved + + manifest = read_json(Path("build/stable-1.0-maintenance.json")) + inputs = manifest["inputs"] + candidate_path = protected_input(inputs["maintenanceCandidate"]) + source_assets = protected_input(inputs["maintenanceCandidateAssets"], directory=True) + candidate = read_json(candidate_path) + + forbidden_pre_freeze = ( + "maintenanceCandidateChecksums", + "maintenanceCandidateProvenance", + "maintenanceCandidateFreeze", + "maintenanceEvidence", + "stableMaintenanceAuthorization", + "stableMaintenancePublicationReceipt", + "coreUpdatePublicationReceipt", + ) + for key in forbidden_pre_freeze: + configured = inputs.get(key) + if isinstance(configured, str) and Path(configured).exists(): + raise SystemExit(f"freeze selection supplied forbidden pre-bound input {key}") + + product = candidate["product"] + catalog = candidate["stableCatalog"] + packages = candidate["packages"] + catalog_names = [catalog["fileName"], catalog["signatureFileName"]] + names = [product["fileName"], *catalog_names, *[row["fileName"] for row in packages]] + if len(names) != len(set(names)) or any(Path(name).name != name for name in names): + raise SystemExit("candidate selection contains unsafe or duplicate filenames") + selection_entries = list(source_assets.iterdir()) + if ( + {entry.name for entry in selection_entries} != set(catalog_names) + or any(entry.is_symlink() or not entry.is_file() for entry in selection_entries) + ): + raise SystemExit( + "freeze selection asset root must contain only the declared stable catalog and detached signature" + ) + + selected = Path("build/stable-maintenance-freeze/assets") + shutil.rmtree(selected.parent, ignore_errors=True) + selected.mkdir(parents=True) + producer_root = Path("build/stable-maintenance-packages") + for name in names: + if name in catalog_names: + matches = [source_assets / name] + else: + matches = sorted(producer_root.glob(f"*/assets/{name}")) + if len(matches) != 1 or matches[0].is_symlink() or not matches[0].is_file(): + raise SystemExit(f"selected asset {name} does not have one safe producer") + destination = selected / name + shutil.copyfile(matches[0], destination) + destination.chmod(0o644) + + catalog_path = selected / catalog["fileName"] + catalog_signature_path = selected / catalog["signatureFileName"] + catalog_verifier = Path( + "platform-devtools/build/install/crypta-app/bin/crypta-app" + ).resolve(strict=True) + trusted_catalog_keys = Path( + os.environ["STABLE_CATALOG_TRUSTED_KEYS_FILE"] + ).resolve(strict=True) + verified_catalog = subprocess.run( + [ + str(catalog_verifier), + "catalog", + "verify", + "--catalog-file", + str(catalog_path), + "--catalog-signature-file", + str(catalog_signature_path), + "--expected-key-id", + catalog["signingKeyId"], + "--trusted-keys-file", + str(trusted_catalog_keys), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if verified_catalog.returncode != 0: + raise SystemExit( + "Stable catalog cryptographic verification failed for the exact candidate bytes" + ) + catalog_verification = stable_catalog_verification_identity( + catalog, file_digest(trusted_catalog_keys) + ) + catalog_verification_digest = semantic_digest(catalog_verification) + + checksum_path = selected.parent / "checksums.txt" + checksum_rows = [ + f"{file_digest(path).removeprefix('sha256:')} {path.name}" + for path in sorted(selected.iterdir(), key=lambda item: item.name) + ] + checksum_path.write_text("\n".join(checksum_rows) + "\n", encoding="utf-8") + checksum_path.chmod(0o644) + + producer_receipts = sorted(Path("build/stable-maintenance-packages").glob("*/producer.json")) + if len(producer_receipts) != 3: + raise SystemExit("freeze requires Linux, macOS, and Windows producer receipts") + source_tree_digest = "sha256:" + hashlib.sha256( + os.popen("git ls-tree -r --full-tree HEAD").read().encode() + ).hexdigest() + build_logic_digest = "sha256:" + hashlib.sha256( + os.popen("git ls-tree -r --full-tree HEAD -- build-logic").read().encode() + ).hexdigest() + wrapper_digest = file_digest(Path("gradle/wrapper/gradle-wrapper.jar")) + dependency_verification_digest = file_digest(Path("gradle/verification-metadata.xml")) + java_version = os.popen("java --version 2>&1").read().splitlines()[0] + java_major = int(java_version.split()[1].split(".")[0]) + gradle_version_lines = os.popen("./gradlew --version").read().splitlines() + gradle_version = next(line.split()[1] for line in gradle_version_lines if line.startswith("Gradle ")) + actual_source = dict(candidate["source"]) + actual_source["sourceTreeDigest"] = source_tree_digest + expected_branch_prefix = ( + "release" if os.environ["INPUT_RELEASE_CLASS"] == "maintenance" else "hotfix" + ) + expected_branch = f"{expected_branch_prefix}/{os.environ['INPUT_BUILD_VERSION']}" + expected_ref = f"commit:{os.environ['INPUT_CANDIDATE_COMMIT']}" + if ( + actual_source.get("branch") != expected_branch + or actual_source.get("ref") != expected_ref + or actual_source.get("commit") != os.environ["INPUT_CANDIDATE_COMMIT"] + ): + raise SystemExit("candidate selection source branch/ref/commit is not the reviewed identity") + build_tasks = set() + signing_receipt_by_name = {} + signer_workflow = ( + "crypta-network/cryptad/.github/workflows/" + "stable-1.0-maintenance-release.yml" + ) + for receipt_path in producer_receipts: + receipt = read_json(receipt_path) + receipt_source = receipt.get("source", {}) + receipt_toolchain = receipt.get("toolchain", {}) + if ( + receipt_source.get("branch") != expected_branch + or receipt_source.get("ref") != expected_ref + or receipt_source.get("commit") != os.environ["INPUT_CANDIDATE_COMMIT"] + or receipt_source.get("sourceTreeDigest") != source_tree_digest + or receipt_toolchain.get("javaMajorVersion", 0) < 25 + or receipt_toolchain.get("gradleVersion") != gradle_version + or receipt_toolchain.get("gradleWrapperDigest") != wrapper_digest + or receipt_toolchain.get("dependencyVerificationDigest") + != dependency_verification_digest + or receipt_toolchain.get("dependencyVerificationStatus") != "pass" + or receipt_toolchain.get("buildLogicDigest") != build_logic_digest + or receipt_toolchain.get("productionSigning") is not True + or receipt_toolchain.get("testSigning") is not False + ): + raise SystemExit(f"producer {receipt_path.parent.name} toolchain/source identity drifted") + build_tasks.update(receipt_toolchain.get("buildTasks", [])) + package_class = receipt.get("packageClass") + if package_class not in {"linux-and-portable", "macos"}: + continue + signing = receipt.get("assetSigning", {}) + asset_root = receipt_path.parent / "assets" + receipt_root = receipt_path.parent / "signing-receipts" + producer_assets = sorted(asset_root.iterdir(), key=lambda path: path.name) + receipt_digests = signing.get("receiptDigests", {}) + if ( + signing.get("method") != "sigstore-github-attestation" + or signing.get("signerWorkflow") != signer_workflow + or signing.get("sourceCommit") != os.environ["INPUT_CANDIDATE_COMMIT"] + or signing.get("verificationStatus") != "pass" + or not isinstance(receipt_digests, dict) + or set(receipt_digests) != {path.name for path in producer_assets} + or not receipt_root.is_dir() + ): + raise SystemExit( + f"producer {receipt_path.parent.name} lacks exact cryptographic signing receipts" + ) + for asset in producer_assets: + verification_path = receipt_root / f"{asset.name}.signing.json" + if ( + asset.is_symlink() + or not asset.is_file() + or verification_path.is_symlink() + or not verification_path.is_file() + or receipt_digests.get(asset.name) != file_digest(verification_path) + ): + raise SystemExit( + f"producer signing receipt is missing or mismatched for {asset.name}" + ) + verification = read_json(verification_path) + if ( + verification.get("kind") + != "stable-1.0-maintenance-asset-signing-verification" + or verification.get("releaseId") != os.environ["INPUT_RELEASE_ID"] + or verification.get("buildVersion") != os.environ["INPUT_BUILD_VERSION"] + or verification.get("sourceCommit") + != os.environ["INPUT_CANDIDATE_COMMIT"] + or verification.get("fileName") != asset.name + or verification.get("subjectDigest") != file_digest(asset) + or verification.get("subjectSizeBytes") != asset.stat().st_size + or verification.get("signingMethod") + != "sigstore-github-attestation" + or verification.get("signerWorkflow") != signer_workflow + or verification.get("hostedRunnerRequired") is not True + or verification.get("cryptographicVerificationStatus") != "pass" + or verification.get("redaction") + != {"status": "pass", "findingCount": 0, "findings": []} + ): + raise SystemExit( + f"producer signing receipt identity drifted for {asset.name}" + ) + verified = subprocess.run( + [ + "gh", "attestation", "verify", str(asset), + "--repo", os.environ["GITHUB_REPOSITORY"], + "--signer-workflow", signer_workflow, + "--source-digest", os.environ["INPUT_CANDIDATE_COMMIT"], + "--deny-self-hosted-runners", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if verified.returncode != 0: + raise SystemExit( + f"cryptographic signing attestation failed for {asset.name}" + ) + if asset.name in signing_receipt_by_name: + raise SystemExit(f"duplicate signing receipt for {asset.name}") + signing_receipt_by_name[asset.name] = file_digest(verification_path) + actual_toolchain = { + "javaVersion": java_version, + "javaMajorVersion": java_major, + "gradleVersion": gradle_version, + "gradleWrapperDigest": wrapper_digest, + "dependencyVerificationDigest": dependency_verification_digest, + "dependencyVerificationStatus": "pass", + "buildLogicDigest": build_logic_digest, + "buildTasks": sorted(build_tasks), + "productionSigning": True, + "testSigning": False, + } + build_receipt = semantic_digest( + [{"name": path.parent.name, "digest": file_digest(path)} for path in producer_receipts] + ) + producer_identity = { + "repository": os.environ["GITHUB_REPOSITORY"], + "workflowPath": ".github/workflows/stable-1.0-maintenance-release.yml", + "workflowCommit": os.environ["INPUT_CANDIDATE_COMMIT"], + "runId": os.environ["GITHUB_RUN_ID"], + "runAttempt": int(os.environ["GITHUB_RUN_ATTEMPT"]), + "runnerEnvironment": "github-hosted", + } + source_ref = { + "branch": actual_source["branch"], + "ref": actual_source["ref"], + "commit": actual_source["commit"], + } + + baseline_path = protected_input(inputs["predecessorBaseline"]) + receipt_path = protected_input(inputs["predecessorPublicationReceipt"]) + baseline = read_json(baseline_path) + release = baseline["release"] + pointer_value = inputs.get("latestPublishedMaintenancePointer") + pointer_digest = None + if pointer_value: + pointer_digest = file_digest(protected_input(pointer_value)) + predecessor_product = release.get("productDigest", release.get("rcProductDigest")) + if predecessor_product is None: + raise SystemExit("predecessor baseline lacks its product identity") + + mac_verification = Path("build/stable-maintenance-packages") + mac_receipts = sorted(mac_verification.glob("*/verification.json")) + if len(mac_receipts) != 1: + raise SystemExit("freeze requires one macOS signing/notarization receipt") + mac_receipt = mac_receipts[0] + mac_receipt_digest = file_digest(mac_receipt) + mac_receipt_value = read_json(mac_receipt) + mac_assets = sorted( + path for path in (mac_receipt.parent / "assets").iterdir() + if path.is_file() and not path.is_symlink() + ) + if len(mac_assets) != 1 or mac_assets[0].suffix.lower() != ".dmg": + raise SystemExit("macOS notarization receipt is not paired with exactly one DMG") + if ( + mac_receipt_value.get("fileName") != mac_assets[0].name + or mac_receipt_value.get("subjectDigest") != file_digest(mac_assets[0]) + or mac_receipt_value.get("subjectSizeBytes") != mac_assets[0].stat().st_size + ): + raise SystemExit("macOS notarization receipt does not bind its sibling DMG bytes") + try: + dmg_package = select_candidate_dmg_for_freeze( + packages, + os.environ["INPUT_RELEASE_CLASS"], + candidate.get("changeScope", {}), + ) + except ValueError as error: + raise SystemExit(str(error)) from None + if dmg_package is not None: + dmg_path = selected / dmg_package["fileName"] + if ( + mac_assets[0].name != dmg_path.name + or file_digest(mac_assets[0]) != file_digest(dmg_path) + or mac_assets[0].stat().st_size != dmg_path.stat().st_size + ): + raise SystemExit("macOS notarization receipt is not bound to the selected DMG bytes") + for package in packages: + expected_notarization = ( + "pass" if package.get("packageType") == "dmg" else "not-applicable" + ) + if package.get("notarizationStatus") != expected_notarization: + raise SystemExit( + f"package {package.get('fileName')} has invalid notarization scope" + ) + producer_receipt_by_name = {} + producer_architecture_by_name = {} + for receipt in producer_receipts: + receipt_value = read_json(receipt) + receipt_architectures = receipt_value.get("assetArchitectures", {}) + if not isinstance(receipt_architectures, dict): + raise SystemExit(f"producer {receipt.parent.name} lacks architecture metadata") + for asset in (receipt.parent / "assets").iterdir(): + if asset.name in producer_receipt_by_name: + raise SystemExit(f"asset {asset.name} has multiple package producers") + producer_receipt_by_name[asset.name] = file_digest(receipt) + architecture = receipt_architectures.get(asset.name) + if architecture is not None: + if architecture not in {"amd64", "arm64"}: + raise SystemExit(f"asset {asset.name} has an unsupported producer architecture") + producer_architecture_by_name[asset.name] = architecture + + assets = [] + product_path = selected / product["fileName"] + product_signing_receipt = signing_receipt_by_name.get(product_path.name) + if product_signing_receipt is None: + raise SystemExit( + "portable product lacks an independently verified cryptographic signing receipt" + ) + assets.append({ + "role": "product", "fileName": product_path.name, + "digest": file_digest(product_path), "sizeBytes": product_path.stat().st_size, + "packageKey": None, "os": None, "arch": None, + "producerArchitecture": None, "packageType": None, + "publicAsset": True, "signingStatus": "pass", + "signingReceiptDigest": product_signing_receipt, + "notarizationStatus": "not-applicable", "notarizationReceiptDigest": None, + }) + if ( + file_digest(catalog_path) != catalog["digest"] + or catalog_path.stat().st_size != catalog["sizeBytes"] + or file_digest(catalog_signature_path) != catalog["signatureDigest"] + or catalog_signature_path.stat().st_size != catalog["signatureSizeBytes"] + ): + raise SystemExit("stable catalog or detached signature bytes do not match the candidate") + assets.append({ + "role": "stable-catalog", "fileName": catalog_path.name, + "digest": file_digest(catalog_path), "sizeBytes": catalog_path.stat().st_size, + "packageKey": None, "os": None, "arch": None, + "producerArchitecture": None, "packageType": None, + "publicAsset": True, "signingStatus": "pass", + "signingReceiptDigest": catalog_verification_digest, + "notarizationStatus": "not-applicable", "notarizationReceiptDigest": None, + }) + assets.append({ + "role": "stable-catalog-signature", "fileName": catalog_signature_path.name, + "digest": file_digest(catalog_signature_path), + "sizeBytes": catalog_signature_path.stat().st_size, + "packageKey": None, "os": None, "arch": None, + "producerArchitecture": None, "packageType": None, + "publicAsset": True, "signingStatus": "pass", + "signingReceiptDigest": catalog_verification_digest, + "notarizationStatus": "not-applicable", "notarizationReceiptDigest": None, + }) + for package in packages: + path = selected / package["fileName"] + is_dmg = package["packageType"] == "dmg" + receipt_digest = producer_receipt_by_name.get(path.name) + if receipt_digest is None: + raise SystemExit(f"package {path.name} lacks a producer receipt") + if package["packageType"] in {"deb", "rpm", "dmg"}: + receipt_digest = signing_receipt_by_name.get(path.name) + if receipt_digest is None: + raise SystemExit( + f"package {path.name} lacks independent cryptographic signing verification" + ) + producer_architecture = producer_architecture_by_name.get(path.name) + if producer_architecture != package["arch"]: + raise SystemExit( + f"package {path.name} producer architecture {producer_architecture!r} " + f"does not match selector {package['arch']!r}" + ) + assets.append({ + "role": "package", "fileName": path.name, + "digest": file_digest(path), "sizeBytes": path.stat().st_size, + "packageKey": package["packageKey"], "os": package["os"], + "arch": package["arch"], "packageType": package["packageType"], + "producerArchitecture": producer_architecture, + "publicAsset": True, "signingStatus": "pass", + "signingReceiptDigest": receipt_digest, + "notarizationStatus": "pass" if is_dmg else "not-applicable", + "notarizationReceiptDigest": mac_receipt_digest if is_dmg else None, + }) + assets.sort(key=lambda row: row["fileName"]) + frozen_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + freeze = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-candidate-freeze", + "generatedAt": frozen_at, + "frozenAt": frozen_at, + "stableMilestone": "1.0", + "releaseId": os.environ["INPUT_RELEASE_ID"], + "buildVersion": os.environ["INPUT_BUILD_VERSION"], + "releaseClass": os.environ["INPUT_RELEASE_CLASS"], + "source": actual_source, + "toolchain": actual_toolchain, + "producer": { + "system": "github-actions", **producer_identity, + "producerIdentityReceiptDigest": semantic_digest(producer_identity), + "sourceRefReceiptDigest": semantic_digest(source_ref), + "buildReceiptDigest": build_receipt, + "authenticationStatus": "pass", + }, + "predecessorObservation": { + "releaseId": release["releaseId"], + "buildVersion": release["buildVersion"], + "productDigest": predecessor_product, + "baselineDigest": file_digest(baseline_path), + "publicationReceiptDigest": file_digest(receipt_path), + "latestPublishedPointerDigest": pointer_digest, + "observedAt": frozen_at, + "status": "latest-published", + }, + "stableCatalogVerification": catalog_verification, + "buildCount": 1, + "rebuildPerformed": False, + "checksumsDigest": file_digest(checksum_path), + "assets": assets, + "assetSetDigest": semantic_digest(assets), + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + } + errors = validate_schema(freeze, "stable-1.0-maintenance-candidate-freeze-v1.schema.json") + if errors: + raise SystemExit("candidate freeze failed its closed schema: " + "; ".join(errors)) + freeze_path = selected.parent / "stable-1.0-maintenance-candidate-freeze.json" + freeze_path.write_bytes(canonical_bytes(freeze)) + freeze_path.chmod(0o644) + PY + + - name: Materialize and verify prior frozen bytes for authorization preparation + if: inputs.operation == 'prepare-authorization' + run: | + set -euo pipefail + manifest="build/stable-1.0-maintenance.json" + candidate_path="$(jq -er '.inputs.maintenanceCandidate' "$manifest")" + asset_root="$(jq -er '.inputs.maintenanceCandidateAssets' "$manifest")" + checksums_path="$(jq -er '.inputs.maintenanceCandidateChecksums' "$manifest")" + provenance_path="$(jq -er '.inputs.maintenanceCandidateProvenance' "$manifest")" + freeze_path="$(jq -er '.inputs.maintenanceCandidateFreeze' "$manifest")" + python3 - "$candidate_path" "$asset_root" "$checksums_path" \ + "$provenance_path" "$freeze_path" <<'PY' + import sys + from pathlib import Path + + root = Path("build/protected-inputs").resolve(strict=True) + for index, value in enumerate(sys.argv[1:]): + path = Path(value) + if path.is_absolute() or path.is_symlink(): + raise SystemExit("candidate input path escapes the authenticated protected-input root") + try: + if index in {1, 4} and not path.exists(): + resolved = path.parent.resolve(strict=True) / path.name + else: + resolved = path.resolve(strict=True) + resolved.relative_to(root) + except (OSError, ValueError): + raise SystemExit( + "candidate input path escapes the authenticated protected-input root" + ) from None + if index == 1 and not path.exists(): + # Actions artifacts omit empty directories. Recreate only the authenticated, + # confined candidate-assets directory; it must remain empty until the exact + # prior freeze bytes are restored below. + path.mkdir(mode=0o755) + PY + if [[ -L "$candidate_path" || ! -f "$candidate_path" \ + || -L "$asset_root" || ! -d "$asset_root" \ + || -L "$checksums_path" || ! -f "$checksums_path" \ + || -L "$provenance_path" || ! -f "$provenance_path" ]]; then + echo "::error::Post-freeze candidate metadata is missing or unsafe." + exit 1 + fi + prior_freeze="build/prior-validated-candidate/freeze" + if [[ -n "$(find "$asset_root" -mindepth 1 -print -quit)" ]]; then + echo "::error::Post-freeze evidence producer must not supply or replace candidate asset bytes." + exit 1 + fi + rm -rf "$asset_root" + mkdir -p "$asset_root" + cp "$prior_freeze/assets/"* "$asset_root/" + cp "$prior_freeze/stable-1.0-maintenance-candidate-freeze.json" "$freeze_path" + chmod 0644 "$asset_root/"* "$freeze_path" + if ! cmp --silent "$prior_freeze/checksums.txt" "$checksums_path"; then + echo "::error::Post-freeze candidate checksums do not equal the attested freeze checksums." + exit 1 + fi + PYTHONPATH=tools/release-certification python3 - "$manifest" <<'PY' + import hashlib + import json + import sys + from pathlib import Path + + from cryptad_certification.schema_validation import validate_schema + + def load(path): + return json.loads(Path(path).read_text(encoding="utf-8")) + + def digest(path): + return "sha256:" + hashlib.sha256(Path(path).read_bytes()).hexdigest() + + manifest = load(sys.argv[1]) + inputs = manifest["inputs"] + candidate = load(inputs["maintenanceCandidate"]) + freeze = load(inputs["maintenanceCandidateFreeze"]) + evidence = load(inputs["maintenanceEvidence"]) + provenance = load(inputs["maintenanceCandidateProvenance"]) + freeze_digest = digest(inputs["maintenanceCandidateFreeze"]) + if validate_schema(freeze, "stable-1.0-maintenance-candidate-freeze-v1.schema.json"): + raise SystemExit("attested candidate freeze failed its closed schema") + if candidate.get("candidateFreezeDigest") != freeze_digest: + raise SystemExit("candidate metadata does not bind the exact freeze record") + if provenance.get("candidateFreezeDigest") != freeze_digest: + raise SystemExit("candidate provenance does not bind the exact freeze record") + if evidence.get("candidateFreezeDigest") != freeze_digest: + raise SystemExit("maintenance evidence does not bind the exact freeze record") + if evidence.get("candidateProductDigest") != freeze["assets"][ + next(index for index, row in enumerate(freeze["assets"]) if row["role"] == "product") + ]["digest"]: + raise SystemExit("maintenance evidence is bound to the wrong frozen product") + PY + + - name: Restore prepared inputs and add only the exact approval + if: inputs.operation == 'validate-authorization' + run: | + set -euo pipefail + authorization="build/protected-inputs-artifact/stable-1.0-maintenance-authorization.json" + if [[ -L "$authorization" || ! -f "$authorization" \ + || "$(find build/protected-inputs-artifact -mindepth 1 -maxdepth 1 -type f | wc -l)" -ne 1 \ + || -n "$(find build/protected-inputs-artifact -mindepth 1 -maxdepth 1 -type d -print -quit)" ]]; then + echo "::error::validate-authorization producer must contain only the exact approval JSON." + exit 1 + fi + rm -rf build/protected-inputs + cp -R build/prior-validated-candidate/protected-inputs build/protected-inputs + cp build/prior-validated-candidate/stable-1.0-maintenance.json \ + build/stable-1.0-maintenance.json + manifest="build/stable-1.0-maintenance.json" + authorization_path="build/protected-inputs/authorization/stable-1.0-maintenance-authorization.json" + jq -e ' + .commands["stable-maintenance"].mode == "prepare-authorization" + and (.inputs | has("stableMaintenanceAuthorization") | not)' \ + "$manifest" >/dev/null + jq -S --arg authorization_path "$authorization_path" ' + .commands["stable-maintenance"].mode = "validate-only" + | .inputs.stableMaintenanceAuthorization = $authorization_path' \ + "$manifest" > "$manifest.next" + jq -S ' + .commands["stable-maintenance"].mode = "prepare-authorization" + | del(.inputs.stableMaintenanceAuthorization)' \ + "$manifest.next" > "$manifest.next.projected" + jq -S . "$manifest" > "$manifest.prepared.canonical" + if ! cmp --silent "$manifest.prepared.canonical" "$manifest.next.projected"; then + echo "::error::Authorization validation attempted to mutate prepared manifest fields." + exit 1 + fi + mv "$manifest.next" "$manifest" + mkdir -p "$(dirname "$authorization_path")" + cp "$authorization" "$authorization_path" + + - name: Authenticate source ancestry and exact change scope + if: inputs.operation != 'publish' + env: + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + run: | + set -euo pipefail + manifest="build/stable-1.0-maintenance.json" + candidate_path="$(jq -er '.inputs.maintenanceCandidate' "$manifest")" + base_branch="$(jq -er '.source.baseBranch' "$candidate_path")" + base_commit="$(jq -er '.source.baseCommit' "$candidate_path")" + expected_base="develop" + if [[ "$INPUT_RELEASE_CLASS" == security-hotfix ]]; then + expected_base="main" + fi + if [[ "$base_branch" != "$expected_base" \ + || ! "$base_commit" =~ ^[0-9a-f]{40,64}$ ]]; then + echo "::error::Candidate source base does not match its closed release class." + exit 1 + fi + remote_base="refs/remotes/origin/$base_branch" + if ! git rev-parse --verify "$remote_base^{commit}" >/dev/null; then + echo "::error::Authenticated checkout does not contain origin/$base_branch." + exit 1 + fi + if ! git merge-base --is-ancestor "$base_commit" "$INPUT_CANDIDATE_COMMIT"; then + echo "::error::Declared source base is not an ancestor of the candidate." + exit 1 + fi + predecessor_baseline="$(jq -er '.inputs.predecessorBaseline' "$manifest")" + if [[ -L "$predecessor_baseline" || ! -f "$predecessor_baseline" ]]; then + echo "::error::Authenticated predecessor baseline is missing or unsafe." + exit 1 + fi + predecessor_commit="$(jq -er ' + .release.sourceCommit + | select(type == "string" and test("^[0-9a-f]{40,64}$"))' \ + "$predecessor_baseline")" + if ! git rev-parse --verify "$predecessor_commit^{commit}" >/dev/null 2>&1; then + echo "::error::Authenticated predecessor source commit is absent from the checkout." + exit 1 + fi + if ! git merge-base --is-ancestor \ + "$predecessor_commit" "$INPUT_CANDIDATE_COMMIT"; then + echo "::error::Candidate does not contain the latest published predecessor commit." + exit 1 + fi + merge_base="$(git merge-base "$INPUT_CANDIDATE_COMMIT" "$remote_base")" + if [[ "$base_commit" != "$merge_base" ]]; then + echo "::error::Declared source base is not the exact candidate/$base_branch merge base." + exit 1 + fi + if [[ "$INPUT_RELEASE_CLASS" == security-hotfix \ + && "$base_commit" != "$(git rev-parse "$remote_base^{commit}")" ]]; then + echo "::error::Security hotfix was not based on the current published main head." + exit 1 + fi + if ! jq -e '.changeScope.unrelatedFeatureChanges == []' "$candidate_path" >/dev/null; then + echo "::error::Candidate change-scope audit contains unrelated feature work." + exit 1 + fi + + mapfile -t changed_files < <(git diff --name-only --diff-filter=ACDMRTUXB \ + "$base_commit..$INPUT_CANDIDATE_COMMIT" | LC_ALL=C sort -u) + if [[ "${#changed_files[@]}" -eq 0 ]]; then + echo "::error::Maintenance candidate has no source changes from its authenticated base." + exit 1 + fi + mapfile -t derived_modules < <( + for path in "${changed_files[@]}"; do + if [[ "$path" == */* ]]; then + printf '%s\n' "${path%%/*}" + else + printf '%s\n' repository-root + fi + done | LC_ALL=C sort -u + ) + mapfile -t declared_modules < <(jq -er '.changeScope.changedModules[]' \ + "$candidate_path" | LC_ALL=C sort -u) + if [[ "${#derived_modules[@]}" -ne "${#declared_modules[@]}" \ + || "$(printf '%s\n' "${derived_modules[@]}")" \ + != "$(printf '%s\n' "${declared_modules[@]}")" ]]; then + echo "::error::Declared changedModules does not exactly match the authenticated source diff." + exit 1 + fi + + - name: Validate manifest identity and side-effect-free mode + if: inputs.operation == 'prepare-authorization' || inputs.operation == 'validate-authorization' + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + EXPECTED_BRANCH: ${{ needs.validate-dispatch.outputs.expected_branch }} + INPUT_OPERATION: ${{ inputs.operation }} + run: | + set -euo pipefail + manifest="build/stable-1.0-maintenance.json" + jq -e \ + --arg release "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg release_class "$INPUT_RELEASE_CLASS" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg branch "$EXPECTED_BRANCH" \ + --arg operation "$INPUT_OPERATION" ' + .release.id == $release + and .release.version == $build + and .release.profile == "stable-review" + and .policies.releaseClass == $release_class + and .policies.candidateSourceBranch == $branch + and .policies.candidateSourceCommit == $commit + and .policies.candidateSourceRef == ("commit:" + $commit) + and .execution.allowDirtyWorkspace == false + and .execution.fixtureEvidence == false + and .execution.allowTestSigningInProduction == false + and .commands["stable-maintenance"].mode + == (if $operation == "validate-authorization" then "validate-only" else "prepare-authorization" end)' \ + "$manifest" >/dev/null + if [[ -n "$(git status --porcelain=v1 --untracked-files=all)" ]]; then + # All generated material is under ignored build/, so any result here is source drift. + echo "::error::Source state changed before candidate validation." + exit 1 + fi + + - name: Validate exact frozen candidate without publication + if: inputs.operation == 'prepare-authorization' || inputs.operation == 'validate-authorization' + run: >- + python3 tools/release-certification/certify.py stable-maintenance + --manifest build/stable-1.0-maintenance.json + + - name: Prepare exact authorization material without publication + if: inputs.operation == 'prepare-authorization' + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + run: | + set -euo pipefail + source="build/release-certification/$INPUT_RELEASE_ID/stable-maintenance" + destination="build/stable-maintenance-authorization/$INPUT_RELEASE_ID/stable-maintenance" + mkdir -p "$(dirname "$destination")" + cp -R "$source" "$destination" + + - name: Verify final frozen candidate and exact prior identity + if: inputs.operation == 'prepare-authorization' || inputs.operation == 'validate-authorization' + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_OPERATION: ${{ inputs.operation }} + run: | + set -euo pipefail + component="build/release-certification/$INPUT_RELEASE_ID/stable-maintenance" + legacy="$component/artifacts/legacy" + jq -e \ + --arg release "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg operation "$INPUT_OPERATION" ' + .schemaVersion == 2 + and .kind == "stable-1.0-maintenance-promotion" + and .subject.releaseId == $release + and .subject.version == $build + and .subject.profile == "stable-review" + and .subject.component == "stable-maintenance" + and .result.status == "pass" + and .result.exitCode == 0 + and .result.promotionReady == ($operation == "validate-authorization") + and .redaction.status == "pass" + and .redaction.findingCount == 0' "$component/summary.json" >/dev/null + jq -e '.status == "pass" and .findingCount == 0 and .findings == []' \ + "$component/redaction-report.json" >/dev/null + required=( + stable-1.0-maintenance-lineage.json + stable-1.0-maintenance-candidate.json + stable-1.0-maintenance-comparison.json + stable-1.0-maintenance-validation.json + stable-1.0-maintenance-promotion-summary.json + stable-1.0-maintenance-publication-plan.json + stable-1.0-maintenance-checksums.txt + stable-1.0-maintenance-provenance.json + core-info.json + core-update-publication-plan.json + ) + for name in "${required[@]}"; do + if [[ -L "$legacy/$name" || ! -f "$legacy/$name" ]]; then + echo "::error::Required frozen candidate artifact $name is missing or unsafe." + exit 1 + fi + done + jq -e \ + --arg release "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg release_class "$INPUT_RELEASE_CLASS" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" ' + .releaseId == $release + and .buildVersion == $build + and .releaseClass == $release_class + and .source.commit == $commit + and .redaction.status == "pass"' \ + "$legacy/stable-1.0-maintenance-candidate.json" >/dev/null + if [[ "$INPUT_OPERATION" == validate-authorization ]]; then + prior="build/prior-validated-candidate/component/artifacts/legacy" + immutable=( + stable-1.0-maintenance-lineage.json + stable-1.0-maintenance-candidate.json + stable-1.0-maintenance-comparison.json + stable-1.0-maintenance-known-limitations.json + stable-1.0-maintenance-release-notes.md + stable-1.0-maintenance-checksums.txt + stable-1.0-maintenance-provenance.json + core-info.json + ) + for name in "${immutable[@]}"; do + if ! cmp --silent "$prior/$name" "$legacy/$name"; then + echo "::error::Prepublication validation changed frozen artifact $name." + exit 1 + fi + done + while read -r digest name; do + if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ \ + || "sha256:$(sha256sum "$legacy/$name" | awk '{print $1}')" \ + != "sha256:$digest" ]]; then + echo "::error::A checksum-bound candidate byte changed before publication." + exit 1 + fi + done < "$legacy/stable-1.0-maintenance-checksums.txt" + fi + + - name: Stage exact redaction-safe candidate + id: stage + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_OPERATION: ${{ inputs.operation }} + run: | + set -euo pipefail + root="build/stable-maintenance-validation-artifact" + rm -rf "$root" + mkdir -p "$root" + if [[ "$INPUT_OPERATION" == freeze-candidate ]]; then + mkdir -p "$root/freeze" "$root/packages" + cp -R build/stable-maintenance-freeze/. "$root/freeze/" + cp -R build/stable-maintenance-packages/. "$root/packages/" + artifact_name="stable-1-0-maintenance-frozen-$INPUT_RELEASE_ID-$INPUT_BUILD_VERSION-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + elif [[ "$INPUT_OPERATION" == publish ]]; then + cp -R build/prior-validated-candidate/. "$root/" + artifact_name="stable-1-0-maintenance-publish-input-$INPUT_RELEASE_ID-$INPUT_BUILD_VERSION-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + else + component="build/release-certification/$INPUT_RELEASE_ID/stable-maintenance" + mkdir -p "$root/component" "$root/packages" "$root/authenticated-inputs" + cp -R "$component/." "$root/component/" + cp -R build/stable-maintenance-packages/. "$root/packages/" + cp build/stable-1.0-maintenance.json "$root/stable-1.0-maintenance.json" + cp -R build/protected-inputs "$root/protected-inputs" + artifact_name="stable-1-0-maintenance-prepared-$INPUT_RELEASE_ID-$INPUT_BUILD_VERSION-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + if [[ "$INPUT_OPERATION" == validate-authorization ]]; then + artifact_name="stable-1-0-maintenance-authorized-$INPUT_RELEASE_ID-$INPUT_BUILD_VERSION-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + fi + fi + stage_authenticated_input() { + local key="$1" + local output_name="$2" + local configured + configured="$(jq -er --arg key "$key" '.inputs[$key]' \ + build/stable-1.0-maintenance.json)" + if ! python3 - "$configured" <<'PY' + import sys + from pathlib import Path + + root = Path("build/protected-inputs").resolve(strict=True) + path = Path(sys.argv[1]) + if path.is_absolute() or path.is_symlink() or not path.is_file(): + raise SystemExit(1) + try: + path.resolve(strict=True).relative_to(root) + except (OSError, ValueError): + raise SystemExit(1) from None + PY + then + echo "::error::Authenticated input $key is missing or escapes the protected-input root." + exit 1 + fi + cp "$configured" "$root/authenticated-inputs/$output_name" + } + if [[ "$INPUT_OPERATION" == prepare-authorization \ + || "$INPUT_OPERATION" == validate-authorization ]]; then + stage_authenticated_input maintenanceCandidate maintenance-candidate-input.json + stage_authenticated_input maintenanceCandidateFreeze maintenance-candidate-freeze.json + stage_authenticated_input stableGaMaintenanceBaseline stable-ga-maintenance-baseline.json + stage_authenticated_input predecessorBaseline predecessor-maintenance-baseline.json + stage_authenticated_input maintenanceEvidence maintenance-evidence.json + if jq -e \ + '.inputs.hotfixFollowUpClosure? | type == "string" and length > 0' \ + build/stable-1.0-maintenance.json >/dev/null; then + stage_authenticated_input \ + hotfixFollowUpClosure stable-1.0-hotfix-follow-up-closure.json + fi + fi + if [[ "$INPUT_OPERATION" == prepare-authorization ]]; then + authorization_component="build/stable-maintenance-authorization/$INPUT_RELEASE_ID/stable-maintenance" + if [[ ! -d "$authorization_component" ]]; then + echo "::error::prepare-authorization did not produce its component." + exit 1 + fi + mkdir -p "$root/authorization-component" + cp -R "$authorization_component/." "$root/authorization-component/" + fi + if [[ -n "$(find "$root" -type l -print -quit)" \ + || -n "$(find "$root" -mindepth 1 ! -type f ! -type d -print -quit)" ]]; then + echo "::error::Staged candidate contains a link or special entry." + exit 1 + fi + echo "artifact_name=$artifact_name" >> "$GITHUB_OUTPUT" + + - name: Upload exact validated candidate + id: upload + uses: actions/upload-artifact@v6 + with: + name: ${{ steps.stage.outputs.artifact_name }} + path: build/stable-maintenance-validation-artifact/ + if-no-files-found: error + retention-days: 90 + + attest-evidence: + name: Attest exact Stable maintenance phase output + if: inputs.operation != 'publish' + needs: + - validate-dispatch + - freeze-and-validate + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: stable-1.0-maintenance-evidence + permissions: + contents: read + actions: read + attestations: write + artifact-metadata: write + id-token: write + + steps: + - name: Download exact validated candidate from this run + uses: actions/download-artifact@v8 + with: + name: ${{ needs.freeze-and-validate.outputs.artifact_name }} + path: build/stable-maintenance-evidence-attestation + digest-mismatch: error + + - name: Verify protected attestation subjects + if: inputs.operation == 'prepare-authorization' + run: | + set -euo pipefail + root="build/stable-maintenance-evidence-attestation" + subjects=( + "$root/component/artifacts/legacy/stable-1.0-maintenance-candidate.json" + "$root/component/artifacts/legacy/stable-1.0-maintenance-validation.json" + "$root/authorization-component/artifacts/legacy/stable-1.0-maintenance-authorization-summary.json" + "$root/authorization-component/artifacts/legacy/stable-1.0-maintenance-publication-plan.json" + "$root/authorization-component/artifacts/legacy/core-info.json" + ) + for subject in "${subjects[@]}"; do + if [[ -L "$subject" || ! -f "$subject" ]]; then + echo "::error::Protected maintenance attestation subject is missing or unsafe." + exit 1 + fi + done + jq -e '.result.status == "pass" and .redaction.status == "pass"' \ + "$root/component/summary.json" >/dev/null + jq -e '.result.status == "pass" and .redaction.status == "pass"' \ + "$root/authorization-component/summary.json" >/dev/null + + - name: Attest candidate, authorization, publication targets, and updater descriptor + if: inputs.operation == 'prepare-authorization' + id: attest-prepared + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: | + build/stable-maintenance-evidence-attestation/component/artifacts/legacy/stable-1.0-maintenance-candidate.json + build/stable-maintenance-evidence-attestation/component/artifacts/legacy/stable-1.0-maintenance-validation.json + build/stable-maintenance-evidence-attestation/authorization-component/artifacts/legacy/stable-1.0-maintenance-authorization-summary.json + build/stable-maintenance-evidence-attestation/authorization-component/artifacts/legacy/stable-1.0-maintenance-publication-plan.json + build/stable-maintenance-evidence-attestation/authorization-component/artifacts/legacy/core-info.json + + - name: Attest exact candidate freeze record and selected bytes + if: inputs.operation == 'freeze-candidate' + id: attest-freeze + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: | + build/stable-maintenance-evidence-attestation/freeze/stable-1.0-maintenance-candidate-freeze.json + build/stable-maintenance-evidence-attestation/freeze/checksums.txt + build/stable-maintenance-evidence-attestation/freeze/assets/* + + - name: Attest authorized candidate, publication plan, and updater descriptor + if: inputs.operation == 'validate-authorization' + id: attest-authorized + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: | + build/stable-maintenance-evidence-attestation/component/artifacts/legacy/stable-1.0-maintenance-candidate.json + build/stable-maintenance-evidence-attestation/component/artifacts/legacy/stable-1.0-maintenance-validation.json + build/stable-maintenance-evidence-attestation/component/artifacts/legacy/stable-1.0-maintenance-authorization-summary.json + build/stable-maintenance-evidence-attestation/component/artifacts/legacy/stable-1.0-maintenance-publication-plan.json + build/stable-maintenance-evidence-attestation/component/artifacts/legacy/core-info.json + + - name: Summarize side-effect-free evidence approval + run: | + echo "Attested the exact ${{ inputs.operation }} Stable maintenance output." >> "$GITHUB_STEP_SUMMARY" + echo "No tag, GitHub Release, catalog insert, CoreUpdater insert, announcement, branch creation, or merge occurred." >> "$GITHUB_STEP_SUMMARY" + + protected-publication: + name: Publish or verify exact authorized maintenance bytes + if: inputs.operation == 'publish' + needs: + - validate-dispatch + - freeze-and-validate + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: + name: >- + ${{ inputs.release_class == 'security-hotfix' + && 'stable-1.0-security-hotfix-publication' + || 'stable-1.0-maintenance-publication' }} + permissions: + contents: read + actions: read + attestations: read + outputs: + artifact_name: ${{ steps.stage-publication.outputs.artifact_name }} + artifact_digest: ${{ steps.upload-publication.outputs.artifact-digest }} + + steps: + - name: Check out exact authorized candidate + uses: actions/checkout@v6 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Materialize exact reviewed publication backend + id: publication-backend + uses: ./.github/actions/setup-stable-maintenance-publication-backend + with: + run-id: ${{ inputs.publication_backend_run_id }} + artifact-name: ${{ inputs.publication_backend_artifact_name }} + artifact-digest: ${{ inputs.publication_backend_artifact_digest }} + source-commit: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SOURCE_COMMIT }} + wheel-sha256: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_WHEEL_SHA256 }} + signer-workflow: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SIGNER_WORKFLOW }} + entrypoint: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND }} + github-token: ${{ github.token }} + + - name: Download exact revalidated candidate + uses: actions/download-artifact@v8 + with: + name: ${{ needs.freeze-and-validate.outputs.artifact_name }} + path: build/stable-maintenance-publication + digest-mismatch: error + + - name: Verify protected leumor GitHub publication identity + env: + LEUMOR_GITHUB_TOKEN: ${{ secrets.LEUMOR_GITHUB_TOKEN }} + run: | + set -euo pipefail + if [[ -z "$LEUMOR_GITHUB_TOKEN" ]]; then + echo "::error::The protected LEUMOR_GITHUB_TOKEN secret is required for publication." + exit 1 + fi + if [[ "$(GH_TOKEN="$LEUMOR_GITHUB_TOKEN" gh api user --jq .login)" != "leumor" ]]; then + echo "::error::Protected GitHub publication identity must be exactly leumor." + exit 1 + fi + + - name: Verify protected attestations and local exact-byte bindings + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_POINTER_DIGEST: ${{ inputs.expected_predecessor_pointer_digest }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + root="build/stable-maintenance-publication" + legacy="$root/component/artifacts/legacy" + candidate="$legacy/stable-1.0-maintenance-candidate.json" + authorization="$legacy/stable-1.0-maintenance-authorization-summary.json" + plan="$legacy/stable-1.0-maintenance-publication-plan.json" + checksums="$legacy/stable-1.0-maintenance-checksums.txt" + for path in "$candidate" "$authorization" "$plan" "$checksums" \ + "$legacy/core-info.json" "$legacy/core-update-publication-plan.json"; do + if [[ -L "$path" || ! -f "$path" ]]; then + echo "::error::Authorized publication input is missing or unsafe." + exit 1 + fi + done + signer="crypta-network/cryptad/.github/workflows/stable-1.0-maintenance-release.yml" + for subject in "$candidate" "$authorization" "$plan" "$legacy/core-info.json"; do + gh attestation verify "$subject" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$signer" \ + --source-digest "$INPUT_CANDIDATE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + done + jq -e \ + --arg release "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg release_class "$INPUT_RELEASE_CLASS" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" ' + .releaseId == $release + and .buildVersion == $build + and .releaseClass == $release_class + and .sourceCommit == $commit + and .publicationState == "publication-authorized" + and .sideEffectsPerformed == false + and .redaction.status == "pass"' "$plan" >/dev/null + expires_at="$(jq -er '.authorization.expiresAt // .expiresAt' "$authorization")" + python3 - "$expires_at" <<'PY' + import datetime as dt + import sys + + value = sys.argv[1] + try: + expiry = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise SystemExit("authorization expiry is malformed") from exc + if expiry <= dt.datetime.now(dt.timezone.utc): + raise SystemExit("authorization expired before the mutation boundary") + PY + if [[ ! "$INPUT_POINTER_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Authorized predecessor pointer digest is malformed." + exit 1 + fi + if [[ -n "$(find "$root" -type l -print -quit)" \ + || -n "$(find "$root" -mindepth 1 ! -type f ! -type d -print -quit)" ]]; then + echo "::error::Publication bundle contains a link or special entry." + exit 1 + fi + + - name: Preflight predecessor, authorization, freeze, and public conflicts + id: preflight + continue-on-error: true + env: + CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND: ${{ steps.publication-backend.outputs.entrypoint }} + CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SITE: ${{ steps.publication-backend.outputs.site-directory }} + PYTHONPATH: ${{ steps.publication-backend.outputs.python-path }} + INPUT_POINTER_DIGEST: ${{ inputs.expected_predecessor_pointer_digest }} + GITHUB_TOKEN: ${{ secrets.LEUMOR_GITHUB_TOKEN }} + run: | + set -euo pipefail + if [[ -z "$CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND" ]]; then + echo "::error::Protected Stable maintenance publication backend was not configured." + exit 1 + fi + adapter="tools/release-certification/protected/stable_maintenance_publication.py" + if [[ ! -f "$adapter" ]]; then + echo "::error::Protected Stable maintenance publication adapter is not materialized." + exit 1 + fi + python3 "$adapter" \ + --mode preflight-only \ + --bundle build/stable-maintenance-publication \ + --no-protected-inputs \ + --expected-predecessor-pointer-digest "$INPUT_POINTER_DIGEST" \ + --check-latest-predecessor \ + --check-authorization-expiry \ + --check-exact-freeze-bytes \ + --check-tag-release-artifact-catalog-and-updater-conflicts \ + --idempotency exact-match-only \ + --conflict-action fail \ + --no-side-effects \ + --out build/stable-maintenance-publication-preflight.json + jq -e ' + .status == "pass" + and .sideEffectsPerformed == false + and .latestPredecessorUnchanged == true + and .authorizationCurrent == true + and .freezeBytesExact == true + and (.publicState == "matching-existing" + or .publicState == "resumable-prefix") + and .redaction.status == "pass"' \ + build/stable-maintenance-publication-preflight.json >/dev/null + + - name: Stage publication preflight evidence + id: stage-preflight + if: always() + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + PREFLIGHT_OUTCOME: ${{ steps.preflight.outcome }} + run: | + set -euo pipefail + preflight="build/stable-maintenance-publication-preflight.json" + if [[ -L "$preflight" ]]; then + rm -f "$preflight" + fi + if [[ ! -f "$preflight" ]]; then + legacy="build/stable-maintenance-publication/component/artifacts/legacy" + plan="$legacy/stable-1.0-maintenance-publication-plan.json" + if [[ -L "$plan" || ! -f "$plan" ]]; then + echo "::error::Failed preflight cannot bind its audit to the publication plan." + exit 1 + fi + jq -n \ + --arg generated_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg release_class "$INPUT_RELEASE_CLASS" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg candidate_digest "$(jq -er '.candidateIdentityDigest' "$plan")" \ + --arg outcome "$PREFLIGHT_OUTCOME" ' + { + schemaVersion: 1, + kind: "stable-1.0-maintenance-publication-preflight", + generatedAt: $generated_at, + releaseId: $release_id, + buildVersion: $build, + releaseClass: $release_class, + sourceCommit: $commit, + candidateIdentityDigest: $candidate_digest, + status: "fail", + sideEffectsPerformed: false, + latestPredecessorUnchanged: false, + authorizationCurrent: false, + freezeBytesExact: false, + publicState: "unavailable", + publicStateObservation: { + tag: "unavailable", + githubRelease: "unavailable", + assets: "unavailable", + artifactBase: "unavailable", + stableCatalog: "unavailable", + coreUpdate: "unavailable" + }, + failureCategory: "preflight-execution-failure", + workflowOutcome: $outcome, + redaction: {status: "pass", findingCount: 0, findings: []} + }' > "$preflight" + fi + root="build/stable-maintenance-publication-preflight-audit" + rm -rf "$root" + mkdir -p "$root" + cp "$preflight" "$root/" + artifact_name="stable-1-0-maintenance-preflight-$INPUT_RELEASE_ID-$INPUT_BUILD_VERSION-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + echo "artifact_name=$artifact_name" >> "$GITHUB_OUTPUT" + + - name: Upload publication preflight evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: ${{ steps.stage-preflight.outputs.artifact_name }} + path: build/stable-maintenance-publication-preflight-audit/ + if-no-files-found: error + retention-days: 90 + + - name: Fail after retaining failed preflight evidence + if: steps.preflight.outcome != 'success' + run: | + echo "::error::Protected publication preflight failed; its side-effect-free audit was retained." + exit 1 + + - name: Publish or idempotently verify exact bytes + id: publication + if: steps.preflight.outcome == 'success' + continue-on-error: true + env: + CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND: ${{ steps.publication-backend.outputs.entrypoint }} + CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SITE: ${{ steps.publication-backend.outputs.site-directory }} + PYTHONPATH: ${{ steps.publication-backend.outputs.python-path }} + CRYPTAD_STABLE_CATALOG_PUBLICATION_INPUT: ${{ secrets.CRYPTAD_STABLE_CATALOG_PUBLICATION_INPUT }} + CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT: ${{ secrets.CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT }} + CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_BRANCH: ${{ needs.validate-dispatch.outputs.expected_branch }} + CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_COMMIT: ${{ inputs.candidate_commit }} + CRYPTAD_STABLE_MAINTENANCE_EXPECTED_REPOSITORY: ${{ github.repository }} + INPUT_POINTER_DIGEST: ${{ inputs.expected_predecessor_pointer_digest }} + GITHUB_TOKEN: ${{ secrets.LEUMOR_GITHUB_TOKEN }} + run: | + set -euo pipefail + if [[ -z "$CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND" ]]; then + echo "::error::Protected Stable maintenance publication backend was not configured." + exit 1 + fi + if [[ -z "$CRYPTAD_STABLE_CATALOG_PUBLICATION_INPUT" ]]; then + echo "::error::Protected stable catalog publication input was not materialized." + exit 1 + fi + if [[ -z "$CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT" ]]; then + echo "::error::Protected CoreUpdater publication input was not materialized." + exit 1 + fi + encoded_branch="${CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_BRANCH//\//%2F}" + current_branch_commit="$(gh api --method GET \ + "repos/$GITHUB_REPOSITORY/git/ref/heads/$encoded_branch" --jq '.object.sha')" + if [[ "$current_branch_commit" != "$CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_COMMIT" ]]; then + echo "::error::The remote release/hotfix ref moved before publication." + exit 1 + fi + adapter="tools/release-certification/protected/stable_maintenance_publication.py" + marker="build/stable-maintenance-publication-side-effect-started" + # The marker is written only after the complete no-side-effect preflight passed. + : > "$marker" + python3 "$adapter" \ + --mode publish-or-verify-exact \ + --bundle build/stable-maintenance-publication \ + --catalog-input-env CRYPTAD_STABLE_CATALOG_PUBLICATION_INPUT \ + --core-update-input-env CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT \ + --expected-predecessor-pointer-digest "$INPUT_POINTER_DIGEST" \ + --expected-source-repository "$GITHUB_REPOSITORY" \ + --expected-source-branch "$CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_BRANCH" \ + --expected-source-commit "$CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_COMMIT" \ + --github-token-env GITHUB_TOKEN \ + --recheck-remote-source-ref \ + --revalidate-before-each-mutation \ + --idempotency exact-match-only \ + --conflict-action fail \ + --partial-state-action record-only \ + --forbid-overwrite \ + --forbid-delete-recovery \ + --receipt build/stable-1.0-maintenance-publication-receipt.json \ + --core-update-receipt build/core-update-publication-receipt.json + + - name: Record truthful partial state after a failed protected operation + if: steps.publication.outcome != 'success' + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + run: | + set -euo pipefail + receipt="build/stable-1.0-maintenance-publication-receipt.json" + audit="build/stable-1.0-maintenance-publication-failure-audit.json" + failure_category="pre-publication-verification-failure" + if [[ -f build/stable-maintenance-publication-side-effect-started ]]; then + failure_category="side-effecting-publication-failure" + fi + if [[ ! -f "$receipt" && ! -f "$audit" ]]; then + legacy="build/stable-maintenance-publication/component/artifacts/legacy" + plan="$legacy/stable-1.0-maintenance-publication-plan.json" + candidate="$legacy/stable-1.0-maintenance-candidate.json" + if [[ -L "$plan" || ! -f "$plan" || -L "$candidate" || ! -f "$candidate" ]]; then + echo "::error::Failed publication cannot bind its failure audit to the candidate plan." + exit 1 + fi + jq -n \ + --arg generated_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg release_class "$INPUT_RELEASE_CLASS" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg candidate_digest "$(jq -er '.candidateIdentityDigest' "$plan")" \ + --arg product_digest "$(jq -er '.productDigest' "$plan")" \ + --arg plan_digest "sha256:$(sha256sum "$plan" | awk '{print $1}')" \ + --arg failure_category "$failure_category" ' + { + schemaVersion: 1, + kind: "stable-1.0-maintenance-publication-failure-audit", + generatedAt: $generated_at, + releaseId: $release_id, + buildVersion: $build, + releaseClass: $release_class, + sourceCommit: $commit, + candidateIdentityDigest: $candidate_digest, + productDigest: $product_digest, + publicationPlanDigest: $plan_digest, + publicationState: "unknown-or-partial", + failureCategory: $failure_category, + predecessorPointerDigest: null, + latestCandidateIdentityDigest: null, + observedPublicState: { + tag: "unavailable", + githubRelease: "unavailable", + assets: "unavailable", + artifactBase: "unavailable", + stableCatalog: "unavailable", + coreUpdate: "unavailable" + }, + attemptedTargets: ( + if $failure_category == "side-effecting-publication-failure" + then ["artifactBase", "tag", "githubRelease", "assets", "stableCatalog", "coreUpdate"] + else [] + end + ), + completedTargets: [], + sideEffectsMayHaveOccurred: ($failure_category == "side-effecting-publication-failure"), + finalVerificationStatus: "fail", + redaction: {status: "pass", findingCount: 0, findings: []} + }' > "$audit" + fi + + - name: Validate publication audit artifacts + env: + PUBLICATION_OUTCOME: ${{ steps.publication.outcome }} + run: | + set -euo pipefail + receipt="build/stable-1.0-maintenance-publication-receipt.json" + audit="build/stable-1.0-maintenance-publication-failure-audit.json" + if [[ "$PUBLICATION_OUTCOME" == success ]]; then + if [[ -L "$receipt" || ! -f "$receipt" ]]; then + echo "::error::Successful protected publication omitted its canonical receipt." + exit 1 + fi + jq -e ' + .publicationState == "publication-complete" + and .finalVerificationStatus == "pass" + and .redaction.status == "pass" + and .redaction.findingCount == 0' "$receipt" >/dev/null + elif [[ -f "$audit" && ! -L "$audit" ]]; then + jq -e ' + .kind == "stable-1.0-maintenance-publication-failure-audit" + and (.publicationState == "unknown-or-partial" + or .publicationState == "publication-partial" + or .publicationState == "publication-verification-failed") + and .finalVerificationStatus == "fail" + and .redaction.status == "pass" + and .redaction.findingCount == 0' "$audit" >/dev/null + else + echo "::error::Failed protected publication omitted its truthful failure audit." + exit 1 + fi + PYTHONPATH=tools/release-certification python3 - "$receipt" "$audit" <<'PY' + import sys + from pathlib import Path + + from cryptad_certification.io import read_json + from cryptad_certification.schema_validation import validate_schema + + receipt = Path(sys.argv[1]) + audit = Path(sys.argv[2]) + if receipt.is_file(): + schemas = ( + (receipt, "stable-1.0-maintenance-publication-receipt-v1.schema.json"), + ( + receipt.with_name("core-update-publication-receipt.json"), + "cryptad-core-update-publication-receipt-v1.schema.json", + ), + ) + else: + schemas = ( + ( + audit, + "stable-1.0-maintenance-publication-failure-audit-v1.schema.json", + ), + ) + if any(not path.is_file() or validate_schema(read_json(path), schema) for path, schema in schemas): + raise SystemExit("publication audit artifact failed its closed schema") + PY + + - name: Stage publication receipt or partial-state audit record + id: stage-publication + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + run: | + set -euo pipefail + root="build/stable-maintenance-publication-audit" + rm -rf "$root" + mkdir -p "$root" + if [[ -f build/stable-1.0-maintenance-publication-receipt.json ]]; then + cp build/stable-1.0-maintenance-publication-receipt.json "$root/" + fi + if [[ -f build/stable-1.0-maintenance-publication-failure-audit.json ]]; then + cp build/stable-1.0-maintenance-publication-failure-audit.json "$root/" + fi + if [[ -f build/core-update-publication-receipt.json ]]; then + cp build/core-update-publication-receipt.json "$root/" + fi + cp build/stable-maintenance-publication-preflight.json "$root/" + cp -R build/stable-maintenance-publication/component "$root/component" + cp -R build/stable-maintenance-publication/authenticated-inputs "$root/authenticated-inputs" + artifact_name="stable-1-0-maintenance-publication-$INPUT_RELEASE_ID-$INPUT_BUILD_VERSION-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + echo "artifact_name=$artifact_name" >> "$GITHUB_OUTPUT" + + - name: Upload publication audit record + id: upload-publication + uses: actions/upload-artifact@v6 + with: + name: ${{ steps.stage-publication.outputs.artifact_name }} + path: build/stable-maintenance-publication-audit/ + if-no-files-found: error + retention-days: 90 + + - name: Fail after retaining partial state + if: steps.publication.outcome != 'success' + run: | + echo "::error::Protected publication failed; exact observed partial state was retained without overwrite or deletion." + exit 1 + + independent-verification: + name: Independently verify exact public bytes + if: inputs.operation == 'publish' + needs: protected-publication + runs-on: ubuntu-latest + environment: stable-1.0-maintenance-evidence + timeout-minutes: 45 + permissions: + contents: read + actions: read + attestations: read + outputs: + artifact_name: ${{ steps.stage.outputs.artifact_name }} + artifact_digest: ${{ steps.upload.outputs.artifact-digest }} + + steps: + - name: Check out exact candidate verifier + uses: actions/checkout@v6 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Materialize exact reviewed verification backend + id: publication-backend + uses: ./.github/actions/setup-stable-maintenance-publication-backend + with: + run-id: ${{ inputs.publication_backend_run_id }} + artifact-name: ${{ inputs.publication_backend_artifact_name }} + artifact-digest: ${{ inputs.publication_backend_artifact_digest }} + source-commit: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SOURCE_COMMIT }} + wheel-sha256: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_WHEEL_SHA256 }} + signer-workflow: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SIGNER_WORKFLOW }} + entrypoint: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND }} + github-token: ${{ github.token }} + + - name: Download exact publication audit artifact + uses: actions/download-artifact@v8 + with: + name: ${{ needs.protected-publication.outputs.artifact_name }} + path: build/stable-maintenance-independent-verification + digest-mismatch: error + + - name: Fetch and verify public tag, assets, catalog, updater, and package identities + id: verify-publication + continue-on-error: true + env: + CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND: ${{ steps.publication-backend.outputs.entrypoint }} + CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SITE: ${{ steps.publication-backend.outputs.site-directory }} + PYTHONPATH: ${{ steps.publication-backend.outputs.python-path }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ -z "$CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND" ]]; then + echo "::error::Protected Stable maintenance verification backend was not configured." + exit 1 + fi + adapter="tools/release-certification/protected/stable_maintenance_publication.py" + if [[ ! -f "$adapter" ]]; then + echo "::error::Independent public-state verifier is not materialized." + exit 1 + fi + python3 "$adapter" \ + --mode verify-public-state-only \ + --bundle build/stable-maintenance-independent-verification \ + --no-protected-inputs \ + --verify-annotated-tag \ + --verify-github-release-and-assets \ + --verify-artifact-base \ + --verify-stable-catalog-primary-mirrors-and-rollback \ + --verify-core-info-exact-bytes \ + --verify-every-core-info-package-reference \ + --forbid-unexpected-assets \ + --receipt build/stable-1.0-maintenance-publication-receipt.json \ + --core-update-receipt build/core-update-publication-receipt.json \ + --successor-baseline build/stable-1.0-maintenance-successor-baseline.json \ + --history-entry build/stable-1.0-maintenance-history-entry.json + jq -e ' + .publicationState == "publication-complete" + and .finalVerificationStatus == "pass" + and .redaction.status == "pass"' \ + build/stable-1.0-maintenance-publication-receipt.json >/dev/null + jq -e ' + .publicationState == "publication-complete" + and .verificationStatus == "pass" + and .redaction.status == "pass"' \ + build/core-update-publication-receipt.json >/dev/null + + - name: Stage independently verified publication and successor + id: stage + if: always() + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + VERIFICATION_OUTCOME: ${{ steps.verify-publication.outcome }} + run: | + set -euo pipefail + root="build/stable-maintenance-verified-publication" + rm -rf "$root" + mkdir -p "$root" + artifact_name="stable-1-0-maintenance-verified-$INPUT_RELEASE_ID-$INPUT_BUILD_VERSION-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + echo "artifact_name=$artifact_name" >> "$GITHUB_OUTPUT" + legacy="build/stable-maintenance-independent-verification/component/artifacts/legacy" + plan="$legacy/stable-1.0-maintenance-publication-plan.json" + if [[ -L "$plan" || ! -f "$plan" ]]; then + echo "::error::Independent verification cannot bind its audit to the publication plan." + exit 1 + fi + jq -n \ + --arg generated_at "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ + --arg release_id "$INPUT_RELEASE_ID" \ + --arg build "$INPUT_BUILD_VERSION" \ + --arg release_class "$INPUT_RELEASE_CLASS" \ + --arg commit "$INPUT_CANDIDATE_COMMIT" \ + --arg candidate_digest "$(jq -er '.candidateIdentityDigest' "$plan")" \ + --arg outcome "$VERIFICATION_OUTCOME" ' + { + schemaVersion: 1, + kind: "stable-1.0-maintenance-independent-verification-audit", + generatedAt: $generated_at, + releaseId: $release_id, + buildVersion: $build, + releaseClass: $release_class, + sourceCommit: $commit, + candidateIdentityDigest: $candidate_digest, + verificationStatus: (if $outcome == "success" then "pass" else "fail" end), + workflowOutcome: $outcome, + sideEffectsPerformed: false, + redaction: {status: "pass", findingCount: 0, findings: []} + }' > "$root/stable-1.0-maintenance-independent-verification-audit.json" + for path in \ + build/stable-1.0-maintenance-publication-receipt.json \ + build/core-update-publication-receipt.json \ + build/stable-1.0-maintenance-successor-baseline.json \ + build/stable-1.0-maintenance-history-entry.json \ + build/stable-1.0-maintenance-publication-failure-audit.json; do + if [[ -f "$path" && ! -L "$path" ]]; then + cp "$path" "$root/" + fi + done + authorization_source="build/stable-maintenance-independent-verification/component/artifacts/legacy/stable-1.0-maintenance-authorization-summary.json" + if [[ -f "$authorization_source" && ! -L "$authorization_source" ]]; then + cp "$authorization_source" "$root/" + elif [[ "$VERIFICATION_OUTCOME" == success ]]; then + echo "::error::Independent verification input omitted the exact published authorization." + exit 1 + fi + if [[ "$VERIFICATION_OUTCOME" == success ]]; then + for path in \ + "$root/stable-1.0-maintenance-publication-receipt.json" \ + "$root/core-update-publication-receipt.json" \ + "$root/stable-1.0-maintenance-successor-baseline.json" \ + "$root/stable-1.0-maintenance-history-entry.json"; do + if [[ -L "$path" || ! -f "$path" ]]; then + echo "::error::Successful independent verification omitted a required record." + exit 1 + fi + done + fi + + - name: Upload independently verified publication + id: upload + if: always() + uses: actions/upload-artifact@v6 + with: + name: ${{ steps.stage.outputs.artifact_name }} + path: build/stable-maintenance-verified-publication/ + if-no-files-found: error + retention-days: 90 + + - name: Fail after retaining independent-verification evidence + if: steps.verify-publication.outcome != 'success' + run: | + echo "::error::Independent public verification failed; its audit evidence was retained." + exit 1 + + activate-latest-baseline: + name: Atomically activate verified successor baseline + if: inputs.operation == 'publish' + needs: independent-verification + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: >- + ${{ inputs.release_class == 'security-hotfix' + && 'stable-1.0-security-hotfix-publication' + || 'stable-1.0-maintenance-publication' }} + permissions: + contents: read + actions: read + attestations: read + + steps: + - name: Check out exact activation verifier + uses: actions/checkout@v6 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Materialize exact reviewed activation backend + id: publication-backend + uses: ./.github/actions/setup-stable-maintenance-publication-backend + with: + run-id: ${{ inputs.publication_backend_run_id }} + artifact-name: ${{ inputs.publication_backend_artifact_name }} + artifact-digest: ${{ inputs.publication_backend_artifact_digest }} + source-commit: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SOURCE_COMMIT }} + wheel-sha256: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_WHEEL_SHA256 }} + signer-workflow: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SIGNER_WORKFLOW }} + entrypoint: ${{ vars.CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND }} + github-token: ${{ github.token }} + + - name: Download independently verified successor + uses: actions/download-artifact@v8 + with: + name: ${{ needs.independent-verification.outputs.artifact_name }} + path: build/stable-maintenance-baseline-activation + digest-mismatch: error + + - name: Compare-and-swap latest-published pointer + id: activation + continue-on-error: true + env: + CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND: ${{ steps.publication-backend.outputs.entrypoint }} + CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SITE: ${{ steps.publication-backend.outputs.site-directory }} + PYTHONPATH: ${{ steps.publication-backend.outputs.python-path }} + CRYPTAD_STABLE_MAINTENANCE_STATE_INPUT: ${{ secrets.CRYPTAD_STABLE_MAINTENANCE_STATE_INPUT }} + CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_BRANCH: ${{ inputs.release_class == 'security-hotfix' && format('hotfix/{0}', inputs.build_version) || format('release/{0}', inputs.build_version) }} + CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_COMMIT: ${{ inputs.candidate_commit }} + CRYPTAD_STABLE_MAINTENANCE_EXPECTED_REPOSITORY: ${{ github.repository }} + INPUT_POINTER_DIGEST: ${{ inputs.expected_predecessor_pointer_digest }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [[ -z "$CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND" ]]; then + echo "::error::Protected Stable maintenance activation backend was not configured." + exit 1 + fi + if [[ -z "$CRYPTAD_STABLE_MAINTENANCE_STATE_INPUT" ]]; then + echo "::error::Protected activation input was not materialized." + exit 1 + fi + adapter="tools/release-certification/protected/stable_maintenance_publication.py" + if [[ ! -f "$adapter" ]]; then + echo "::error::Protected latest-baseline activation adapter is not materialized." + exit 1 + fi + baseline="build/stable-maintenance-baseline-activation/stable-1.0-maintenance-successor-baseline.json" + history="build/stable-maintenance-baseline-activation/stable-1.0-maintenance-history-entry.json" + receipt="build/stable-maintenance-baseline-activation/stable-1.0-maintenance-publication-receipt.json" + authorization="build/stable-maintenance-baseline-activation/stable-1.0-maintenance-authorization-summary.json" + activation_authorization="build/stable-maintenance-baseline-activation/stable-1.0-maintenance-activation-authorization.json" + for path in "$baseline" "$history" "$receipt" "$authorization"; do + if [[ -L "$path" || ! -f "$path" ]]; then + echo "::error::Verified activation input is missing or unsafe." + exit 1 + fi + done + jq -e '.publicationState == "publication-complete" and .finalVerificationStatus == "pass"' \ + "$receipt" >/dev/null + authorization_digest="sha256:$(sha256sum "$authorization" | awk '{print $1}')" + if [[ "$authorization_digest" != "$(jq -er '.authorizationDigest' "$receipt")" ]]; then + echo "::error::Activation authorization differs from the exact published authorization." + exit 1 + fi + gh attestation verify "$authorization" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "crypta-network/cryptad/.github/workflows/stable-1.0-maintenance-release.yml" \ + --source-digest "$INPUT_CANDIDATE_COMMIT" \ + --deny-self-hosted-runners >/dev/null + python3 - \ + "$baseline" \ + "$history" \ + "$receipt" \ + "$authorization" \ + "$activation_authorization" \ + "$INPUT_POINTER_DIGEST" \ + "$GITHUB_RUN_ID" \ + "$GITHUB_RUN_ATTEMPT" <<'PY' + import datetime as dt + import hashlib + import json + import os + from pathlib import Path + import sys + + ( + baseline_path, + history_path, + receipt_path, + original_authorization_path, + output_path, + ) = (Path(value) for value in sys.argv[1:6]) + expected_pointer_digest, run_id, run_attempt = sys.argv[6:9] + + def read_json(path: Path) -> dict[str, object]: + return json.loads(path.read_text(encoding="utf-8")) + + def digest(path: Path) -> str: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + receipt_value = read_json(receipt_path) + release_class = receipt_value["releaseClass"] + protected_environment = ( + "stable-1.0-maintenance-publication" + if release_class == "maintenance" + else "stable-1.0-security-hotfix-publication" + ) + now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + expires = now + dt.timedelta(minutes=30) + timestamp = lambda value: value.isoformat().replace("+00:00", "Z") + authorization = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-activation-authorization", + "generatedAt": timestamp(now), + "authorizationId": f"activation-{run_id}-{run_attempt}", + "authority": "github-protected-environment", + "protectedEnvironment": protected_environment, + "workflowRepository": os.environ["GITHUB_REPOSITORY"], + "workflowRunId": run_id, + "workflowRunAttempt": int(run_attempt), + "sourceCommit": receipt_value["sourceCommit"], + "releaseId": receipt_value["releaseId"], + "buildVersion": receipt_value["buildVersion"], + "releaseClass": release_class, + "candidateIdentityDigest": receipt_value["candidateIdentityDigest"], + "publicationReceiptDigest": digest(receipt_path), + "successorBaselineDigest": digest(baseline_path), + "historyDigest": digest(history_path), + "originalAuthorizationDigest": digest(original_authorization_path), + "expectedCurrentPointerDigest": expected_pointer_digest, + "allowedScope": "successor-baseline:activate", + "authorizedAt": timestamp(now), + "expiresAt": timestamp(expires), + "status": "approved", + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + } + if output_path.exists() or output_path.is_symlink(): + raise SystemExit("refusing to replace an activation authorization") + output_path.write_text( + json.dumps(authorization, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + output_path.chmod(0o644) + PY + encoded_branch="${CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_BRANCH//\//%2F}" + current_branch_commit="$(gh api --method GET \ + "repos/$GITHUB_REPOSITORY/git/ref/heads/$encoded_branch" --jq '.object.sha')" + if [[ "$current_branch_commit" != "$CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_COMMIT" ]]; then + echo "::error::The remote release/hotfix ref moved before baseline activation." + exit 1 + fi + activation_marker="build/stable-maintenance-baseline-activation-started" + if [[ -e "$activation_marker" || -L "$activation_marker" ]]; then + echo "::error::Refusing to replace an existing activation-boundary marker." + exit 1 + fi + (umask 022; : > "$activation_marker") + if [[ -L "$activation_marker" || ! -f "$activation_marker" ]]; then + echo "::error::The activation-boundary marker is unsafe." + exit 1 + fi + python3 "$adapter" \ + --mode activate-latest-baseline \ + --maintenance-state-input-env CRYPTAD_STABLE_MAINTENANCE_STATE_INPUT \ + --expected-current-pointer-digest "$INPUT_POINTER_DIGEST" \ + --expected-source-repository "$GITHUB_REPOSITORY" \ + --expected-source-branch "$CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_BRANCH" \ + --expected-source-commit "$CRYPTAD_STABLE_MAINTENANCE_EXPECTED_SOURCE_COMMIT" \ + --github-token-env GITHUB_TOKEN \ + --recheck-remote-source-ref \ + --successor-baseline "$baseline" \ + --history-entry "$history" \ + --publication-receipt "$receipt" \ + --authorization "$authorization" \ + --activation-authorization "$activation_authorization" \ + --check-authorization-expiry \ + --compare-and-swap \ + --forbid-overwrite-on-conflict \ + --verify-after-activation \ + --activation-receipt build/stable-1.0-maintenance-baseline-activation-receipt.json + jq -e ' + .status == "pass" + and .pointerUpdate == "activated" + and .verificationStatus == "pass" + and .redaction.status == "pass"' \ + build/stable-1.0-maintenance-baseline-activation-receipt.json >/dev/null + + - name: Stage baseline activation outcome + if: always() + run: | + set -euo pipefail + root="build/stable-maintenance-baseline-activation-audit" + rm -rf "$root" + mkdir -p "$root" + marker="build/stable-maintenance-baseline-activation-started" + marker_status=absent + activation_boundary_entered=false + if [[ -L "$marker" || ( -e "$marker" && ! -f "$marker" ) ]]; then + marker_status=unsafe + activation_boundary_entered=true + elif [[ -f "$marker" ]]; then + marker_status=present + activation_boundary_entered=true + cp "$marker" "$root/" + fi + receipt="build/stable-1.0-maintenance-baseline-activation-receipt.json" + observed_pointer_digest='' + if [[ ! -L "$receipt" && -f "$receipt" ]]; then + cp "$receipt" "$root/" + observed_pointer_digest="$(jq -r '.observedPointerDigest // empty' \ + "$receipt" 2>/dev/null || true)" + if [[ ! "$observed_pointer_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + observed_pointer_digest='' + fi + fi + activation_authorization="build/stable-maintenance-baseline-activation/stable-1.0-maintenance-activation-authorization.json" + if [[ ! -L "$activation_authorization" && -f "$activation_authorization" ]]; then + cp "$activation_authorization" "$root/" + fi + jq -n \ + --arg outcome "${{ steps.activation.outcome }}" \ + --arg release_id "${{ inputs.release_id }}" \ + --arg build "${{ inputs.build_version }}" \ + --arg marker_status "$marker_status" \ + --argjson activation_boundary_entered "$activation_boundary_entered" \ + --arg observed_pointer_digest "$observed_pointer_digest" \ + '{kind: "stable-1.0-maintenance-baseline-activation-workflow-audit", + releaseId: $release_id, buildVersion: $build, stepOutcome: $outcome, + activationBoundaryMarkerStatus: $marker_status, + activationBoundaryEntered: $activation_boundary_entered, + sideEffectsMayHaveOccurred: $activation_boundary_entered, + observedPointerDigest: + (if $observed_pointer_digest == "" then null else $observed_pointer_digest end), + activationReceiptPresent: false, activationAuthorizationPresent: false, + redaction: {status: "pass", findingCount: 0, findings: []}}' > "$root/workflow-audit.json" + if [[ -f "$root/stable-1.0-maintenance-baseline-activation-receipt.json" ]]; then + jq '.activationReceiptPresent = true' "$root/workflow-audit.json" \ + > "$root/workflow-audit.tmp" + mv "$root/workflow-audit.tmp" "$root/workflow-audit.json" + fi + if [[ -f "$root/stable-1.0-maintenance-activation-authorization.json" ]]; then + jq '.activationAuthorizationPresent = true' "$root/workflow-audit.json" \ + > "$root/workflow-audit.tmp" + mv "$root/workflow-audit.tmp" "$root/workflow-audit.json" + fi + + - name: Upload baseline activation receipt + if: always() + uses: actions/upload-artifact@v6 + with: + name: stable-1-0-maintenance-baseline-activation-${{ inputs.release_id }}-${{ inputs.build_version }}-${{ github.run_id }}-${{ github.run_attempt }} + path: build/stable-maintenance-baseline-activation-audit/ + if-no-files-found: error + retention-days: 90 + + - name: Summarize protected boundary + if: steps.activation.outcome == 'success' + run: | + echo "Exact public bytes were independently verified before the successor baseline pointer was activated." >> "$GITHUB_STEP_SUMMARY" + echo "This workflow did not create or merge release/hotfix branches and never squashes, rebases, or back-merges them." >> "$GITHUB_STEP_SUMMARY" + + - name: Fail after retaining activation state + if: steps.activation.outcome != 'success' + run: | + echo "::error::Baseline activation failed or could not be verified; its exact receipt/audit was retained." + exit 1 diff --git a/.github/workflows/stable-1.0-maintenance-windows-package-producer.yml b/.github/workflows/stable-1.0-maintenance-windows-package-producer.yml new file mode 100644 index 00000000000..18ed2e79e37 --- /dev/null +++ b/.github/workflows/stable-1.0-maintenance-windows-package-producer.yml @@ -0,0 +1,342 @@ +name: Stable 1.0 Maintenance Windows Package Producer +run-name: >- + Stable 1.0 Windows EXE ${{ inputs.release_id }} build ${{ inputs.build_version }} + +on: + workflow_dispatch: + inputs: + release_id: + description: Path-safe Stable 1.0 maintenance release ID. + required: true + type: string + build_version: + description: Positive integer Cryptad build. + required: true + type: string + release_class: + description: Closed Stable 1.0 release class. + required: true + type: choice + options: + - maintenance + - security-hotfix + candidate_commit: + description: Exact candidate commit; dispatch this workflow at the same ref. + required: true + type: string + exe_file_name: + description: Exact path-safe EXE filename declared by the maintenance candidate. + required: true + type: string + +concurrency: + group: stable-1-0-maintenance-windows-${{ inputs.release_id }}-${{ inputs.build_version }} + cancel-in-progress: false + +permissions: {} + +jobs: + build-sign-and-attest: + name: Build, Authenticode-sign, verify, and attest exact EXE + runs-on: windows-latest + environment: stable-1.0-maintenance-evidence + timeout-minutes: 360 + permissions: + contents: read + attestations: write + artifact-metadata: write + id-token: write + + steps: + - name: Check out exact candidate + uses: actions/checkout@v6 + with: + ref: ${{ inputs.candidate_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: temurin + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5 + + - name: Install Gradle properties + uses: ./.github/actions/setup-gradle-properties + + - name: Validate release identity and pristine tracked source + shell: pwsh + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_EXE_FILE_NAME: ${{ inputs.exe_file_name }} + run: | + $ErrorActionPreference = 'Stop' + if ($env:INPUT_RELEASE_ID -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$' -or + $env:INPUT_BUILD_VERSION -cnotmatch '^[1-9][0-9]*$' -or + $env:INPUT_CANDIDATE_COMMIT -cnotmatch '^[0-9a-f]{40}$' -or + $env:INPUT_EXE_FILE_NAME -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]*\.exe$') { + throw 'The Windows producer release identity is malformed.' + } + if ($env:RUNNER_ARCH -cne 'X64') { + throw 'The amd64 package producer requires a GitHub-hosted X64 runner.' + } + if ($env:GITHUB_SHA -cne $env:INPUT_CANDIDATE_COMMIT) { + throw 'The workflow_dispatch head SHA is not the candidate commit.' + } + $expectedBranch = if ($env:INPUT_RELEASE_CLASS -ceq 'maintenance') { + "release/$($env:INPUT_BUILD_VERSION)" + } else { + "hotfix/$($env:INPUT_BUILD_VERSION)" + } + if ($env:GITHUB_REF -cne "refs/heads/$expectedBranch") { + throw 'The producer must be dispatched from the exact release/hotfix branch.' + } + $remote = (git ls-remote --exit-code origin "refs/heads/$expectedBranch") -split "`t" + if ($LASTEXITCODE -ne 0 -or $remote[0] -cne $env:INPUT_CANDIDATE_COMMIT) { + throw 'The expected release/hotfix branch does not point at the candidate commit.' + } + if ((git rev-parse 'HEAD^{commit}').Trim() -cne $env:INPUT_CANDIDATE_COMMIT) { + throw 'The checked-out HEAD is not the candidate commit.' + } + git diff-index --quiet --cached HEAD -- + if ($LASTEXITCODE -ne 0) { throw 'The source index is dirty before the build.' } + git diff-files --quiet -- + if ($LASTEXITCODE -ne 0) { throw 'Tracked source files are dirty before the build.' } + $version = (./gradlew.bat -q printVersion).Trim() + if ($LASTEXITCODE -ne 0 -or $version -cne $env:INPUT_BUILD_VERSION) { + throw 'The checked-out integer build does not match the dispatch.' + } + + - name: Build one Windows EXE installer + shell: pwsh + env: + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + run: | + $ErrorActionPreference = 'Stop' + $hasWix3 = $null -ne (Get-Command candle.exe -ErrorAction SilentlyContinue) -and + $null -ne (Get-Command light.exe -ErrorAction SilentlyContinue) + $hasWix4 = $null -ne (Get-Command wix.exe -ErrorAction SilentlyContinue) + if (-not $hasWix3 -and -not $hasWix4) { + throw 'No jpackage-supported WiX toolchain is available on the hosted runner.' + } + ./gradlew.bat jpackageInstallerWindowsExeCryptad + if ($LASTEXITCODE -ne 0) { throw 'The Windows EXE installer build failed.' } + $installers = @(Get-ChildItem -LiteralPath 'build/jpackage' -File -Filter '*.exe') + if ($installers.Count -ne 1) { throw 'jpackage did not emit exactly one EXE installer.' } + + - name: Authenticode-sign and verify the exact installer + shell: pwsh + env: + WINDOWS_CERTIFICATE_P12_BASE64: ${{ secrets.CRYPTAD_WINDOWS_CODE_SIGNING_P12_BASE64 }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.CRYPTAD_WINDOWS_CODE_SIGNING_P12_PASSWORD }} + WINDOWS_CERTIFICATE_SHA1: ${{ vars.CRYPTAD_WINDOWS_CODE_SIGNING_CERTIFICATE_SHA1 }} + WINDOWS_TIMESTAMP_URI: ${{ vars.CRYPTAD_WINDOWS_CODE_SIGNING_TIMESTAMP_URI }} + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_P12_BASE64) -or + [string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD) -or + $env:WINDOWS_CERTIFICATE_SHA1 -cnotmatch '^[0-9A-F]{40}$' -or + $env:WINDOWS_TIMESTAMP_URI -cnotmatch '^https://[^\s@?#]+(?:/[^\s?#]*)?$') { + throw 'Protected Windows Authenticode inputs are incomplete or unsafe.' + } + $certificatePath = Join-Path $env:RUNNER_TEMP 'cryptad-maintenance-signing.p12' + $importedCertificates = @() + try { + [IO.File]::WriteAllBytes( + $certificatePath, + [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_P12_BASE64) + ) + $password = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + $importedCertificates = @(Import-PfxCertificate -FilePath $certificatePath ` + -CertStoreLocation Cert:\CurrentUser\My -Password $password -Exportable:$false + ) + $matchingCertificates = @($importedCertificates | Where-Object { + $_.HasPrivateKey -and $_.Thumbprint -ceq $env:WINDOWS_CERTIFICATE_SHA1 + }) + if ($matchingCertificates.Count -ne 1) { + throw 'The imported certificate is not the reviewed Authenticode identity.' + } + $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" ` + -Recurse -File -Filter signtool.exe | + Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $signtool) { throw 'Windows SDK signtool.exe is unavailable.' } + $installers = @(Get-ChildItem 'build/jpackage' -File -Filter '*.exe') + if ($installers.Count -ne 1) { throw 'The signing boundary requires exactly one EXE.' } + $installer = $installers[0] + & $signtool.FullName sign /fd SHA256 /td SHA256 /tr $env:WINDOWS_TIMESTAMP_URI ` + /sha1 $env:WINDOWS_CERTIFICATE_SHA1 $installer.FullName | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Authenticode signing failed.' } + & $signtool.FullName verify /pa /all $installer.FullName | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Authenticode verification failed.' } + & $signtool.FullName verify /pa /all /tw $installer.FullName | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Authenticode timestamp verification failed.' } + $signature = Get-AuthenticodeSignature -LiteralPath $installer.FullName + if ($signature.Status -cne 'Valid' -or + $null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -cne $env:WINDOWS_CERTIFICATE_SHA1 -or + $null -eq $signature.TimeStamperCertificate) { + throw 'The final EXE lacks the exact valid signer and timestamp identity.' + } + } finally { + foreach ($importedCertificate in $importedCertificates) { + Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($importedCertificate.Thumbprint)" -Force ` + -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $certificatePath -Force -ErrorAction SilentlyContinue + } + + - name: Verify post-build source identity and stage the exact PE + shell: pwsh + env: + INPUT_RELEASE_ID: ${{ inputs.release_id }} + INPUT_BUILD_VERSION: ${{ inputs.build_version }} + INPUT_RELEASE_CLASS: ${{ inputs.release_class }} + INPUT_CANDIDATE_COMMIT: ${{ inputs.candidate_commit }} + INPUT_EXE_FILE_NAME: ${{ inputs.exe_file_name }} + EXPECTED_SIGNER_SHA1: ${{ vars.CRYPTAD_WINDOWS_CODE_SIGNING_CERTIFICATE_SHA1 }} + run: | + $ErrorActionPreference = 'Stop' + if ((git rev-parse 'HEAD^{commit}').Trim() -cne $env:INPUT_CANDIDATE_COMMIT) { + throw 'HEAD changed while building the Windows package.' + } + git diff-index --quiet --cached HEAD -- + if ($LASTEXITCODE -ne 0) { throw 'The source index changed while building the package.' } + git diff-files --quiet -- + if ($LASTEXITCODE -ne 0) { throw 'Tracked source changed while building the package.' } + $installers = @(Get-ChildItem 'build/jpackage' -File -Filter '*.exe') + if ($installers.Count -ne 1) { throw 'The staging boundary requires exactly one EXE.' } + $installer = $installers[0] + $stream = [IO.File]::OpenRead($installer.FullName) + try { + $reader = [IO.BinaryReader]::new($stream) + if ($reader.ReadUInt16() -ne 0x5A4D) { throw 'Installer lacks the MZ signature.' } + $stream.Position = 0x3C + $peOffset = $reader.ReadUInt32() + if ($peOffset -lt 0x40 -or $peOffset + 6 -gt $stream.Length) { + throw 'Installer PE header offset is invalid.' + } + $stream.Position = $peOffset + if ($reader.ReadUInt32() -ne 0x00004550 -or $reader.ReadUInt16() -ne 0x8664) { + throw 'Installer is not an amd64 PE image.' + } + } finally { + $stream.Dispose() + } + $output = 'build/stable-maintenance-windows-package-producer' + Remove-Item -Recurse -Force $output -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $output | Out-Null + Copy-Item -LiteralPath $installer.FullName ` + -Destination (Join-Path $output $env:INPUT_EXE_FILE_NAME) + $env:EXPECTED_BRANCH = if ($env:INPUT_RELEASE_CLASS -ceq 'maintenance') { + "release/$($env:INPUT_BUILD_VERSION)" + } else { + "hotfix/$($env:INPUT_BUILD_VERSION)" + } + $env:OUTPUT_EXE = (Resolve-Path (Join-Path $output $env:INPUT_EXE_FILE_NAME)).Path + $env:OUTPUT_RECEIPT = (Join-Path (Resolve-Path $output).Path ` + 'stable-1.0-maintenance-windows-package-producer.json') + @' + import hashlib + import json + import os + import re + import subprocess + from pathlib import Path + + def digest(path): + return "sha256:" + hashlib.sha256(Path(path).read_bytes()).hexdigest() + + def tree_digest(*pathspec): + data = subprocess.check_output( + ["git", "ls-tree", "-r", "--full-tree", "HEAD", *pathspec] + ) + return "sha256:" + hashlib.sha256(data).hexdigest() + + java_version = subprocess.check_output( + ["java", "--version"], stderr=subprocess.STDOUT, text=True + ).splitlines()[0] + match = re.search(r"(?("distTarCryptad") { archiveFileName.set("cryptad-v${project.version}.tar.gz") from(wrapperDistDir) destinationDirectory.set(layout.buildDirectory.dir("distributions")) - isPreserveFileTimestamps = true + isPreserveFileTimestamps = false isReproducibleFileOrder = true + eachFile { + val memberPermissions = PortableArchiveNormalizer.unixPermissionsForMember(path) + permissions { unix(memberPermissions) } + } + dirPermissions { unix("0755") } + doLast { PortableArchiveNormalizer.normalize(archiveFile.get().asFile.toPath()) } } // Package the same distribution as a ZIP for broad platform compatibility @@ -636,8 +643,14 @@ tasks.register("distZipCryptad") { archiveFileName.set("cryptad-v${project.version}.zip") from(wrapperDistDir) destinationDirectory.set(layout.buildDirectory.dir("distributions")) - isPreserveFileTimestamps = true + isPreserveFileTimestamps = false isReproducibleFileOrder = true + eachFile { + val memberPermissions = PortableArchiveNormalizer.unixPermissionsForMember(path) + permissions { unix(memberPermissions) } + } + dirPermissions { unix("0755") } + doLast { PortableArchiveNormalizer.normalize(archiveFile.get().asFile.toPath()) } } // Aggregate distribution lifecycle task diff --git a/build-logic/src/main/kotlin/cryptad.jpackage.gradle.kts b/build-logic/src/main/kotlin/cryptad.jpackage.gradle.kts index 33916a15538..faff17a86f6 100644 --- a/build-logic/src/main/kotlin/cryptad.jpackage.gradle.kts +++ b/build-logic/src/main/kotlin/cryptad.jpackage.gradle.kts @@ -1,5 +1,9 @@ +import java.io.OutputStream +import java.nio.ByteBuffer import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.Locale import java.util.jar.JarOutputStream as JJarOutputStream @@ -54,30 +58,36 @@ fun clearXattrsQuiet(target: File, timeoutSeconds: Long) { val appName = "Crypta" val vendor = "crypta.network" val appId = "network.crypta.cryptad" - -// Windows installer support removed; no UpgradeCode needed. +// This identity is part of the Windows upgrade contract. Do not change it between integer builds. +val windowsUpgradeUuid = "779872cd-ca9b-5a0e-8260-7d372a550fb7" // jpackage --app-version is strict (e.g., macOS CFBundleVersion must be 1..3 integers separated by // dots). -/** Returns a numeric app version accepted by jpackage (platform compliant). */ -fun numericAppVersion(): String { - val raw = project.version.toString() +/** Returns the portable numeric app version accepted by Linux and macOS jpackage. */ +fun numericAppVersion(rawVersion: String = project.version.toString()): String { + val raw = rawVersion val m = Regex("\\d+(?:\\.\\d+){0,3}").find(raw) - var v = (m?.value ?: raw.filter { it.isDigit() }.ifBlank { "1" }) - // Windows installers (MSI/EXE) require 2..4 components in ProductVersion - if (currentOs() == "win") { - val parts = v.split('.') - v = - when { - parts.size < 2 -> parts.firstOrNull()?.let { "$it.0" } ?: "1.0" - parts.size > 4 -> parts.take(4).joinToString(".") - else -> v - } + return (m?.value ?: raw.filter { it.isDigit() }.ifBlank { "1" }) +} + +/** Maps the canonical integer release build to MSI's 16-bit build-version component. */ +fun windowsMsiAppVersion(releaseBuild: String): String { + val build = releaseBuild.toIntOrNull() + if (build == null || build <= 0 || build > 65_535 || build.toString() != releaseBuild) { + throw GradleException( + "Windows Stable release builds must be canonical integers from 1 through 65535" + ) } - return v + return "1.0.$build" } +/** Returns the OS-specific jpackage version without changing Cryptad's integer release version. */ +fun jpackageAppVersion( + os: String = currentOs(), + releaseBuild: String = project.version.toString(), +): String = if (os == "win") windowsMsiAppVersion(releaseBuild) else numericAppVersion(releaseBuild) + // Prepare resources for jpackage from root files and src/jpackage assets val prepareJpackageResources by tasks.registering(Sync::class) { @@ -375,7 +385,7 @@ val jpackageImageCryptad by "--name", imageName, "--app-version", - numericAppVersion(), + jpackageAppVersion(), "--dest", destDir.absolutePath, "--input", @@ -650,7 +660,7 @@ val enrichAppImageWithDist by ?.forEach { f -> out += "app.classpath=${cpPrefix}${f.name}" } out += "" out += "[JavaOptions]" - out += "java-options=-Djpackage.app-version=${numericAppVersion()}" + out += "java-options=-Djpackage.app-version=${jpackageAppVersion()}" cfg.writeText(out.joinToString(System.lineSeparator())) logger.lifecycle("Patched launcher cfg -> {}", cfg.absolutePath) } else { @@ -659,12 +669,342 @@ val enrichAppImageWithDist by } } +/** Returns the opt-in Developer ID Application identity used only by protected macOS packaging. */ +fun macSigningKeyUserName(): String = + providers.gradleProperty("macSigningKeyUserName").orNull?.trim().orEmpty() + +/** Builds one restricted codesign command for the explicit inside-out signing sequence. */ +fun macCodeSigningArgs( + codesignPath: String, + targetPath: String, + signingKeyUserName: String, + preserveExistingMetadata: Boolean, +): List = buildList { + add(codesignPath) + add("--force") + add("--options") + add("runtime") + add("--timestamp") + if (preserveExistingMetadata) { + // jpackage signs the JVM launchers and native runtime before enrichment. Retain their + // identifiers and executable entitlements while replacing the ad-hoc identity. + add("--preserve-metadata=identifier,entitlements") + } + add("--sign") + add(signingKeyUserName) + add(targetPath) +} + +/** Returns whether the target currently has a signature whose metadata can be preserved. */ +fun hasMacCodeSignature(codesign: File, target: File): Boolean = + try { + val process = + ProcessBuilder(codesign.absolutePath, "--display", target.absolutePath) + .redirectErrorStream(true) + .start() + process.inputStream.use { it.copyTo(OutputStream.nullOutputStream()) } + process.waitFor() == 0 + } catch (_: Exception) { + false + } + +/** Returns whether a target belongs to the jpackage-created JVM/native bundle surface. */ +fun requiresExistingMacCodeSignature(appImage: File, target: File): Boolean { + val appPath = appImage.toPath().toAbsolutePath().normalize() + val targetPath = target.toPath().toAbsolutePath().normalize() + return targetPath == appPath || + targetPath.startsWith(appPath.resolve("Contents/runtime")) || + targetPath.startsWith(appPath.resolve("Contents/Frameworks")) +} + +/** Returns whether the regular file starts with a recognized thin or universal Mach-O magic. */ +fun isMachOCodeFile(path: Path): Boolean { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return false + val header = ByteArray(Int.SIZE_BYTES) + val bytesRead = + Files.newInputStream(path).use { input -> + var offset = 0 + while (offset < header.size) { + val count = input.read(header, offset, header.size - offset) + if (count < 0) break + offset += count + } + offset + } + if (bytesRead != header.size) return false + return ByteBuffer.wrap(header).int in + setOf( + 0xFEEDFACE.toInt(), // 32-bit Mach-O + 0xCEFAEDFE.toInt(), // byte-swapped 32-bit Mach-O + 0xFEEDFACF.toInt(), // 64-bit Mach-O + 0xCFFAEDFE.toInt(), // byte-swapped 64-bit Mach-O + 0xCAFEBABE.toInt(), // universal Mach-O + 0xBEBAFECA.toInt(), // byte-swapped universal Mach-O + 0xCAFEBABF.toInt(), // 64-bit universal Mach-O + 0xBFBAFECA.toInt(), // byte-swapped 64-bit universal Mach-O + ) +} + +/** Returns whether the directory is a nested native bundle that codesign must seal explicitly. */ +fun isNestedMacCodeBundle(appPath: Path, path: Path): Boolean { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || path == appPath) return false + if (path == appPath.resolve("Contents/runtime")) return true + val name = path.fileName.toString().lowercase(Locale.ROOT) + return when { + name.endsWith(".framework") -> + Files.isDirectory(path.resolve("Versions"), LinkOption.NOFOLLOW_LINKS) + name.endsWith(".app") || + name.endsWith(".appex") || + name.endsWith(".xpc") || + name.endsWith(".plugin") || + name.endsWith(".bundle") -> + Files.isRegularFile(path.resolve("Contents/Info.plist"), LinkOption.NOFOLLOW_LINKS) + else -> false + } +} + +/** + * Selects nested macOS code in inside-out order, leaving the main launcher for the app-root + * signature as jpackage does. + */ +fun macNestedCodeSigningTargets(appImage: File, mainLauncherName: String): List { + val appPath = appImage.toPath().toAbsolutePath().normalize() + val mainLauncher = appPath.resolve("Contents/MacOS/$mainLauncherName") + val nestedFiles = + Files.walk(appPath).use { paths -> + paths + .filter { path -> path != mainLauncher && isMachOCodeFile(path) } + .sorted(compareByDescending { it.nameCount }.thenBy { it.toString() }) + .map(Path::toFile) + .toList() + } + + val bundleRoots = + Files.walk(appPath).use { paths -> + paths + .filter { path -> isNestedMacCodeBundle(appPath, path) } + .sorted(compareByDescending { it.nameCount }.thenBy { it.toString() }) + .map(Path::toFile) + .toList() + } + return nestedFiles + bundleRoots +} + +/** Verifies one nested code object without relying on recursive signing behavior. */ +fun macCodeVerificationArgs(codesignPath: String, targetPath: String): List = + listOf(codesignPath, "--verify", "--strict", "--verbose=2", targetPath) + +// jpackage does not apply installer-stage signing to a predefined app image, and it rejects using +// --app-image with --type app-image. Sign the final enriched bundle directly, after cryptad-dist +// and the rewritten launcher config have been added, then package those exact signed bytes. +// Ordinary local packaging skips this task when no identity is supplied. +val signFinalMacAppImageCryptad by + tasks.registering { + group = "jpackage" + description = "Developer ID signs the final enriched macOS app image when explicitly enabled" + dependsOn(enrichAppImageWithDist) + onlyIf { currentOs() == "mac" && macSigningKeyUserName().isNotEmpty() } + doLast { + val app = jpackageOutDir.get().asFile.resolve("$appName.app") + if (!app.isDirectory) { + throw GradleException("Final enriched macOS app image not found: ${app.absolutePath}") + } + val codesign = File("/usr/bin/codesign") + if (!codesign.canExecute()) { + throw GradleException("codesign tool not available for protected macOS packaging") + } + + // Enrichment changes the app payload after the initial app-image build. Clear resource-fork + // metadata before replacing the ad-hoc image signature with the protected Developer ID. + clearXattrsQuiet(app, 10) + val signingIdentity = macSigningKeyUserName() + val nestedTargets = macNestedCodeSigningTargets(app, appName) + logger.lifecycle( + "Developer ID signing {} nested macOS code objects before the final app root", + nestedTargets.size, + ) + for (target in nestedTargets) { + val hasExistingSignature = hasMacCodeSignature(codesign, target) + if (requiresExistingMacCodeSignature(app, target) && !hasExistingSignature) { + throw GradleException( + "jpackage nested code lacks the signature metadata required for safe replacement: " + + target.absolutePath + ) + } + execAndLog( + macCodeSigningArgs( + codesign.absolutePath, + target.absolutePath, + signingIdentity, + hasExistingSignature, + ) + ) + execAndLog(macCodeVerificationArgs(codesign.absolutePath, target.absolutePath)) + } + + // The enclosing app is always last so its resource seal authenticates every replacement + // nested signature and the enriched application payload. + if (!hasMacCodeSignature(codesign, app)) { + throw GradleException( + "Final enriched macOS app lacks the jpackage signature metadata required for safe replacement" + ) + } + execAndLog(macCodeSigningArgs(codesign.absolutePath, app.absolutePath, signingIdentity, true)) + execAndLog( + listOf( + codesign.absolutePath, + "--verify", + "--deep", + "--strict", + "--verbose=2", + app.absolutePath, + ) + ) + } + } + +val verifyMacAppImageSigningArguments by + tasks.registering { + group = "verification" + description = "Verifies the restricted final macOS app-image signing command" + doLast { + val args = + macCodeSigningArgs( + "codesign", + "Crypta.app/Contents/runtime/Contents/Home/bin/java", + "Developer ID Application: Crypta (ABCDEFGHIJ)", + true, + ) + val expected = + listOf( + "codesign", + "--force", + "--options", + "runtime", + "--timestamp", + "--preserve-metadata=identifier,entitlements", + "--sign", + "Developer ID Application: Crypta (ABCDEFGHIJ)", + "Crypta.app/Contents/runtime/Contents/Home/bin/java", + ) + check(args == expected) { "Unexpected nested app-image signing arguments: $args" } + check("--deep" !in args) { "Recursive codesign is forbidden at the signing boundary" } + val unsignedArgs = + macCodeSigningArgs( + "codesign", + "Crypta.app/Contents/app/cryptad-dist/bin/cryptad", + "Developer ID Application: Crypta (ABCDEFGHIJ)", + false, + ) + check(unsignedArgs.none { it.startsWith("--preserve-metadata") }) { + "Unsigned enriched code cannot claim pre-existing entitlement metadata" + } + val verifyArgs = macCodeVerificationArgs("codesign", "nested-code") + check(verifyArgs == listOf("codesign", "--verify", "--strict", "--verbose=2", "nested-code")) + val forbidden = + setOf( + "--name", + "--dest", + "--resource-dir", + "--runtime-image", + "--input", + "--main-jar", + "--main-class", + "--icon", + "--mac-package-identifier", + "--app-image", + "--type", + ) + check(args.none(forbidden::contains)) { + "Installer or app-construction options reached the predefined app-image signing boundary" + } + + val fixture = temporaryDir.resolve("Crypta.app") + val runtimeJava = fixture.resolve("Contents/runtime/Contents/Home/bin/java") + val runtimeLibrary = fixture.resolve("Contents/runtime/Contents/Home/lib/libjli.dylib") + val frameworkLibrary = + fixture.resolve("Contents/Frameworks/Crypta.framework/Versions/A/Crypta") + val mainLauncher = fixture.resolve("Contents/MacOS/Crypta") + val embeddedMacLibrary = + fixture.resolve("Contents/app/cryptad-dist/lib/libwrapper-macosx-universal-64.dylib") + val embeddedLinuxWrapper = + fixture.resolve("Contents/app/cryptad-dist/bin/wrapper-linux-x86-64") + val embeddedWindowsWrapper = fixture.resolve("Contents/app/cryptad-dist/bin/wrapper.exe") + val embeddedScript = fixture.resolve("Contents/app/cryptad-dist/bin/cryptad") + val resource = fixture.resolve("Contents/app/cryptad-dist/conf/cryptad.ini") + val fakeMacLibrary = fixture.resolve("Contents/app/cryptad-dist/lib/not-really-native.dylib") + for (file in + listOf( + runtimeJava, + runtimeLibrary, + frameworkLibrary, + mainLauncher, + embeddedMacLibrary, + embeddedLinuxWrapper, + embeddedWindowsWrapper, + embeddedScript, + resource, + fakeMacLibrary, + )) { + file.parentFile.mkdirs() + } + val machO64Magic = byteArrayOf(0xCF.toByte(), 0xFA.toByte(), 0xED.toByte(), 0xFE.toByte()) + for (file in + listOf(runtimeJava, runtimeLibrary, frameworkLibrary, mainLauncher, embeddedMacLibrary)) { + file.writeBytes(machO64Magic) + } + embeddedLinuxWrapper.writeBytes( + byteArrayOf(0x7F, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte()) + ) + embeddedWindowsWrapper.writeBytes(byteArrayOf('M'.code.toByte(), 'Z'.code.toByte(), 0, 0)) + embeddedScript.writeText("#!/bin/sh\n") + resource.writeText("not native code\n") + fakeMacLibrary.writeText("a suffix is not a file format\n") + embeddedLinuxWrapper.setExecutable(true) + embeddedWindowsWrapper.setExecutable(true) + embeddedScript.setExecutable(true) + val ordered = macNestedCodeSigningTargets(fixture, "Crypta") + check(mainLauncher !in ordered) { "The app-root signature must own the main launcher" } + check(embeddedMacLibrary in ordered) { "An embedded Mach-O library must be signed" } + check(embeddedLinuxWrapper !in ordered) { + "An embedded ELF executable must not be codesigned" + } + check(embeddedWindowsWrapper !in ordered) { + "An embedded PE executable must not be codesigned" + } + check(embeddedScript !in ordered) { "An executable script must not be codesigned" } + check(resource !in ordered) { "An ordinary resource must not be codesigned" } + check(fakeMacLibrary !in ordered) { "A fake .dylib resource must not be codesigned" } + check(ordered.indexOf(runtimeJava) < ordered.indexOf(fixture.resolve("Contents/runtime"))) { + "Runtime code must be signed before the runtime bundle root" + } + check( + ordered.indexOf(frameworkLibrary) < + ordered.indexOf(fixture.resolve("Contents/Frameworks/Crypta.framework")) + ) { + "Framework code must be signed before the framework bundle root" + } + check((ordered + fixture).last() == fixture) { "The app root must be signed last" } + check(requiresExistingMacCodeSignature(fixture, runtimeJava)) + check(requiresExistingMacCodeSignature(fixture, frameworkLibrary)) + check(requiresExistingMacCodeSignature(fixture, fixture)) + check( + !requiresExistingMacCodeSignature( + fixture, + fixture.resolve("Contents/app/cryptad-dist/bin/cryptad"), + ) + ) + } + } + +tasks.named("check") { dependsOn(verifyMacAppImageSigningArguments) } + // Build an OS-native installer (dmg/msi/deb) using the image created above. val jpackageInstallerCryptad by tasks.registering { group = "jpackage" description = "Creates a native installer for the current OS" - dependsOn(enrichAppImageWithDist) + dependsOn(signFinalMacAppImageCryptad) onlyIf { when (currentOs()) { "linux" -> hasExe("dpkg-deb") || hasExe("rpmbuild") @@ -695,7 +1035,7 @@ val jpackageInstallerCryptad by "--name", appName, "--app-version", - numericAppVersion(), + jpackageAppVersion(), "--dest", outDir.absolutePath, "--resource-dir", @@ -706,7 +1046,13 @@ val jpackageInstallerCryptad by vendor, ) if (providers.gradleProperty("jpackageDebug").orNull == "true") args += "--verbose" - if (os == "mac") args.addAll(listOf("--mac-package-identifier", appId)) + if (os == "mac") { + args.addAll(listOf("--mac-package-identifier", appId)) + val signingKeyUserName = macSigningKeyUserName() + if (signingKeyUserName.isNotEmpty()) { + args.addAll(listOf("--mac-sign", "--mac-signing-key-user-name", signingKeyUserName)) + } + } if (os == "linux") { // Install under a stable path used by our service/scripts and tests args.addAll(listOf("--install-dir", "/opt/cryptad")) @@ -740,7 +1086,7 @@ val jpackageInstallerRpm by "--name", appName, "--app-version", - numericAppVersion(), + jpackageAppVersion(), "--dest", outDir.absolutePath, "--resource-dir", @@ -779,7 +1125,7 @@ val jpackageInstallerDeb by "--name", appName, "--app-version", - numericAppVersion(), + jpackageAppVersion(), "--dest", outDir.absolutePath, "--resource-dir", @@ -809,11 +1155,108 @@ val jpackageInstallerLinuxAll by onlyIf { currentOs() == "linux" && (hasExe("dpkg-deb") || hasExe("rpmbuild")) } } -// Windows MSI installer task removed +/** Builds the closed jpackage command used by the protected Windows EXE producer. */ +fun windowsExeInstallerArgs( + jpackagePath: String, + appVersion: String, + outputPath: String, + resourcesPath: String, + appImagePath: String, + iconPath: String, + verbose: Boolean, +): List = buildList { + addAll( + listOf( + jpackagePath, + "--type", + "exe", + "--name", + appName, + "--app-version", + appVersion, + "--dest", + outputPath, + "--resource-dir", + resourcesPath, + "--app-image", + appImagePath, + "--vendor", + vendor, + "--icon", + iconPath, + "--win-upgrade-uuid", + windowsUpgradeUuid, + "--win-dir-chooser", + "--win-menu", + "--win-shortcut", + ) + ) + if (verbose) add("--verbose") +} + +/** Builds the protected Windows EXE from the final enriched app image. */ +val jpackageInstallerWindowsExeCryptad by + tasks.registering { + group = "jpackage" + description = "Creates the Stable maintenance Windows EXE installer" + dependsOn(enrichAppImageWithDist) + doLast { + if (currentOs() != "win") { + throw GradleException("jpackageInstallerWindowsExeCryptad requires a Windows host") + } + val jpackage = resolveJpackageExecutable() + val outDir = jpackageOutDir.get().asFile.also { it.mkdirs() } + val imagePath = outDir.resolve(appName) + if (!imagePath.isDirectory) { + throw GradleException( + "Final enriched Windows app image not found: ${imagePath.absolutePath}" + ) + } + val args = + windowsExeInstallerArgs( + jpackage.absolutePath, + jpackageAppVersion(), + outDir.absolutePath, + jpackageResourcesDir.get().asFile.absolutePath, + imagePath.absolutePath, + iconPathForOs(), + providers.gradleProperty("jpackageDebug").orNull == "true", + ) + logger.lifecycle("Executing jpackage Windows EXE installer") + execAndLog(args) + } + } -// Windows EXE installer task removed +val verifyWindowsExeInstallerArguments by + tasks.registering { + group = "verification" + description = "Verifies the protected Windows EXE jpackage command" + doLast { + val args = + windowsExeInstallerArgs( + "jpackage.exe", + jpackageAppVersion("win", "301"), + "jpackage-output", + "jpackage-resources", + "Crypta", + "cryptad.ico", + true, + ) + check(args.take(3) == listOf("jpackage.exe", "--type", "exe")) + check(args.windowed(2).contains(listOf("--app-version", "1.0.301"))) + check(jpackageAppVersion("win", "301") == "1.0.301") + check(jpackageAppVersion("linux", "301") == "301") + check(jpackageAppVersion("mac", "301") == "301") + check(args.containsAll(listOf("--app-image", "--win-upgrade-uuid", windowsUpgradeUuid))) + check(args.count { it == "--win-upgrade-uuid" } == 1) + check(args.last() == "--verbose") + for (invalidBuild in listOf("0", "065", "65536", "1.0", "not-a-build")) { + check(runCatching { windowsMsiAppVersion(invalidBuild) }.isFailure) + } + } + } -// Aggregate Windows installer task removed +tasks.named("check") { dependsOn(verifyWindowsExeInstallerArguments) } // WiX relink task removed diff --git a/build-logic/src/main/kotlin/cryptad.runtime.gradle.kts b/build-logic/src/main/kotlin/cryptad.runtime.gradle.kts index 6564d4a30b4..21da00cc501 100644 --- a/build-logic/src/main/kotlin/cryptad.runtime.gradle.kts +++ b/build-logic/src/main/kotlin/cryptad.runtime.gradle.kts @@ -1,3 +1,4 @@ +import cryptad.PortableArchiveNormalizer import java.io.ByteArrayOutputStream import java.util.concurrent.TimeUnit @@ -280,8 +281,14 @@ val distZipCryptadJlink by archiveFileName.set("cryptad-jlink-v${project.version}.zip") from(jlinkImageDir) destinationDirectory.set(layout.buildDirectory.dir("distributions")) - isPreserveFileTimestamps = true + isPreserveFileTimestamps = false isReproducibleFileOrder = true + eachFile { + val memberPermissions = PortableArchiveNormalizer.unixPermissionsForMember(path) + permissions { unix(memberPermissions) } + } + dirPermissions { unix("0755") } + doLast { PortableArchiveNormalizer.normalize(archiveFile.get().asFile.toPath()) } } // Tar.gz counterpart for convenience @@ -296,8 +303,14 @@ val distTarCryptadJlink by archiveFileName.set("cryptad-jlink-v${project.version}.tar.gz") from(jlinkImageDir) destinationDirectory.set(layout.buildDirectory.dir("distributions")) - isPreserveFileTimestamps = true + isPreserveFileTimestamps = false isReproducibleFileOrder = true + eachFile { + val memberPermissions = PortableArchiveNormalizer.unixPermissionsForMember(path) + permissions { unix(memberPermissions) } + } + dirPermissions { unix("0755") } + doLast { PortableArchiveNormalizer.normalize(archiveFile.get().asFile.toPath()) } } // Aggregate task diff --git a/build-logic/src/main/kotlin/cryptad/PortableArchiveNormalizer.kt b/build-logic/src/main/kotlin/cryptad/PortableArchiveNormalizer.kt new file mode 100644 index 00000000000..20f570d5554 --- /dev/null +++ b/build-logic/src/main/kotlin/cryptad/PortableArchiveNormalizer.kt @@ -0,0 +1,358 @@ +package cryptad + +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFileAttributeView +import java.nio.file.attribute.PosixFilePermissions +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Comparator +import java.util.Locale +import java.util.zip.Deflater +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.archivers.tar.TarConstants +import org.apache.commons.compress.archivers.zip.Zip64Mode +import org.apache.commons.compress.archivers.zip.ZipArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream +import org.apache.commons.compress.archivers.zip.ZipFile +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream +import org.apache.commons.compress.compressors.gzip.GzipParameters + +/** + * Rewrites Gradle distribution archives with the metadata required by the Stable release archive + * gate. The implementation runs on Gradle's Java toolchain and intentionally has no external + * interpreter or operating-system command dependency. + */ +object PortableArchiveNormalizer { + private const val FILE_TYPE_MASK = 0xF000 + private const val REGULAR_FILE_TYPE = 0x8000 + private const val DIRECTORY_TYPE = 0x4000 + private const val FILE_MODE = 0x1A4 // 0644 + private const val EXECUTABLE_MODE = 0x1ED // 0755 + private const val SYMLINK_MODE = 0x1FF // 0777 + private const val COPY_BUFFER_SIZE = 1024 * 1024 + + /** Normalizes one `.tar.gz`, `.tgz`, `.tar`, or `.zip` archive in place. */ + fun normalize(archive: Path) { + val source = archive.toAbsolutePath().normalize() + require(Files.isRegularFile(source)) { "Portable archive is not a regular file: $source" } + val fileName = source.fileName.toString().lowercase(Locale.ROOT) + when { + fileName.endsWith(".tar.gz") || fileName.endsWith(".tgz") -> normalizeTar(source, true) + fileName.endsWith(".tar") -> normalizeTar(source, false) + fileName.endsWith(".zip") -> normalizeZip(source) + else -> throw IllegalArgumentException("Unsupported portable archive: ${source.fileName}") + } + } + + /** Returns the deterministic Unix permissions for one portable archive file member. */ + fun unixPermissionsForMember(memberName: String): String = + if (isExecutableMember(safeMemberName(memberName))) "0755" else "0644" + + private fun normalizeTar(source: Path, compressed: Boolean) { + val staging = Files.createTempDirectory(source.parent, ".archive-normalize-") + val replacement = Files.createTempFile(source.parent, ".${source.fileName}.", ".tmp") + try { + val entries = readTarEntries(source, staging, compressed) + BufferedOutputStream(Files.newOutputStream(replacement)).use { rawOutput -> + val archiveOutput = + if (compressed) { + val parameters = + GzipParameters().apply { + compressionLevel = Deflater.BEST_COMPRESSION + modificationTime = 0 + operatingSystem = GzipParameters.OS.UNKNOWN.type() + } + GzipCompressorOutputStream(rawOutput, parameters) + } else { + rawOutput + } + archiveOutput.use { encodedOutput -> + TarArchiveOutputStream(encodedOutput).use { output -> + output.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX) + output.setBigNumberMode(TarArchiveOutputStream.BIGNUMBER_POSIX) + output.setAddPaxHeadersForNonAsciiNames(true) + entries.sortedBy(TarStagedEntry::name).forEach { writeTarEntry(output, it) } + } + } + } + replace(source, replacement) + } finally { + Files.deleteIfExists(replacement) + deleteTree(staging) + } + } + + private fun readTarEntries( + source: Path, + staging: Path, + compressed: Boolean, + ): List { + val entries = mutableListOf() + val names = mutableSetOf() + BufferedInputStream(Files.newInputStream(source)).use { rawInput -> + val archiveInput = if (compressed) GzipCompressorInputStream(rawInput) else rawInput + archiveInput.use { decodedInput -> + TarArchiveInputStream(decodedInput).use { input -> + var entry = input.nextEntry + while (entry != null) { + if (!input.canReadEntryData(entry)) { + throw IOException("Portable tar member cannot be read: ${entry.name}") + } + val name = safeMemberName(entry.name) + require(names.add(name)) { "Portable tar contains duplicate member: $name" } + val staged = + when { + entry.isFile -> stageTarFile(input, entry, name, staging, entries.size) + entry.isDirectory -> + TarStagedEntry(name, TarEntryKind.DIRECTORY, normalizedMode(name, true)) + entry.isSymbolicLink -> { + val linkName = safeMemberName(entry.linkName) + require(safeSymlinkTarget(name, linkName)) { + "Portable tar symlink escapes its root: $name" + } + TarStagedEntry(name, TarEntryKind.SYMLINK, SYMLINK_MODE, linkName = linkName) + } + else -> throw IOException("Portable tar contains special member: $name") + } + entries.add(staged) + entry = input.nextEntry + } + } + } + } + return entries + } + + private fun stageTarFile( + input: TarArchiveInputStream, + entry: TarArchiveEntry, + name: String, + staging: Path, + index: Int, + ): TarStagedEntry { + require(entry.size >= 0) { "Portable tar member has an invalid size: $name" } + val payload = staging.resolve(index.toString()) + Files.newOutputStream(payload).use { output -> input.copyTo(output, COPY_BUFFER_SIZE) } + val actualSize = Files.size(payload) + require(actualSize == entry.size) { + "Portable tar member size changed while reading $name: expected ${entry.size}, got $actualSize" + } + return TarStagedEntry( + name, + TarEntryKind.FILE, + normalizedMode(name, false), + actualSize, + payload, + ) + } + + private fun writeTarEntry(output: TarArchiveOutputStream, staged: TarStagedEntry) { + val flag = + when (staged.kind) { + TarEntryKind.FILE -> TarConstants.LF_NORMAL + TarEntryKind.DIRECTORY -> TarConstants.LF_DIR + TarEntryKind.SYMLINK -> TarConstants.LF_SYMLINK + } + val entry = + TarArchiveEntry(staged.name, flag).apply { + setModTime(0L) + userId = 0 + groupId = 0 + userName = "root" + groupName = "root" + mode = staged.mode + size = staged.size + if (staged.linkName != null) { + linkName = staged.linkName + } + } + output.putArchiveEntry(entry) + if (staged.payload != null) { + Files.newInputStream(staged.payload).use { input -> input.copyTo(output, COPY_BUFFER_SIZE) } + } + output.closeArchiveEntry() + } + + private fun normalizeZip(source: Path) { + val replacement = Files.createTempFile(source.parent, ".${source.fileName}.", ".tmp") + try { + ZipFile.builder().setPath(source).get().use { input -> + val entries = input.entries.asSequence().toList() + val namedEntries = entries.associateBy { safeMemberName(it.name) } + require(namedEntries.size == entries.size) { "Portable ZIP contains duplicate members" } + ZipArchiveOutputStream(replacement.toFile()).use { output -> + output.setEncoding("UTF-8") + output.setFallbackToUTF8(false) + output.setUseLanguageEncodingFlag(true) + output.setCreateUnicodeExtraFields(ZipArchiveOutputStream.UnicodeExtraFieldPolicy.NEVER) + output.setUseZip64(Zip64Mode.AsNeeded) + output.setMethod(ZipArchiveOutputStream.DEFLATED) + output.setLevel(Deflater.BEST_COMPRESSION) + output.setComment("") + namedEntries.toSortedMap().forEach { (name, original) -> + writeZipEntry(input, output, name, original) + } + } + } + replace(source, replacement) + } finally { + Files.deleteIfExists(replacement) + } + } + + private fun writeZipEntry( + input: ZipFile, + output: ZipArchiveOutputStream, + name: String, + original: ZipArchiveEntry, + ) { + require(input.canReadEntryData(original)) { "Portable ZIP member cannot be read: $name" } + require(!original.isUnixSymlink) { "Portable ZIP contains a symlink: $name" } + val directory = original.isDirectory + val fileType = original.unixMode and FILE_TYPE_MASK + require(fileType == 0 || fileType == REGULAR_FILE_TYPE || fileType == DIRECTORY_TYPE) { + "Portable ZIP contains special member: $name" + } + require(directory || fileType != DIRECTORY_TYPE) { + "Portable ZIP member type does not match its name: $name" + } + require(original.size >= 0 && (!directory || original.size == 0L)) { + "Portable ZIP member has an invalid size: $name" + } + val entryName = if (directory) "$name/" else name + val entry = + ZipArchiveEntry(entryName).apply { + setTime(zipEpochMillis()) + setUnixMode( + (if (directory) DIRECTORY_TYPE else REGULAR_FILE_TYPE) or + normalizedMode(name, directory) + ) + method = ZipArchiveOutputStream.DEFLATED + comment = "" + extra = byteArrayOf() + if (!directory) { + size = original.size + } + } + output.putArchiveEntry(entry) + if (!directory) { + input.getInputStream(original).use { content -> content.copyTo(output, COPY_BUFFER_SIZE) } + } + output.closeArchiveEntry() + } + + private fun normalizedMode(memberName: String, directory: Boolean): Int = + if (directory || isExecutableMember(memberName)) EXECUTABLE_MODE else FILE_MODE + + private fun isExecutableMember(memberName: String): Boolean { + val normalized = memberName.lowercase(Locale.ROOT) + val executableBinMember = + normalized.startsWith("bin/") && + !normalized.endsWith(".bat") && + !normalized.endsWith(".exe") + return executableBinMember || + normalized == "lib/jexec" || + normalized == "lib/jspawnhelper" || + normalized.startsWith("lib/libwrapper-linux-") || + normalized.startsWith("lib/libwrapper-macosx-") + } + + private fun safeMemberName(value: String): String { + val normalized = value.replace('\\', '/').trimEnd('/') + val parts = normalized.split('/') + require(normalized.isNotEmpty()) { "Portable archive contains an empty member name" } + require(!normalized.startsWith('/') && !normalized.startsWith("//")) { + "Portable archive contains an absolute member: $value" + } + require(!DRIVE_PATH.containsMatchIn(normalized)) { + "Portable archive contains a Windows-absolute member: $value" + } + require(parts.none { it == ".." }) { "Portable archive member traverses its root: $value" } + require(parts.none { it == "__MACOSX" || it == ".DS_Store" || it.startsWith("._") }) { + "Portable archive contains forbidden metadata: $value" + } + return normalized + } + + private fun safeSymlinkTarget(memberName: String, linkName: String): Boolean { + if (linkName.startsWith('/') || DRIVE_PATH.containsMatchIn(linkName)) { + return false + } + val resolved = mutableListOf() + resolved.addAll(memberName.substringBeforeLast('/', "").split('/').filter(String::isNotEmpty)) + for (part in linkName.split('/')) { + when (part) { + "", "." -> Unit + ".." -> if (resolved.isEmpty()) return false else resolved.removeLast() + else -> resolved.add(part) + } + } + return true + } + + private fun zipEpochMillis(): Long = + LocalDateTime.of(1980, 1, 1, 0, 0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + private fun replace(source: Path, replacement: Path) { + makeDistributionArchiveReadable(replacement) + try { + Files.move( + replacement, + source, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(replacement, source, StandardCopyOption.REPLACE_EXISTING) + } + } + + private fun makeDistributionArchiveReadable(archive: Path) { + val posixView = Files.getFileAttributeView(archive, PosixFileAttributeView::class.java) + if (posixView != null) { + posixView.setPermissions(PosixFilePermissions.fromString("rw-r--r--")) + return + } + + val file = archive.toFile() + check(file.setReadable(true, false) || file.canRead()) { + "Cannot make normalized distribution archive readable: $archive" + } + check(file.setWritable(true, true) || file.canWrite()) { + "Cannot make normalized distribution archive owner-writable: $archive" + } + } + + private fun deleteTree(root: Path) { + if (!Files.exists(root)) return + Files.walk(root).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } + } + + private enum class TarEntryKind { + FILE, + DIRECTORY, + SYMLINK, + } + + private data class TarStagedEntry( + val name: String, + val kind: TarEntryKind, + val mode: Int, + val size: Long = 0, + val payload: Path? = null, + val linkName: String? = null, + ) + + private val DRIVE_PATH = Regex("^[A-Za-z]:") +} diff --git a/docs/app-catalogs.md b/docs/app-catalogs.md index b6d7fbfce92..39068c6d476 100644 --- a/docs/app-catalogs.md +++ b/docs/app-catalogs.md @@ -664,6 +664,8 @@ crypta-app catalog sign \ crypta-app catalog verify \ --catalog-file dist/catalog/cryptad-app-catalog.properties \ + --catalog-signature-file dist/catalog/cryptad-app-catalog.signature \ + --expected-key-id dev-local \ --trusted-key-id dev-local \ --trusted-public-key-file /abs/path/to/dev-app-signing-public.pem ``` @@ -672,6 +674,13 @@ The catalog signature authenticates the exact bytes of `cryptad-app-catalog.prop rewrite, sort, or reformat the catalog after signing. See [app-dev-cli.md](app-dev-cli.md) for the full standalone app CLI workflow. +`--catalog-signature-file` selects the exact detached sidecar instead of deriving the canonical +sibling filename. `--expected-key-id` additionally requires that sidecar to name the separately +declared signer, even when the trusted-key registry contains several valid keys. General local +verification may omit either option and use the canonical sibling or any trusted signer. Protected +release verification supplies both so a renamed sidecar or a different trusted key cannot satisfy +the frozen catalog identity. + For a local Site Publisher catalog, use a `file:` `bundle.uri` that points at the signed ZIP artifact, create and sign the catalog, then add the catalog source through Platform API: @@ -690,6 +699,8 @@ crypta-app catalog sign \ crypta-app catalog verify \ --catalog-file dist/catalog/cryptad-app-catalog.properties \ + --catalog-signature-file dist/catalog/cryptad-app-catalog.signature \ + --expected-key-id dev-local \ --trusted-key-id dev-local \ --trusted-public-key-file /abs/path/to/dev-app-signing-public.pem ``` diff --git a/docs/app-data-backup-restore-portability.md b/docs/app-data-backup-restore-portability.md index e0917bad3be..0d277c40d25 100644 --- a/docs/app-data-backup-restore-portability.md +++ b/docs/app-data-backup-restore-portability.md @@ -208,3 +208,14 @@ Release certification records this surface with: Those checks verify the backup envelope, operator-only route design, app-principal denial, metadata-only restore planning, merge and replacement modes, Web Shell controls, first-party app backup-scope docs, and support-bundle redaction without writing raw backup values to evidence. + +## Maintenance-release evidence + +Stable maintenance certification binds backup and restore results to the exact frozen candidate, +the immediate predecessor, the app bundle and schema identities, and each tested environment. It +requires recovery from failed update and migration scenarios without publishing raw backup content, +raw app data, identity material, or runner paths. These gates remain mandatory for security +hotfixes. + +See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/app-dev-cli.md b/docs/app-dev-cli.md index b6573051b17..4ab347ed83b 100644 --- a/docs/app-dev-cli.md +++ b/docs/app-dev-cli.md @@ -755,6 +755,8 @@ Verify the catalog and sibling signature: ```bash crypta-app catalog verify \ --catalog-file dist/catalog/cryptad-app-catalog.properties \ + --catalog-signature-file dist/catalog/cryptad-app-catalog.signature \ + --expected-key-id dev-local \ --trusted-key-id dev-local \ --trusted-public-key-file /abs/path/to/dev-app-signing-public.pem ``` @@ -762,6 +764,12 @@ crypta-app catalog verify \ The sign command writes `cryptad-app-catalog.signature` beside `cryptad-app-catalog.properties`. Do not rewrite, sort, or reformat the catalog after signing. +By default, `catalog verify` derives that canonical sibling signature filename and accepts the +signer named by any trusted key. Use `--catalog-signature-file` to verify an exact detached sidecar +at another path and `--expected-key-id` to require a separately declared signer identity. Stable +release verification uses both options so another key in a broad trusted registry cannot replace +the candidate's frozen catalog signer. + `crypta-app` does not publish catalogs or signatures to Crypta. To offer a public catalog-over-Crypta source, create and sign the catalog locally, then publish `cryptad-app-catalog.properties` and `cryptad-app-catalog.signature` with the existing Crypta diff --git a/docs/app-update-lifecycle.md b/docs/app-update-lifecycle.md index da15c202bda..1cf30352f69 100644 --- a/docs/app-update-lifecycle.md +++ b/docs/app-update-lifecycle.md @@ -299,3 +299,15 @@ The evidence is collected by `tools/release-certification/certify.py app-platfor `tools/release-certification/certify.py release-certification`. It does not require a live node. Release candidates fail when any required app-update evidence item is missing, failing, skipped, or wrong-mode unless a release manager records a waiver. + +## Stable 1.0 maintenance evidence + +Maintenance certification binds catalog refresh, exact bundle selection, update, rollback, and +state-preservation scenarios to the frozen core product digest, the exact predecessor digest, and +the reviewed catalog/app digests. Changing an app bundle or catalog entry after authorization +invalidates the candidate. A security hotfix cannot waive app migration, backup, restore, or +rollback gates for affected apps. + +Core package discovery remains a separate package-based CoreUpdater operation. See the [Stable 1.0 +maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/app-upgrade-data-migrations.md b/docs/app-upgrade-data-migrations.md index 7de36606471..cc20a3dc522 100644 --- a/docs/app-upgrade-data-migrations.md +++ b/docs/app-upgrade-data-migrations.md @@ -214,3 +214,14 @@ logs, private insert URIs, private keys, app process tokens, browser session tok passwords, catalog scratch paths, staged bundle paths, rollback paths, or AppHost filesystem paths. Use schema versions, namespaces, step ids, booleans, counts, stable status strings, and redacted error codes instead. + +## Maintenance and hotfix migration gates + +The maintenance path requires candidate-bound migration dry-run and apply evidence, a backup before +the update, restore after a failed update, and rollback behavior against the immediate predecessor. +It also requires a direct-GA upgrade path while policy says that path remains supported. Evidence +records only safe metadata and digests; raw app-data values and backup payloads remain private. + +An expedited security hotfix may shorten only policy-listed observation windows. It cannot omit +migration, backup, restore, or rollback gates. See the [Stable 1.0 maintenance release and security +hotfix path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/catalog-operations-and-mirrors.md b/docs/catalog-operations-and-mirrors.md index e470ccd095f..c7d0b97233f 100644 --- a/docs/catalog-operations-and-mirrors.md +++ b/docs/catalog-operations-and-mirrors.md @@ -203,3 +203,21 @@ The redaction-safe publication receipt records public location ids/URIs and veri It never records private insert URIs, credentials, authorization headers, raw catalog content, private signing material, or runner-local paths. See [Stable 1.0 RC validation and GA promotion](stable-1.0-rc-validation-and-ga-promotion.md). + +## Maintenance publication and recovery + +The Stable maintenance publication plan binds the exact signed catalog bytes, revision, primary, +mirrors, rollback state, and trust-transition evidence to the frozen candidate. A matching public +state is idempotent success only after every location is independently verified. A downgrade, +untrusted signature, stale mirror, partial conflict, or unavailable observation blocks completion; +the workflow does not overwrite or delete conflicting public state. + +The catalog properties and detached signature are separate frozen and published assets. Protected +verification supplies both exact files and the candidate's independently declared signing-key id +to `crypta-app catalog verify`; a valid signature from another key in the trusted registry is not +an identity match. If the catalog bytes, signature bytes, or signing-key id changes, the edition or +revision must advance. Equal counters are allowed only when all three identities are unchanged. + +Catalog publication and the package-based CoreUpdater descriptor remain separate protected +mutations with separate receipts. See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/cryptad-release-workflow-and-runbook.md b/docs/cryptad-release-workflow-and-runbook.md index 43767b03393..7304f204930 100644 --- a/docs/cryptad-release-workflow-and-runbook.md +++ b/docs/cryptad-release-workflow-and-runbook.md @@ -678,3 +678,34 @@ edition number. If the incident is a confirmed performance regression, link the --- Keep this runbook synchronized with future CoreUpdater/AppEnv changes. Update references whenever package selection logic or descriptor schema evolves. + +## Stable 1.0 maintenance and security hotfix publication + +After Stable 1.0 GA, routine work stabilizes on `release/` from `develop`; an +emergency security fix stabilizes on `hotfix/` from the published `main` state. Both +use the next suitable integer build, the `stable-maintenance` command, and the same exact-byte +candidate, package, catalog, and CoreUpdater trust model. + +The protected workflow owns annotated `v` tag creation and publication. It never +creates or merges branches. After a verified publication receipt, complete the existing release or +hotfix merge-back with no squash and `--no-ff` merges. See the [Stable 1.0 maintenance release and +security hotfix path](stable-1.0-maintenance-release-and-hotfix-path.md). + +For this Stable path, do not use the earlier manual `core-info.json`, tag, GitHub Release, catalog, +or update-key steps in this general runbook. The four protected maintenance operations are +`freeze-candidate`, `prepare-authorization`, `validate-authorization`, and `publish`; each consumes +the prior phase's exact run id, artifact name, and Actions artifact digest. Only freeze builds +packages. Publish additionally authenticates the reviewed publication-backend wheel, requires an +exact pre-staged artifact base, verifies public state independently, and activates the successor +baseline with a separate short-lived activation authorization. + +Use Java 25 or newer for local packaging checks. The focused side-effect-free validation is: + +```bash +python3 tools/release-certification/certify.py stable-maintenance --self-test +./gradlew verifyMacAppImageSigningArguments verifyWindowsExeInstallerArguments +``` + +Those commands do not replace hosted Windows Authenticode production, macOS Developer ID signing +and notarization, Linux package production, protected catalog-signature verification, or public +publication verification. diff --git a/docs/first-party-app-maintenance-policy.md b/docs/first-party-app-maintenance-policy.md index 7d7021ccbe9..91cd7427d9d 100644 --- a/docs/first-party-app-maintenance-policy.md +++ b/docs/first-party-app-maintenance-policy.md @@ -184,3 +184,19 @@ When adding or changing a first-party app: `python3 -u tools/release-certification/certify.py release-certification --self-test`. 7. If Stable RC bytes have already been frozen, treat the policy change as payload drift and start the authorized refreeze path before any new GA validation. + +## Stable 1.0 maintenance application + +For a later Stable 1.0 release, compare every first-party app against both the immutable GA app set +and the immediate predecessor. Only compatible patch or security changes are eligible: app ids and +stable channel membership remain fixed, support commitments cannot decrease, reviewed bundle bytes +must match the catalog, and any data-schema or permission change needs candidate-bound migration, +rollback, backup, restore, consent, and rationale evidence. + +App versions must use the release-canonical dotted-numeric grammar that `AppUpdateService` can +compare. A version cannot regress below either Stable GA or the immediate predecessor, and changed +bundle bytes require a strictly newer version so installed clients can select the update. + +The [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md) defines the fail-closed delta and +authorization process. diff --git a/docs/multi-node-beta-soak-and-upgrade-drill.md b/docs/multi-node-beta-soak-and-upgrade-drill.md index 4a1241d75f6..a297846be98 100644 --- a/docs/multi-node-beta-soak-and-upgrade-drill.md +++ b/docs/multi-node-beta-soak-and-upgrade-drill.md @@ -68,3 +68,14 @@ fixture-only topology results cannot satisfy that gate. Private insert URIs, tokens, browser sessions, raw app data, raw messages, raw trust documents, node profile paths, rollback backup paths, and local absolute paths are excluded from release artifacts. + +## Stable 1.0 maintenance binding + +For a Stable maintenance candidate, every required upgrade, rollback, migration, backup, restore, +state-preservation, and recovery scenario records the exact candidate product digest and immediate +predecessor product digest. Policy may also require a direct-GA upgrade path. Only fresh production +evidence for the declared environment, node count, operation count, and package targets satisfies +the gate; a security hotfix cannot replace these non-waivable checks with fixtures or simulation. + +See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/network-scale-soak-and-subscription-budget.md b/docs/network-scale-soak-and-subscription-budget.md index 424a70cad72..4f6b7321d1c 100644 --- a/docs/network-scale-soak-and-subscription-budget.md +++ b/docs/network-scale-soak-and-subscription-budget.md @@ -162,3 +162,14 @@ text. Safe fields include normalized app ids, operation names, bounded counts, booleans, status labels, stable error codes, window timestamps, next retry timestamps, public key kinds, digests, and redacted source summaries. + +## Maintenance evidence windows + +Routine Stable maintenance uses the normal production soak, live-network, interoperability, and +performance windows recorded by the authenticated baseline and current policy. Evidence must be +fresh and bound to the exact frozen candidate. A critical security hotfix may shorten only the +closed scenarios named by policy and authorization; publication then creates a deadline-bound +full-window follow-up obligation without changing the published bytes. + +See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/platform-api-compatibility-support-window.md b/docs/platform-api-compatibility-support-window.md index 330084b066b..3ba7230d510 100644 --- a/docs/platform-api-compatibility-support-window.md +++ b/docs/platform-api-compatibility-support-window.md @@ -286,3 +286,15 @@ api.experimentalCapabilitiesAccepted=true Keep compatibility reports and digests in submission records. Do not attach raw private data, local paths, raw fetched content, raw app data, submission ZIP contents, keys, tokens, or private insert URIs. + +## Maintenance-release enforcement + +The canonical maintenance command compares the current Platform API contract and stable surface +against both the immutable GA baseline and the immediate predecessor. It carries original +deprecation clocks into each successor baseline; a release cannot reset a clock, relabel an +experimental surface as stable, waive a critical stable removal, or use a new baseline to conceal a +breaking change. Candidate-bound, fresh contract and stable-diff evidence is required even for a +security hotfix. + +See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/privacy-preserving-beta-diagnostics.md b/docs/privacy-preserving-beta-diagnostics.md index bf73e697673..4463c2feef3 100644 --- a/docs/privacy-preserving-beta-diagnostics.md +++ b/docs/privacy-preserving-beta-diagnostics.md @@ -194,3 +194,14 @@ The support bundle is not telemetry, crash-report upload, analytics, remote logg support intake. It does not prove that an operator has reviewed the JSON before sharing it. It also does not replace release certification, live-network evidence, multi-node soak evidence, security response drills, or the direct local diagnostics endpoint. + +## Stable 1.0 maintenance support evidence + +Maintenance validation requires a redacted support bundle from an exact-candidate upgrade or +recovery failure scenario. The evidence binds safe counts, environment class, times, statuses, and +digests to the candidate and predecessor. Raw content, raw app data, identity material, secrets, +private insert URIs, support payloads, and absolute runner paths remain forbidden. Redaction is a +non-waivable gate for both routine maintenance and security hotfixes. + +See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/production-first-party-catalog-channels.md b/docs/production-first-party-catalog-channels.md index b539f9bad2c..b4472c11c9f 100644 --- a/docs/production-first-party-catalog-channels.md +++ b/docs/production-first-party-catalog-channels.md @@ -153,3 +153,15 @@ The Stable GA publication receipt verifies the primary and every authorized mirr same catalog and signature bytes and records rollback/key-rotation/advisory state. Mirrors remain transport fallbacks, not trust authorities. The resulting Stable 1.0 maintenance baseline carries the frozen app set and signed maintenance commitments into later maintenance and hotfix comparison. + +## Maintenance catalog publication + +A maintenance candidate records an exact catalog and first-party app delta against both the GA +baseline and the immediately preceding release. Compatible patch and security updates still require +trusted bundle signing, review receipts, permission-change consent, migration and backup evidence, +and non-decreasing support commitments. Catalog downgrade, missing apps, untrusted keys, stale +mirrors, or rollback conflicts block publication. + +Protected publication may accept pre-existing catalog state only when every signed byte, revision, +mirror, and rollback observation matches. See the [Stable 1.0 maintenance release and security +hotfix path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/production-security-response-runbook.md b/docs/production-security-response-runbook.md index e15879e2bc3..3423eda50fa 100644 --- a/docs/production-security-response-runbook.md +++ b/docs/production-security-response-runbook.md @@ -348,3 +348,25 @@ production-beta drills or copy final digests into pre-freeze evidence. - fixtures and docs contain no obvious private keys, private insert URIs, bearer/session/app tokens, raw fetched content, raw app data, local absolute paths, command lines with secrets, CI secret values, reporter private data, or raw support payloads. + +## Stable 1.0 security hotfix release class + +An urgent Stable 1.0 fix uses `policies.releaseClass=security-hotfix` on +`hotfix/` from the currently published `main` state. It requires a public-safe +incident or advisory id, policy-qualified severity and affected scope, a Stable security release +manager authorization, an exact frozen candidate, and all non-waivable compatibility, signing, +redaction, updater, packaging, upgrade, rollback, migration, backup, and publication-conflict gates. + +The dedicated hotfix policy may shorten only closed prepublication observation windows. It creates +a deterministic full-window follow-up obligation and blocks the next routine publication if that +obligation becomes overdue. There is no generic skip or waiver. Follow the [Stable 1.0 maintenance +release and security hotfix path](stable-1.0-maintenance-release-and-hotfix-path.md). + +Every security hotfix declares a nonempty `affectedPackageKeys` subset, including a full-window +hotfix that still publishes the complete package matrix. A complete matrix records the unaffected +target proof as `not-applicable`; a narrowed matrix must exactly equal the affected set and carry a +passing proof that omitted targets do not ship the vulnerable code. Follow-up closure requires the +normal duration and freshness on the aggregate and on every obligated evidence row, with completed +timestamps and the original hotfix predecessor identity. A later superseding hotfix may carry an +open or overdue obligation, but it cannot relabel or clear that obligation with its own bytes, +authorization, or predecessor evidence. diff --git a/docs/release-certification.md b/docs/release-certification.md index 5afb1a256ad..ba4216f758e 100644 --- a/docs/release-certification.md +++ b/docs/release-certification.md @@ -219,3 +219,15 @@ archive redaction, and every required redaction result must pass before publicat See [the tooling README](../tools/release-certification/README.md) for the command tree, manifest schema, evidence envelope v2, and focused self-test commands. + +## Stable 1.0 maintenance component + +`stable-maintenance` is the canonical component for both routine maintenance and critical security +hotfix candidates after Stable 1.0 GA. It authenticates the immutable GA root and latest published +predecessor, freezes one new integer-build candidate, compares compatibility and production +evidence, prepares closed-scope authorization, and emits deterministic publication and successor +baseline records. Local modes are side-effect-free; only the protected workflow may publish the +authorized bytes. + +See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/docs/stable-1.0-maintenance-release-and-hotfix-path.md b/docs/stable-1.0-maintenance-release-and-hotfix-path.md new file mode 100644 index 00000000000..a8f7b269e42 --- /dev/null +++ b/docs/stable-1.0-maintenance-release-and-hotfix-path.md @@ -0,0 +1,676 @@ +# Stable 1.0 maintenance release and security hotfix path + +Use this runbook to validate and prepare a later Stable 1.0 maintenance build or critical security +hotfix, publish the exact frozen bytes through the protected workflow, and activate the verified +result as the next maintenance baseline. + +This path starts after Stable 1.0 GA publication. It does not replace the Stable RC freeze or GA +promotion process. Stable 1.0 remains a product and API milestone; Cryptad releases continue to use +one integer build number and an annotated `v` tag. + +## Command and scope + +Copy the checked-in example, replace every placeholder with public-safe release data, and keep +private publication material in protected environment or file inputs: + +```bash +cp tools/release-certification/manifests/stable-1.0-maintenance.example.json \ + build/stable-1.0-maintenance.json +python3 tools/release-certification/certify.py stable-maintenance \ + --manifest build/stable-1.0-maintenance.json +``` + +The component is `stable-maintenance`, uses `release.profile=stable-review`, and writes below: + +```text +build/release-certification//stable-maintenance/ +``` + +`policies.releaseClass` is closed to: + +- `maintenance` for a routine `release/` stabilization branch from `develop`; +- `security-hotfix` for a critical `hotfix/` branch from the currently published + `main` state. + +`commands.stable-maintenance.mode` is closed to: + +| Mode | Purpose | Public side effects | +| --- | --- | --- | +| `validate-only` | Authenticate lineage, freeze and compare a candidate, and evaluate all gates. | None. | +| `prepare-authorization` | Produce the exact candidate, comparison, publication target, note, and updater identities that a protected approver may authorize. | None. | +| `close-hotfix-follow-up` | Verify the full normal-window evidence for an already published expedited hotfix and close its obligation. | None. | + +No mode creates a branch, merge, tag, GitHub Release, catalog revision, update descriptor, CHK or +USK insert, public announcement, or latest-baseline activation. Self-tests, pull-request workflows, +and ordinary local runs cannot publish. + +## Deterministic artifact set + +The component uses the versioned maintenance, successor-baseline v2, CoreUpdater, and hotfix +follow-up schemas under `tools/release-certification/schemas/`. Its canonical component files are: + +```text +stable-1.0-maintenance-lineage.json +stable-1.0-maintenance-candidate.json +stable-1.0-maintenance-comparison.json +stable-1.0-maintenance-validation.json +stable-1.0-maintenance-authorization-summary.json +stable-1.0-maintenance-promotion-summary.json +stable-1.0-maintenance-go-no-go.md +stable-1.0-maintenance-known-limitations.json +stable-1.0-maintenance-release-notes.md +stable-1.0-maintenance-publication-plan.json +stable-1.0-maintenance-publication-receipt.json +stable-1.0-maintenance-publication-failure-audit.json +stable-1.0-maintenance-checksums.txt +stable-1.0-maintenance-audit-checksums.txt +stable-1.0-maintenance-provenance.json +stable-1.0-maintenance-successor-baseline.json +stable-1.0-maintenance-history-entry.json +stable-1.0-maintenance-latest-published.json +core-info.json +core-update-publication-plan.json +core-update-publication-receipt.json +redaction-report.json +``` + +`stable-1.0-maintenance-checksums.txt` is the public, operator-verifiable manifest. It lists only +the noncircular public payloads: the product and package assets, stable catalog and detached +signature, release notes, known-limitations delta, provenance, and `core-info.json`. The checksum +file cannot list itself, and it cannot list the authorization because the authorization already +binds the checksum-file digest. The publication plan and receipt bind both omitted public files by +their exact size and digest. Internal lineage, candidate-freeze, comparison, evidence, validation, +follow-up, publication, baseline, history, pointer, redaction, summary, and report records never +appear in the public checksum rows. + +`stable-1.0-maintenance-audit-checksums.txt` is the separate internal component inventory. It is +written after the component summary and report and deterministically covers every other file +present in the release-scoped component output. It is retained for audit and recovery, but it is +not a GitHub Release or artifact-base publication asset. + +The canonical publication receipt is emitted only after its closed schema and independent public +verification pass. A failed or partial operation emits the separately schema-validated +`stable-1.0-maintenance-publication-failure-audit.json`, including unavailable observations and a +conservative side-effect flag; it is never presented as a successful receipt. An expedited hotfix +also emits `stable-1.0-hotfix-follow-up-obligation.json`; a successful +full-window closure emits `stable-1.0-hotfix-follow-up-closure.json`. Routine maintenance forbids +the obligation. Prepared plans, receipts, baselines, history, and pointers do not assert public +activation: only a verified protected publication may finalize or activate them. +The protected activation audit additionally retains +`stable-1.0-maintenance-activation-authorization.json` and +`stable-1.0-maintenance-baseline-activation-receipt.json`; the former is a renewable workflow +authorization, not a release asset or successor-baseline input. + +When closing an obligation carried by a later hotfix, the closure manifest keeps the originally +obligated release id and build as its candidate identity while selecting the latest activated +carrier as `expectedPredecessor*`. That carrier build may therefore be higher than the obligated +build. The exact original candidate freeze authenticates its own predecessor observation; the +carrier baseline, receipt, and pointer authenticate only the current location of the obligation. + +## Stable GA root and immediate predecessor + +Every candidate has two authenticated anchors: + +1. the immutable Stable 1.0 GA maintenance baseline, which defines the long-term Stable 1.0 API, + catalog, app, content-profile, security, support, limitation, and legacy boundaries; +2. the immediately preceding published Stable 1.0 build, which defines the upgrade, rollback, + release-history, and short-term comparison boundary. + +The GA root remains `stable-1.0-maintenance-baseline.json` schema v1 with `status=prepared`. That +file is trusted only when its exact digest is present in the authenticated GA publication plan, +promotion record, checksums, and a passing `publication-complete` GA receipt. The maintenance path +also authenticates the GA validation, authorization, annotated tag, GitHub Release assets, +artifact base, product digest, provenance, stable catalog primary and mirrors, and public-state +observations. It never rewrites or relabels the GA baseline. + +For the first maintenance build, GA is both anchors. For a later build, the immediate predecessor +is the latest activated maintenance successor baseline and its passing publication receipt. The +engine walks every predecessor link back to the GA v1 root and rejects a stale pointer, fork, +missing receipt, skipped published predecessor, mismatched product, unverified public state, or +unpublished candidate presented as current stable state. Build numbers must increase strictly but +do not need to be consecutive. + +## Successor baseline and history + +A verified maintenance or hotfix publication produces a reusable Stable 1.0 successor maintenance +baseline schema v2. It records: + +- the immutable GA baseline digest and Stable 1.0 API identity; +- the previous baseline, lineage, product, and publication-receipt digests; +- chain depth and the current release id, integer build, tag, source commit, and release class; +- product, package, checksum, provenance, catalog, update-descriptor, and publication identities; +- current Platform API contract and stable-surface identities without resetting compatibility + clocks; +- stable catalog, first-party app, content-profile, limitation, security, support, and legacy + state; +- the evidence-window policy and any security-hotfix follow-up state. + +The protected workflow writes a deterministic release-history entry and atomically replaces the +latest-published pointer only after independent publication verification passes. A failed or +partially published candidate remains in failed history and never becomes the predecessor for a +later release. The activation adapter supplies the backend with the exact canonical pointer bytes, +requires the independently observed post-activation byte digest to match them for both a new and an +idempotent activation, and records the expected and observed pointer digests in its activation +receipt. The immutable publication authorization is authenticated to the receipt but is not reused +as a time-limited activation credential. After the protected activation environment admits the +job, that job creates a renewable, activation-only authorization bound to the exact receipt, +successor baseline, history entry, original authorization digest, and compare-and-swap predecessor +pointer. The adapter requires that separate grant to be current and no more than one hour long. +This lets an approved retry finish activation after publication without replacing any public byte. +Immediately before invoking the compare-and-swap adapter, the workflow creates a retained +activation-boundary marker. Every activation outcome uploads that marker and an audit that treats +pointer mutation as possible after the boundary, even if the adapter process exits before writing +a receipt. When a success or failure receipt exists, the audit also preserves its independently +observed pointer digest; a missing receipt never implies that the pointer remained unchanged. + +## Candidate build and exact-byte freeze + +A maintenance build introduces new bytes. Build and freeze one candidate once, then preserve that +candidate through authorization and publication. + +Portable archive modes are determined solely from canonical member paths, not host filesystem +execute checks. Directories, Unix/JRE launchers, jlink helper executables, and shipped wrapper +native libraries use the fixed executable mode; JARs, configuration, Windows launchers, and other +ordinary payloads use the fixed regular-file mode on every supported build host. Both the Gradle +and Python normalizers derive these modes anew, and the independent archive gate rejects a member +whose mode does not match its path role. + +Every package producer verifies after its build that `HEAD` is still the declared candidate and +that neither the Git index nor any tracked working-tree path changed. Generated untracked build +outputs are deliberately excluded from this check. The source-lineage gate also requires the exact +`release.sourceCommit` from the authenticated latest predecessor baseline to be an ancestor of the +candidate. A release or hotfix that omits its predecessor's published fixes must stop until the +required manual merge and back-merge have carried those fixes forward. + +The freeze binds: + +- release id, integer build, release class, branch, immutable ref, source commit, and clean source + state; +- Java and Gradle toolchain identity plus dependency-verification state; +- deterministic product archive and every package or installer digest, size, mode, and name; +- stable catalog bytes and detached-signature sidecar as separate exact assets, plus the signing + key, edition, revision, mirrors, and rollback state; +- first-party bundle, manifest, signing, review, permission, data-schema, and support identities; +- Platform API contract, stable diff, compatibility policy, and third-party stable sample results; +- content-profile descriptors, canonicalization, size, and signature-payload rules; +- limitations, security, support, live, interop, performance, sandbox, upgrade, and recovery + evidence digests; +- release notes, checksums, provenance, and deterministic `core-info.json` bytes; +- freeze time and redaction result. + +Archive checks retain the Stable RC rules: canonical member order, normalized timestamps and +ownership, safe regular files/directories/symlinks, deterministic gzip metadata, no absolute or +traversal paths, no special files, no AppleDouble, `__MACOSX`, or `.DS_Store`, and recursive +inspection of nested archives. + +The independent gate also rejects path aliases such as `a`, `a/`, or `bin/./x`, Windows drive and +UNC absolute paths, duplicate members at every nesting level, escaping symlink targets, optional +gzip header fields, noncanonical PAX records, ZIP comments or extra fields, and ZIP members without +explicit Unix type and mode metadata. Nested archives are read through a bounded stream before +materialization. Package rows must agree with their AppEnv selector, producer architecture, +suffix, container magic, and authenticated DEB, RPM, or PE architecture where that metadata is +available; a renamed or mislabeled installer is not an acceptable package. + +After authorization, any changed product, package, signature, catalog, descriptor, checksum, +provenance, note source, archive member, or file mode invalidates the authorization. Produce a new +candidate freeze; a waiver cannot authorize byte drift. + +## Compatibility comparison + +### Platform API 1.0 + +Compare the candidate contract with both the authenticated GA contract and the immediate +predecessor contract. Preserve `stableBaseline.name=1.0`, baseline contract version 19, stable +capability and endpoint membership, required-capability sets, action labels, and app-process and +app-browser access flags. + +The current contract version is monotonic. The candidate carries a canonical structured +deprecation history whose semantic digest is checked against the rows. Existing identities and +their original start versions carry forward from the authenticated predecessor; a maintenance +release cannot drop or backdate a row, restart a clock, or shorten a removal window. +Stable removal, reclassification, access regression, or critical-removal waiver is a non-waivable +blocker. Compatible additions remain outside the frozen 1.0 membership unless a separately +governed future baseline includes them. Experimental additions stay explicitly experimental. + +### Stable catalog and first-party apps + +The seven Stable 1.0 first-party app ids remain present on the stable channel with their support +commitments intact. Compatible bug and security patch bundles are allowed only when the signed +catalog, exact bundle, manifest, trusted review receipt, signing and reviewer keys, Platform API +target, permissions, data schema, migration, backup/restore, service grants, and support metadata +all pass. + +Reject app removal or id substitution, support or channel downgrade, unsigned or unreviewed bytes, +revoked or compromised keys, unexplained permission expansion, incompatible API dependencies, +missing migration/restore paths, stale catalog editions, untrusted mirrors, rollback conflicts, +and advisory or denylist regressions. An approved signing-key rotation must include the complete +trust transition. The comparison artifact lists every exact catalog and app delta. + +App support commitments are ordered rather than fixed: a promotion to a stronger defined support +level is allowed, but a downgrade or unknown level is not. App versions use the same canonical +dotted-numeric comparison as `AppUpdateService`; versions cannot regress below either anchor, and +changed bundle bytes require a strictly newer version so installed nodes can select the update. + +### Content-format profiles + +The frozen v1 identities remain compatible: + +```text +crypta.profile.v1 +crypta.feed.snapshot.v1 +crypta.trust.statement.v1 +crypta.social.message.v1 +crypta.social.outbox.v1 +``` + +Parser, validator, diagnostics, and bounds fixes may ship only when existing valid documents remain +valid and the profile id, canonical signed bytes, signature payload, field meanings, required +fields, and compatible size semantics do not change in place. An incompatible format requires a +new separately versioned profile and explicit migration and deprecation policy. + +### Security, support, limitations, and legacy boundaries + +Catalog, app, and reviewer keys must remain uncompromised. Advisory and exact-version denylist +changes use the signed catalog lifecycle and cannot regress active containment. Known limitations +are rendered as an exact added/resolved/unchanged delta; a maintenance baseline cannot hide a new +Stable blocker or relabel a beta-only limitation. Catalog bytes, signatures, or signing-key identity +may remain at the predecessor edition and revision only when all three identities are unchanged; +otherwise at least one of edition or revision must advance. + +The limitation delta is a partition of the predecessor's authenticated membership. For the GA v1 +root, membership comes from `allowedLimitations[].id`; successor v2 baselines carry the canonical +sorted membership in `limitations.currentIds`. Candidate `addedIds`, `resolvedIds`, and +`unchangedIds` are sorted, unique, disjoint, and must account for every predecessor id. The current +digest covers the sorted current id set, while the delta digest also covers the predecessor set and +all three transition sets. Self-reported counts or review flags cannot replace these comparisons. + +Support evidence stays metadata-only and uses `cryptad-operator-support-bundle` schema v2 or later. +Legacy admin remains maintenance-only, mutating retired routes remain disabled, the legacy plugin +runtime and new in-core plugin APIs remain absent, and FProxy browse, content filtering, and the +documented emergency fallback routes remain retained. + +## Production evidence + +Every scenario is bound to the exact release id, build, source commit, candidate freeze, product, +archive, catalog, predecessor product, and evidence digest. It records start/end time, +environment class, production classification, node and operation counts where relevant, and a +passing final status. Fixture, simulated-only, skipped, stale, dirty-workspace, test-signing, or +wrong-candidate evidence cannot satisfy publication. + +Routine maintenance uses the normal policy windows and complete target matrix. Evidence covers: + +- clean install and launch for every required OS/package target; +- direct GA upgrade when policy requires it and upgrade from the immediate predecessor; +- CoreUpdater discovery, package selection, download containment, and installer validation; +- daemon and app rollback, catalog refresh/rollback, app-data migration dry-run/apply, backup before + migration, restore after failure, and operator recovery; +- Social Inbox, Trust Graph, Feed Reader, Profile Publisher, app-service grant, subscription, and + durable state preservation; +- live-network behavior, Hyphanet interop, performance comparison, network-budget soak, sandbox, + security drills, catalog operations, and redacted support output. + +Every evidence row remains bound to the immediate predecessor used for the candidate. The +`stable-maintenance.direct-ga-upgrade` row additionally records the exact authenticated GA release +id, GA build, and GA product digest; those GA fields are null on every other row. This keeps a later +maintenance build's direct-GA scenario anchored to the immutable GA root without substituting GA +for the predecessor used by rollback and ordinary upgrade scenarios. Follow-up closure applies the +same two bindings. + +The existing multi-node, live-network, network-scale, interop, performance, app-platform, and +security-response collectors remain evidence producers. Their summaries must be protected, +candidate-bound v2 envelopes or explicitly authenticated external evidence; a path or URL alone +does not authenticate a producer. + +## Package matrix and the protected Windows EXE producer + +The normal policy requires the maintained package matrix, including Linux DEB and RPM, macOS DMG, +Windows EXE, and the portable product distribution when declared. Each row binds filename, +OS/architecture/type, digest, size, build, source commit, signing or notarization status, +install/launch/upgrade evidence, data-retention behavior, and redaction. + +The protected Linux-and-portable producer cryptographically signs every exact staged subject with +a GitHub/Sigstore artifact attestation: both Linux installers and all four portable archives. It +then verifies every subject against the exact maintenance workflow, repository, candidate commit, +and GitHub-hosted-runner policy before upload, emitting one public-safe verification receipt per +asset. The freeze job downloads the immutable producer artifact, independently repeats +`gh attestation verify`, and binds the exact per-asset receipt digest into the selected product or +package freeze row. A producer Boolean, checksum, artifact path, or generic build receipt cannot +satisfy `signingStatus=pass`. This keyless boundary requires only the workflow's narrowly scoped +`id-token: write`, `attestations: write`, and `artifact-metadata: write` permissions; it introduces +no package-signing private-key or passphrase variable, and no signing secret may enter a command, +log, receipt, or artifact. Attestation does not modify the staged subject, so the verified bytes +remain the single built-once candidate bytes. + +Dispatch `.github/workflows/stable-1.0-maintenance-windows-package-producer.yml` at the exact +candidate commit to run `jpackageInstallerWindowsExeCryptad` once on a GitHub-hosted Windows +runner. The protected evidence environment supplies the reviewed Authenticode certificate and +timestamp service. Configure the certificate P12 and password as protected secrets and its exact +uppercase SHA-1 certificate identity in `CRYPTAD_WINDOWS_CODE_SIGNING_CERTIFICATE_SHA1`. The exact +protected names are `CRYPTAD_WINDOWS_CODE_SIGNING_P12_BASE64`, +`CRYPTAD_WINDOWS_CODE_SIGNING_P12_PASSWORD`, and +`CRYPTAD_WINDOWS_CODE_SIGNING_TIMESTAMP_URI`; the consumer independently binds the reviewed signer +identity through a public-safe SHA-256 projection. The producer verifies the release/hotfix ref, +integer build, pristine tracked checkout before and after the build, amd64 PE identity, exact +Authenticode signer, trusted timestamp, and source/toolchain identity, then attests both the final +EXE and its public-safe producer receipt. +Pass that run id, artifact name, artifact digest, and exact EXE SHA-256 to `freeze-candidate`; the +consumer independently verifies the workflow, source digest, hosted-runner provenance, bytes, and +receipt. A local Linux or macOS run cannot synthesize or replace this artifact. A security hotfix +always declares a nonempty `affectedPackageKeys` subset of the maintained matrix. Shipping the full +matrix uses `unaffectedPackageProofStatus=not-applicable`; narrowing the published matrix is allowed +only when its package keys exactly equal that affected set and +`unaffectedPackageProofStatus=pass` proves the omitted targets do not ship the vulnerable code. A +valid narrowed hotfix that excludes `amd64.dmg` does not attach the authenticated macOS +notarization receipt to another package: every selected non-DMG row records notarization as +`not-applicable`. + +## CoreUpdater descriptor + +The candidate generates deterministic `core-info.json` with: + +- `version` equal to the canonical integer build string; +- a public HTTPS release page; +- optional protected, candidate-bound short and full changelog CHKs; +- a sorted `.` package map using `AppEnv` platform keys; +- an exact public CHK or store URL and exact size for every candidate package. + +Reject non-integer or stale versions, duplicate keys, placeholders, private insert URIs, local +paths, omitted or substituted packages, misleading local SHA-256 fields, unsupported package keys, +and changelog references not bound to the candidate. Descriptor bytes are part of the freeze, +checksums, provenance, and authorization. + +The publication plan names the update USK edition but never contains the private insert URI or +key. The protected workflow reads private insertion material only from environment or protected +file indirection. After insertion, an independent fetch verifies the public descriptor bytes and +every referenced package identity, then records `core-update-publication-receipt.json`. + +## Authorization and decision + +`prepare-authorization` emits a closed authorization identity containing the release, class, +candidate, GA baseline, predecessor, freeze, product, package, compatibility delta, recovery +evidence, catalog, `core-info.json`, limitation delta, notes, publication targets, and, for a +hotfix, incident and follow-up digests. + +Authorization requires the policy-defined role, named approver, expiration, and exact closed +scopes. Wildcard scopes are forbidden. Routine maintenance normally requires `go`. +`go-with-waivers` is limited to policy-allowlisted noncritical operational warnings; it cannot +cover lineage, byte identity, API/profile compatibility, signing, catalog/app trust, updater, +upgrade/recovery, affected-platform install, sandbox/security, redaction, authorization, public +conflict, or receipt verification. + +Every warning-bearing evidence row must have an exact frozen `operationalWarnings` entry with the +same warning id and evidence digest. The authorization request carries those ids in +`acceptedWarningIds` and requires `go-with-waivers`; a warning omitted from the frozen set, a +declared warning without a matching `warn` row, or a plain `go` decision is a blocker. + +## Protected publication + +Dispatch `.github/workflows/stable-1.0-maintenance-release.yml` only after protected evidence and +authorization are ready. The workflow separates: + +1. `freeze-candidate`, which builds each selected asset once, Developer-ID-signs, notarizes, and + staples the DMG, and emits the attested candidate-freeze record plus those exact bytes; +2. `prepare-authorization`, which consumes that frozen artifact and protected post-freeze evidence + without building, replacing, or signing any candidate byte; +3. `validate-authorization`, which derives a validation manifest from the attested prepared + manifest by changing only the command mode and adding the exact protected authorization; +4. `publish`, which consumes only the attested authorized bundle and an authenticated publication + provider, after the appropriate routine-maintenance or security-hotfix environment approval; +5. current-time exact-byte, remote source-ref, predecessor, authorization, key, and conflict + revalidation before each mutation; +6. annotated tag, GitHub Release/assets, artifact base, stable catalog, and update-descriptor + publication; +7. independent public-state verification; +8. publication receipt, successor baseline, history entry, and latest-pointer activation. + +The four operations are separate protected workflow runs. The prepare run accepts only the exact +`freeze-candidate` run, artifact name, and artifact digest. The authorization-validation run accepts +only the exact prepared artifact and an approval artifact containing one authorization JSON file. +The publish run accepts only the exact authorized artifact; separately supplied candidate, +evidence, package, manifest, or authorization inputs are rejected. This ordering ensures that +post-freeze evidence can describe the bytes actually produced without requiring a speculative +earlier build or rebuilding native installers after authorization. + +Use this handoff sequence; every artifact coordinate means the producing run id, exact artifact +name, and `sha256:` Actions artifact digest: + +| Consumer operation | Authenticated inputs | Output for the next phase | +| --- | --- | --- | +| `freeze-candidate` | `freeze-candidate` phase bundle plus the Windows producer artifact and EXE SHA-256 | Attested frozen-candidate artifact containing the one built asset set. | +| `prepare-authorization` | `prepare-authorization` phase bundle plus the exact frozen-candidate artifact | Attested prepared-candidate artifact; its candidate asset directory is reconstructed only from the prior freeze. | +| `validate-authorization` | `validate-authorization` phase bundle plus the exact prepared-candidate artifact | Attested authorized-candidate artifact. | +| `publish` | Exact authorized-candidate artifact plus the authenticated publication-backend wheel | Publication audit, independent verification, and activation artifacts; no replacement phase bundle is accepted. | + +Use the protected environments `stable-1.0-maintenance-evidence`, +`stable-1.0-maintenance-publication`, and `stable-1.0-security-hotfix-publication` with +least-privilege job permissions. The workflow validates branch/ref/commit identity but never +creates or merges `release/*` or `hotfix/*` branches. Release managers retain the no-squash, +`--no-ff` merge into `main` and back-merge into `develop`. + +Configure the evidence environment with exact GitHub signer-workflow identities in +`CRYPTAD_STABLE_MAINTENANCE_INPUT_SIGNER_WORKFLOW` and +`CRYPTAD_STABLE_MAINTENANCE_WINDOWS_SIGNER_WORKFLOW`. The consumer verifies the selected run's +workflow path, dispatch event, repository, source commit, artifact digest, SLSA provenance signer, +source digest, and hosted-runner identity. The same environment must supply the public-key-only +`STABLE_CATALOG_TRUSTED_KEYS_BASE64` secret. Its decoded value is the production trusted catalog +key registry in `TrustedAppKeys` properties format; it must never contain a private key. The freeze +job builds the checked-out `crypta-app` verifier, rechecks that the build did not change tracked +source, and verifies the exact catalog and detached-signature bytes under the candidate's declared +`signingKeyId`. The temporary registry is mode `0600`, is deleted before job exit, and neither its +key bytes nor raw signature content is serialized into a candidate JSON record. The detached +signature file itself remains a separately frozen and published exact-byte asset. The freeze +records the registry's SHA-256 beside the signer ID so a same-ID trust-material change is auditable +without publishing any public-key material. + +The macOS freeze job also requires the reviewed +`CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION` identity; the protected +`CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION_P12_BASE64` and +`CRYPTAD_MACOS_DEVELOPER_ID_APPLICATION_P12_PASSWORD` secrets; and the protected +`CRYPTAD_MACOS_NOTARY_APPLE_ID`, `CRYPTAD_MACOS_NOTARY_APP_PASSWORD`, and +`CRYPTAD_MACOS_NOTARY_TEAM_ID` secrets. The protected Gradle property signs and strictly verifies +the final enriched `Crypta.app` after `cryptad-dist` and the launcher configuration are installed. +It replaces jpackage signatures in explicit inside-out order with +`codesign --options runtime --timestamp`, preserves the JVM/framework identifier and entitlement +metadata, and signs the app root last. +Missing pre-existing jpackage metadata or any recursive signing attempt is a blocker. Jpackage then +uses that predefined image to create the DMG. +Installer-stage mac signing remains enabled, but the workflow does not treat it as proof that the +outer DMG container is signed. It Developer-ID-signs and verifies the exact DMG after jpackage and +before submitting those bytes to Apple, then staples and verifies the resulting DMG again before +computing the frozen digest. JDK 25's invalid `--type app-image --app-image` combination is never +used. Missing app signing, DMG signing, notarization, stapling, or Gatekeeper verification stops +the freeze. + +Set the two signer variables to the full checked-in workflow identities +`crypta-network/cryptad/.github/workflows/stable-1.0-maintenance-input-producer.yml` and +`crypta-network/cryptad/.github/workflows/stable-1.0-maintenance-windows-package-producer.yml`. +The input producer +retrieves one reviewed public-safe ZIP from the evidence environment's protected secret HTTPS +locator `CRYPTAD_STABLE_MAINTENANCE_INPUT_BUNDLE_URL`, optionally using +`CRYPTAD_STABLE_MAINTENANCE_INPUT_BUNDLE_BEARER_TOKEN`, and requires an independently supplied +exact bundle SHA-256. It forbids redirects, proxies, private-address targets, unsafe archive +members, and every file outside the manifest's referenced phase inputs, including root-level and +sibling files that would otherwise survive artifact upload. It also forbids secrets, placeholders, +runner paths, wrong release/build/class/source identity, and phase-incompatible material. For +retrieval, it resolves the locator once, rejects the complete result if any address is non-global, +connects only to those validated numeric socket addresses, verifies the connected peer address, and +retains the original hostname for TLS SNI and certificate verification. The bearer credential is +therefore never transmitted through an unvalidated second DNS lookup. Before phase-specific +acceptance and attestation, every JSON file must already use the publication boundary's exact +canonical UTF-8 bytes: unescaped Unicode, two-space indentation, sorted object keys, and one final +newline, with duplicate keys forbidden. `freeze-candidate` and +`prepare-authorization` bundles contain +`stable-1.0-maintenance.json` plus `protected-inputs/`; a `validate-authorization` bundle contains +only `stable-1.0-maintenance-authorization.json`. The manifest's sole checkout-relative input is +the exact authoritative +`tools/release-certification/stable-1.0-maintenance-policy.json`; the producer resolves it from the +authenticated, pristine candidate checkout and the engine reauthenticates its exact bytes. Every +other file or directory input remains confined beneath `build/protected-inputs/`. The locator and +credential are never uploaded or serialized. Environment approval, exact bundle and artifact +digests, and the same-commit workflow attestation authenticate the producer; a URL alone does not. + +Publication dispatches identify an exact protected provider artifact by run id, artifact name, and +artifact digest. The canonical provider wheel is produced only by +`.github/workflows/stable-1.0-maintenance-publication-backend-producer.yml` from the current reviewed +`main` commit. Its fixed artifact is `stable-1.0-maintenance-publication-backend` and its fixed +entrypoint is `cryptad_stable_maintenance_backend:factory`; see +`tools/release-certification/publication-backend/README.md` for the closed deployment-service and +capability protocol. Configure the reviewed source commit, wheel digest, signer workflow, and entry +point as repository-level Actions variables: +`CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SOURCE_COMMIT`, +`CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_WHEEL_SHA256`, +`CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SIGNER_WORKFLOW`, and +`CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND`. These four immutable, public-safe identity pins +must be visible to the evidence-scoped independent-verification job as well as both publication +environments; do not configure them only on a publication environment. If organization policy +requires environment-scoped variables, configure the exact same four values on +`stable-1.0-maintenance-evidence`, `stable-1.0-maintenance-publication`, and +`stable-1.0-security-hotfix-publication`. Every clean hosted job authenticates the producer run and +attestation, installs that one wheel without dependency resolution, and loads the provider only +from the authenticated installation directory. Publication environments expose the purpose-bound +`CRYPTAD_STABLE_CATALOG_PUBLICATION_INPUT` only to catalog publication, the purpose-bound +`CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT` only to CoreUpdater insertion, and the distinct +`CRYPTAD_STABLE_MAINTENANCE_STATE_INPUT` only to latest-baseline compare-and-swap activation. A +missing signer identity, provider, or target-specific protected input fails before mutation. The +adapter materializes these opaque values before provider construction, removes their names from +both its environment snapshot and ambient process environment, and passes only the intended value +to the corresponding target method; imports, observations, and unrelated targets see none of them. + +The provider's `deploymentServicePublicUri` follows the same canonical public-HTTPS contract as +authorization: an authority root may appear with or without its trailing slash, and a non-root +endpoint may end in one slash, but internal empty segments, dot segments, credentials, fragments, +ambiguous whitespace, redirects, and non-global addresses are rejected. Independent +`verify-publication` calls are self-contained. They send a closed `verificationInputs` map with +the authenticated plan, candidate and candidate input, lineage, CoreUpdater plan and descriptor, +GA and predecessor baselines, evidence summary, provenance, and nullable follow-up records. Each +entry binds the canonical physical JSON digest to the parsed record. The deployment service does +not rely on undocumented out-of-band candidate state, and the adapter independently validates +every returned receipt, successor baseline, and history entry. + +Configure `LEUMOR_GITHUB_TOKEN` as a secret in both Stable maintenance publication environments. +The protected job verifies `/user` resolves to exactly `leumor` before it gives that credential to +the publication adapter for tag, GitHub Release, and release-asset operations. The job-scoped +Actions token has read-only repository permission and remains limited to workflow-artifact and +attestation authentication; it is not a fallback publication identity. + +Before each mutation, revalidate that the immutable ref did not move, the latest predecessor did +not change, no conflicting publication is active, authorization is current, candidate and public +artifact-base bytes match, tag/Release/catalog/update targets are conflict-free, and signing keys +remain uncompromised. +The canonical provider is verify-only for the artifact base. An independent protected deployment +must pre-stage every planned artifact-base object at its authorized URI before publication +preflight. An entirely absent, partially populated, or byte-mismatched artifact base is a hard stop; +the provider never creates or repairs those objects. Exact pre-staging followed by an absent public +suffix is the first safe resumable-prefix state, so publication begins with the annotated tag. +Before authorization, conflict checking expands every concrete artifact-base child plus the +catalog primary, detached-signature sibling, mirrors, rollback object, GitHub Release page, and +CoreUpdater descriptor. Canonical URI aliases across any two roles are a blocker. + +Activation rereads the exact published authorization and checks its physical digest, role, closed +scope, and decision. It separately rereads the short-lived activation-only authorization created +after the protected environment gate and checks that grant's exact input digests, scope, and expiry +immediately before compare-and-swap, then verifies the remote release or hotfix ref again. The +activation step always uploads its authorization plus its receipt or a truthful workflow audit even +when post-mutation observation fails, then fails the job after retaining that state. + +## Idempotency, conflicts, and recovery + +Existing public state is idempotent success only when every authenticated byte and identity +matches: annotated tag object and target, Release notes and assets, artifact base, stable catalog +primary/mirrors/rollback, update USK edition, fetched `core-info.json`, and package mappings. + +Fail closed and record observed partial state when a tag points elsewhere, a tag is lightweight, +an asset or note differs, an unexpected asset exists, a catalog is stale or untrusted, the update +edition conflicts, or a public observation is unavailable. An interrupted publication is resumable +only when public observation proves an exact matching prefix in the canonical target order and an +entirely absent suffix. Every remaining target receives the same byte, authorization, predecessor, +source-ref, and conflict revalidation before mutation. A non-prefix partial state remains a +conflict. Do not delete, overwrite, repair, or claim absence for conflicting state. + +## Security-hotfix policy and follow-up + +An expedited path is available only for `releaseClass=security-hotfix`, a critical public-safe +incident/advisory identity, a correctly based `hotfix/` branch, the exact authenticated +`policies.candidateBaseCommit` from the currently published `main` state, declared affected surface +and platforms, explicit Stable security release-manager authorization, and a source-scope audit +that rejects unrelated feature work. The candidate declares the closed +`changeScope.shortenedEvidenceIds` set; the authorization binds that exact set and its dedicated +hotfix-policy authorization digest. + +The hotfix policy may shorten only its closed prepublication evidence durations or an explicitly +proved unaffected target matrix. It cannot skip candidate identity, archive and signing integrity, +redaction, API/content compatibility, updater integrity, affected-platform packaging/install, +upgrade, rollback, migration, backup/restore, sandbox/security, conflict, authorization, or receipt +verification. Evidence remains protected production evidence, not fixtures or simulations. + +When the shortened policy is used, the command generates +`stable-1.0-hotfix-follow-up-obligation.json`. It records the incident, published build/product, +shortened scenarios, full evidence still required, deadline, owner/approver, closure criteria, and +escalation behavior. The successor baseline and dashboard show the obligation as open. + +After the full window completes, run `close-hotfix-follow-up` with the latest activated successor +baseline, its publication receipt and latest-published pointer, the original authorization and +obligation, and exact full evidence for the originally obligated hotfix. The obligation binds the +original release id, build, product, candidate identity, freeze, and freeze time. If a later hotfix +carried the obligation forward, do not relabel the evidence or substitute the later hotfix's +authorization. The latest baseline, receipt, and pointer prove where the obligation is currently +carried; the original identities prove which published bytes completed the full window. The +side-effect-free command emits the versioned +`stable-1.0-hotfix-follow-up-closure.json` overlay; it does not rewrite the published hotfix, +successor baseline, receipt, or pointer. A later candidate must authenticate the overlay against +the original obligated candidate identity and product, latest carried successor-baseline digest, +latest publication-receipt digest and receipt identity, latest-pointer digest, obligation digest, +and exact original authorization digest. Its lineage records the exact closure digest that cleared +the obligation. An overdue or failed +obligation becomes a release incident and blocks the next routine maintenance publication. A +separately authorized superseding security hotfix may carry the obligation forward only when the +policy permits it. + +## Public and private data boundaries + +Manifests and public artifacts may contain public URLs, ids, counts, timestamps, status values, +digests, key ids, package names, and redacted summaries. They must not contain: + +- private update or catalog insert URIs, private keys, seed material, or signing files; +- tokens, cookies, credentials, passwords, authorization headers, or CI secret values; +- raw content, fetched documents, app-data values, backups, support bundles, signatures paired + with raw bodies, identity material, or incident artifacts; +- command lines containing secrets, absolute Unix/macOS/Windows/UNC paths, runner workspaces, + staging paths, rollback paths, or local package paths. + +Inputs with duplicate JSON keys, unsafe strings, symlinks, workspace escapes, untrusted archive +members, or placeholders fail before comparison. Public release notes describe validation and +availability only after the independently verified receipt proves publication. Checked-in docs, +templates, fixtures, and self-tests never claim that a maintenance build or hotfix was published. + +## Local verification + +Run the focused offline suite after changing the maintenance policy, schemas, engine, workflows, +provider, packaging logic, or this contract: + +```bash +python3 tools/release-certification/certify.py stable-maintenance --self-test +python3 tools/release-certification/certify.py stable-ga --self-test +python3 tools/release-certification/certify.py stable-rc --self-test +./gradlew verifyMacAppImageSigningArguments verifyWindowsExeInstallerArguments +``` + +When portable archive construction changes, also build the affected `distZipCryptad`, +`distTarCryptad`, and `distJlinkCryptad` outputs and run the maintenance self-test so the Python +hygiene gate checks the Java-normalized bytes. These commands are validation only. Developer ID, +Authenticode, notarization, multi-OS installer production, public artifact staging, catalog or USK +insertion, and latest-pointer activation remain protected operations and must not be claimed from a +local run. + +## Branch and merge completion + +Publication does not merge branches. After the release manager verifies the receipt: + +- merge `release/` or `hotfix/` into `main` with `--no-ff` and no squash; +- back-merge the same branch into `develop` with `--no-ff` and no squash; +- verify `main` contains the shipped commit and the annotated `v` tag and publication + receipt identify that exact commit. The `main` tip is normally the later `--no-ff` merge commit. + +See [the standard Git workflow](standard-git-branching-and-release-workflow.md) for branch +operations and [the release runbook](cryptad-release-workflow-and-runbook.md) for the wider release +gate checklist. diff --git a/docs/stable-1.0-rc-validation-and-ga-promotion.md b/docs/stable-1.0-rc-validation-and-ga-promotion.md index c331d3e1da7..e8f410d8a52 100644 --- a/docs/stable-1.0-rc-validation-and-ga-promotion.md +++ b/docs/stable-1.0-rc-validation-and-ga-promotion.md @@ -596,3 +596,14 @@ non-waivable. - [ ] Public tag, release assets, notes, and catalog state match the authorization. - [ ] The publication receipt says `publication-complete`; otherwise GA is not complete. - [ ] The Stable 1.0 maintenance baseline is retained with the release record. + +## Later Stable 1.0 releases + +The GA baseline remains the immutable compatibility root. Later integer-build maintenance releases +and security hotfixes authenticate the GA publication receipt and those exact baseline bytes; they +do not refreeze the RC, rerun GA promotion, or replace the GA baseline. They also authenticate the +immediately preceding published Stable 1.0 release and advance a separate successor-baseline chain. + +Use the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md) for candidate freeze, compatibility +comparison, authorization, protected exact-byte publication, and hotfix follow-up closure. diff --git a/docs/standard-git-branching-and-release-workflow.md b/docs/standard-git-branching-and-release-workflow.md index 062f16c8f4b..e5bcd1a1b8f 100644 --- a/docs/standard-git-branching-and-release-workflow.md +++ b/docs/standard-git-branching-and-release-workflow.md @@ -139,3 +139,15 @@ For a Stable 1.0 GA publication, also confirm: - The integer build number participates in network compatibility checks and update gating. - Update-over-Mandatory (UOM) for the core JAR is disabled in favor of the package‑based CoreUpdater. Tags and build numbers still identify releases for packaging and distribution. + +## Stable 1.0 maintenance and security hotfix branches + +Stable 1.0 maintenance keeps the same branch model: `release/` starts from +`develop`, while an urgent `hotfix/` starts from the currently published `main` state. +The protected maintenance workflow validates the immutable ref and creates the annotated +`v` tag only when exact-byte publication is authorized. It never merges either branch. +Release and hotfix merge-backs remain explicit, no-squash, `--no-ff` operations. + +See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md) for the complete lineage, authorization, +publication, and recovery rules. diff --git a/docs/templates/stable-1.0-maintenance-release-notes.md b/docs/templates/stable-1.0-maintenance-release-notes.md new file mode 100644 index 00000000000..31054ecc50d --- /dev/null +++ b/docs/templates/stable-1.0-maintenance-release-notes.md @@ -0,0 +1,52 @@ + +# Cryptad Stable 1.0 maintenance release + +{{identity}} + +## Release class + +{{classification}} + +## User-visible fixes + +{{fixes}} + +## Security advisory + +{{security}} + +## Platform API compatibility + +{{platform_api}} + +## Stable catalog and first-party apps + +{{catalog_apps}} + +## Upgrade, migration, backup, and rollback + +{{migration_recovery}} + +## Installer and package matrix + +{{packages}} + +## CoreUpdater availability + +{{core_updater}} + +## Known limitations + +{{limitations}} + +## Support and diagnostics + +{{support}} + +## Checksums and provenance + +{{verification}} + +## Publication status + +{{publication_status}} diff --git a/docs/trust-social-content-format-profiles.md b/docs/trust-social-content-format-profiles.md index 315cbcf4d59..7bde6178e63 100644 --- a/docs/trust-social-content-format-profiles.md +++ b/docs/trust-social-content-format-profiles.md @@ -183,3 +183,14 @@ These profiles do not implement WoT, Freetalk, Sone, Freemail, or old plugin ABI compatibility. They do not add global moderation, daemon-core crawling, arbitrary browser signing, remote scripts, analytics, direct HTTP fetch, or generic network-message storage. They do not expose AppVault private key material or raw identity envelopes. + +## Stable 1.0 maintenance compatibility + +Maintenance validation compares all frozen v1 profile identities, descriptor digests, +canonicalization rules, signature payload rules, field meanings, and size semantics against both +the immutable GA baseline and the immediate predecessor. Parser, bounds, diagnostic, and verifier +bug fixes are allowed only when previously valid signed documents remain valid. An incompatible +change requires a separately versioned profile and an explicit migration plan. + +See the [Stable 1.0 maintenance release and security hotfix +path](stable-1.0-maintenance-release-and-hotfix-path.md). diff --git a/platform-appcatalog/src/main/java/network/crypta/platform/appcatalog/AppCatalogVerifier.java b/platform-appcatalog/src/main/java/network/crypta/platform/appcatalog/AppCatalogVerifier.java index fa90eba7413..f3bac976d9b 100644 --- a/platform-appcatalog/src/main/java/network/crypta/platform/appcatalog/AppCatalogVerifier.java +++ b/platform-appcatalog/src/main/java/network/crypta/platform/appcatalog/AppCatalogVerifier.java @@ -90,10 +90,35 @@ private static Map parseSignatureSidecar(byte[] signatureBytes) public static AppCatalog verify( byte[] catalogBytes, byte[] signatureBytes, TrustedAppKeys trustedKeys) throws AppCatalogException { + return verify(catalogBytes, signatureBytes, trustedKeys, null); + } + + /** + * Verifies a catalog under one explicitly declared trusted signing-key identity. + * + *

This overload is intended for release boundaries that have already frozen the expected key + * id separately from the detached signature. It rejects a structurally valid signature made by + * any other trusted key before checking the signature bytes, preventing a broad trusted-key + * registry from weakening the release's exact signer binding. + * + * @param catalogBytes exact bytes from {@code cryptad-app-catalog.properties} + * @param signatureBytes exact bytes from {@code cryptad-app-catalog.signature} + * @param trustedKeys explicit trusted Ed25519 public keys + * @param expectedKeyId exact key id declared by the release candidate + * @return authenticated catalog content in declared entry order + * @throws AppCatalogException if the signer identity, signature, trust, or catalog parsing fails + */ + public static AppCatalog verify( + byte[] catalogBytes, byte[] signatureBytes, TrustedAppKeys trustedKeys, String expectedKeyId) + throws AppCatalogException { Objects.requireNonNull(catalogBytes, "catalogBytes"); Objects.requireNonNull(signatureBytes, "signatureBytes"); Objects.requireNonNull(trustedKeys, "trustedKeys"); AppCatalogSignature signature = readSignature(signatureBytes); + if (expectedKeyId != null && !signature.keyId().equals(expectedKeyId)) { + throw AppCatalogSidecars.invalidSignature( + "catalog signature key id does not match expected key id"); + } TrustedAppKey trustedKey = trustedKeys .find(signature.keyId()) @@ -127,6 +152,36 @@ public static AppCatalog verify( public static AppCatalog verify(Path catalogFile, TrustedAppKeys trustedKeys) throws IOException { Path normalizedCatalogFile = Objects.requireNonNull(catalogFile, "catalogFile").toAbsolutePath().normalize(); + return verify( + normalizedCatalogFile, + normalizedCatalogFile.resolveSibling(AppCatalogSignature.SIGNATURE_FILE_NAME), + trustedKeys, + null); + } + + /** + * Verifies exact catalog and detached-signature files under a declared signing-key identity. + * + *

Release workflows use this overload because their authenticated candidate can give the + * detached sidecar a public artifact filename that differs from the runtime source filename. Both + * files are read as exact bounded byte sequences and the signature sidecar's key id must equal + * {@code expectedKeyId}. + * + * @param catalogFile exact catalog-properties file + * @param signatureFile exact detached signature sidecar + * @param trustedKeys explicit trusted Ed25519 public keys + * @param expectedKeyId exact key id declared by the release candidate + * @return authenticated catalog content in declared entry order + * @throws IOException if either sidecar cannot be read + * @throws AppCatalogException if the signer identity, signature, trust, or catalog parsing fails + */ + public static AppCatalog verify( + Path catalogFile, Path signatureFile, TrustedAppKeys trustedKeys, String expectedKeyId) + throws IOException { + Path normalizedCatalogFile = + Objects.requireNonNull(catalogFile, "catalogFile").toAbsolutePath().normalize(); + Path normalizedSignatureFile = + Objects.requireNonNull(signatureFile, "signatureFile").toAbsolutePath().normalize(); byte[] catalogBytes = AppCatalogSidecars.readRequiredBytes( normalizedCatalogFile, @@ -135,11 +190,11 @@ public static AppCatalog verify(Path catalogFile, TrustedAppKeys trustedKeys) th AppCatalogSidecars.INVALID_CATALOG_ENTRY); byte[] signatureBytes = AppCatalogSidecars.readRequiredBytes( - normalizedCatalogFile.resolveSibling(AppCatalogSignature.SIGNATURE_FILE_NAME), + normalizedSignatureFile, AppCatalogSidecars.MAX_SIGNATURE_BYTES, "catalog signature", AppCatalogSidecars.INVALID_CATALOG_SIGNATURE); - return verify(catalogBytes, signatureBytes, trustedKeys); + return verify(catalogBytes, signatureBytes, trustedKeys, expectedKeyId); } private static void verifySignature( diff --git a/platform-appcatalog/src/test/java/network/crypta/platform/appcatalog/AppCatalogVerifierTest.java b/platform-appcatalog/src/test/java/network/crypta/platform/appcatalog/AppCatalogVerifierTest.java new file mode 100644 index 00000000000..691d5eb05c2 --- /dev/null +++ b/platform-appcatalog/src/test/java/network/crypta/platform/appcatalog/AppCatalogVerifierTest.java @@ -0,0 +1,134 @@ +package network.crypta.platform.appcatalog; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import network.crypta.platform.appdist.TrustedAppKey; +import network.crypta.platform.appdist.TrustedAppKeys; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SuppressWarnings("java:S100") +class AppCatalogVerifierTest { + private static final String EXPECTED_KEY_ID = "stable-catalog-production"; + private static final String OTHER_TRUSTED_KEY_ID = "stable-catalog-other"; + + @TempDir private Path tempDir; + + @Test + void verify_whenDetachedSignatureAndExpectedKeyMatch_expectAuthenticatesExactCatalog() + throws Exception { + KeyPair expectedKey = keyPair(); + KeyPair otherTrustedKey = keyPair(); + Path catalog = writeCatalog(); + AppCatalogSigner.sign(catalog, EXPECTED_KEY_ID, expectedKey.getPrivate()); + Path detachedSignature = tempDir.resolve("candidate-catalog.detached-signature"); + Files.move(catalog.resolveSibling(AppCatalogSignature.SIGNATURE_FILE_NAME), detachedSignature); + TrustedAppKeys trustedKeys = trustedKeys(expectedKey, otherTrustedKey); + + AppCatalog verified = + AppCatalogVerifier.verify(catalog, detachedSignature, trustedKeys, EXPECTED_KEY_ID); + + assertEquals("stable", verified.catalogId()); + } + + @Test + void verify_whenSignatureUsesDifferentTrustedKeyThanDeclared_expectRejectsSignerSubstitution() + throws Exception { + KeyPair expectedKey = keyPair(); + KeyPair otherTrustedKey = keyPair(); + Path catalog = writeCatalog(); + AppCatalogSigner.sign(catalog, OTHER_TRUSTED_KEY_ID, otherTrustedKey.getPrivate()); + Path signature = catalog.resolveSibling(AppCatalogSignature.SIGNATURE_FILE_NAME); + TrustedAppKeys trustedKeys = trustedKeys(expectedKey, otherTrustedKey); + + AppCatalogException exception = + assertThrows( + AppCatalogException.class, + () -> AppCatalogVerifier.verify(catalog, signature, trustedKeys, EXPECTED_KEY_ID)); + + assertEquals(AppCatalogSidecars.INVALID_CATALOG_SIGNATURE, exception.errorCode()); + assertEquals("catalog signature key id does not match expected key id", exception.getMessage()); + } + + @Test + void verify_whenCatalogBytesChangeAfterSigning_expectRejectsDetachedSignature() throws Exception { + KeyPair expectedKey = keyPair(); + KeyPair otherTrustedKey = keyPair(); + Path catalog = writeCatalog(); + AppCatalogSigner.sign(catalog, EXPECTED_KEY_ID, expectedKey.getPrivate()); + Path signature = catalog.resolveSibling(AppCatalogSignature.SIGNATURE_FILE_NAME); + Files.writeString( + catalog, + "catalog.name=Mutated Stable catalog\n", + StandardCharsets.UTF_8, + java.nio.file.StandardOpenOption.APPEND); + TrustedAppKeys trustedKeys = trustedKeys(expectedKey, otherTrustedKey); + + AppCatalogException exception = + assertThrows( + AppCatalogException.class, + () -> AppCatalogVerifier.verify(catalog, signature, trustedKeys, EXPECTED_KEY_ID)); + + assertEquals(AppCatalogSidecars.INVALID_CATALOG_SIGNATURE, exception.errorCode()); + } + + @Test + void verify_whenRegistryReplacesPublicKeyUnderExpectedId_expectRejectsSignature() + throws Exception { + KeyPair signingKey = keyPair(); + KeyPair substitutedKey = keyPair(); + Path catalog = writeCatalog(); + AppCatalogSigner.sign(catalog, EXPECTED_KEY_ID, signingKey.getPrivate()); + Path signature = catalog.resolveSibling(AppCatalogSignature.SIGNATURE_FILE_NAME); + TrustedAppKeys substitutedRegistry = + TrustedAppKeys.of( + new TrustedAppKey( + EXPECTED_KEY_ID, + AppCatalogSignature.SIGNATURE_ALGORITHM, + substitutedKey.getPublic())); + + AppCatalogException exception = + assertThrows( + AppCatalogException.class, + () -> + AppCatalogVerifier.verify( + catalog, signature, substitutedRegistry, EXPECTED_KEY_ID)); + + assertEquals(AppCatalogSidecars.INVALID_CATALOG_SIGNATURE, exception.errorCode()); + } + + private Path writeCatalog() throws Exception { + Path catalog = tempDir.resolve(AppCatalogSignature.CATALOG_FILE_NAME); + Files.writeString( + catalog, + """ + catalog.version=1 + catalog.id=stable + catalog.name=Stable catalog + catalog.generatedAt=2026-07-20T00:00:00Z + catalog.entries= + """, + StandardCharsets.UTF_8); + return catalog; + } + + private static KeyPair keyPair() throws Exception { + return KeyPairGenerator.getInstance(AppCatalogSignature.SIGNATURE_ALGORITHM).generateKeyPair(); + } + + private static TrustedAppKeys trustedKeys(KeyPair expectedKey, KeyPair otherTrustedKey) { + return TrustedAppKeys.of( + new TrustedAppKey( + EXPECTED_KEY_ID, AppCatalogSignature.SIGNATURE_ALGORITHM, expectedKey.getPublic()), + new TrustedAppKey( + OTHER_TRUSTED_KEY_ID, + AppCatalogSignature.SIGNATURE_ALGORITHM, + otherTrustedKey.getPublic())); + } +} diff --git a/platform-devtools/src/main/java/network/crypta/platform/devtools/CryptaAppCli.java b/platform-devtools/src/main/java/network/crypta/platform/devtools/CryptaAppCli.java index e8c4bbce200..1a0b275bc6d 100644 --- a/platform-devtools/src/main/java/network/crypta/platform/devtools/CryptaAppCli.java +++ b/platform-devtools/src/main/java/network/crypta/platform/devtools/CryptaAppCli.java @@ -4490,6 +4490,12 @@ static final class CatalogVerifyCommand extends SpecAwareCommand implements Call @Option(names = "--catalog-file", required = true, description = "Catalog properties file.") private Path catalogFile; + @Option(names = "--catalog-signature-file", description = "Exact detached signature sidecar.") + private Path catalogSignatureFile; + + @Option(names = "--expected-key-id", description = "Exact expected catalog signing key id.") + private String expectedKeyId; + @Option(names = "--trusted-keys-file", description = "Trusted keys properties file.") private Path trustedKeysFile; @@ -4508,14 +4514,19 @@ public Integer call() throws Exception { KeyMaterialLoader.loadTrustedKeys( trustedKeysFile, trustedKeyId, trustedPublicKeyBase64, trustedPublicKeyFile); Path signatureFile = - catalogFile - .toAbsolutePath() - .normalize() - .resolveSibling(AppCatalogSignature.SIGNATURE_FILE_NAME); + catalogSignatureFile == null + ? catalogFile + .toAbsolutePath() + .normalize() + .resolveSibling(AppCatalogSignature.SIGNATURE_FILE_NAME) + : catalogSignatureFile; if (!Files.exists(signatureFile)) { throw new AppDistributionException("missing catalog signature: " + signatureFile); } - AppCatalog catalog = AppCatalogVerifier.verify(catalogFile, trustedKeys); + AppCatalog catalog = + expectedKeyId == null + ? AppCatalogVerifier.verify(catalogFile, signatureFile, trustedKeys, null) + : AppCatalogVerifier.verify(catalogFile, signatureFile, trustedKeys, expectedKeyId); super.commandLine().getOut().println("Verified catalog: " + catalog.catalogId()); return CommandLine.ExitCode.OK; } diff --git a/platform-devtools/src/test/java/network/crypta/platform/devtools/CryptaAppCliTest.java b/platform-devtools/src/test/java/network/crypta/platform/devtools/CryptaAppCliTest.java index ca8675b1be3..9455938eb24 100644 --- a/platform-devtools/src/test/java/network/crypta/platform/devtools/CryptaAppCliTest.java +++ b/platform-devtools/src/test/java/network/crypta/platform/devtools/CryptaAppCliTest.java @@ -2814,12 +2814,32 @@ void catalogSignAndVerify_whenCatalogCliRoundTrips_expectSuccess() throws Except "dev-local", "--private-key-file", privateKey.toString()); + Path detachedSignature = tempDir.resolve("candidate-catalog.detached-signature"); + Files.move(catalogFile.resolveSibling("cryptad-app-catalog.signature"), detachedSignature); CliResult verifyResult = runCli( "catalog", "verify", "--catalog-file", catalogFile.toString(), + "--catalog-signature-file", + detachedSignature.toString(), + "--expected-key-id", + "dev-local", + "--trusted-key-id", + "dev-local", + "--trusted-public-key-file", + publicKey.toString()); + CliResult wrongExpectedKey = + runCli( + "catalog", + "verify", + "--catalog-file", + catalogFile.toString(), + "--catalog-signature-file", + detachedSignature.toString(), + "--expected-key-id", + "catalog-production-other", "--trusted-key-id", "dev-local", "--trusted-public-key-file", @@ -2829,6 +2849,9 @@ void catalogSignAndVerify_whenCatalogCliRoundTrips_expectSuccess() throws Except assertTrue(signResult.out().contains("Signed catalog with key: dev-local")); assertEquals(CommandLine.ExitCode.OK, verifyResult.exitCode()); assertTrue(verifyResult.out().contains("Verified catalog: dev")); + assertEquals(CommandLine.ExitCode.SOFTWARE, wrongExpectedKey.exitCode()); + assertTrue( + wrongExpectedKey.err().contains("catalog signature key id does not match expected key id")); } @Test diff --git a/tools/release-certification/README.md b/tools/release-certification/README.md index 9d67ea6851b..97980730210 100644 --- a/tools/release-certification/README.md +++ b/tools/release-certification/README.md @@ -503,3 +503,124 @@ new public state as `publication-complete`. Follow the [production security response runbook](../../docs/production-security-response-runbook.md) when collecting release-blocking drill evidence or responding to an app ecosystem incident. + +## Stable 1.0 maintenance and security hotfix certification + +Use one command and policy family for both release classes: + +```bash +python3 tools/release-certification/certify.py stable-maintenance \ + --manifest build/stable-1.0-maintenance.json +``` + +The manifest selects `maintenance` or `security-hotfix` and one side-effect-free mode: +`validate-only`, `prepare-authorization`, or `close-hotfix-follow-up`. Output is release-scoped +under `build/release-certification//stable-maintenance/`. Self-tests and ordinary local +execution never tag, publish, insert a CoreUpdater descriptor, or update the latest baseline. +The generated `stable-1.0-maintenance-checksums.txt` names only noncircular public payloads: +product and package bytes, the stable catalog and detached signature, release notes, the +known-limitations delta, provenance, and `core-info.json`. It does not name internal certification +records. The checksum file itself and the public authorization are separately bound by exact size +and digest in the publication plan and receipt because including either would introduce a checksum +or authorization cycle. The separate +`stable-1.0-maintenance-audit-checksums.txt` deterministically inventories every other file in the +component output for internal audit and recovery and is never a planned public asset. +Candidate construction has a separate protected `freeze-candidate` boundary. Its versioned +`stable-1.0-maintenance-candidate-freeze.json` records the one-build producer and source identities, +toolchain and dependency-verification state, latest predecessor observation, exact checksum digest, +and the complete product, catalog, and package byte/signing/notarization receipt set. Subsequent +validation supplies that file as `inputs.maintenanceCandidateFreeze`; the candidate declaration, +candidate provenance, authorization, evidence envelope, and every evidence row bind its exact file +digest. Evidence must start after the recorded `frozenAt`. A rebuild, stale predecessor, extra or +replaced asset, or pre-freeze evidence requires a new freeze and cannot be repaired during +authorization preparation. + +Production evidence rows bind the immediate predecessor build and product. The +`stable-maintenance.direct-ga-upgrade` row also binds the separately authenticated immutable GA +release id, build, and product digest; all non-GA rows forbid those GA fields. Normal validation and +hotfix follow-up closure enforce both identities, so a later successor cannot relabel its immediate +predecessor as the direct-GA upgrade source. + +The protected maintenance workflow uses four closed operations in four runs: +`freeze-candidate`, `prepare-authorization`, `validate-authorization`, and `publish`. Freeze is the +only operation that builds packages; it signs, notarizes, staples, and verifies the DMG before +recording any digest. Prepare consumes the exact attested freeze plus later candidate-bound evidence +and cannot replace an asset. Authorization validation consumes the exact prepared artifact plus an +approval artifact containing only the authorization JSON. Publish consumes the exact authorized +bundle and rejects separately supplied candidate, evidence, package, manifest, or authorization +inputs. Its provider is one protected, attested wheel pinned by producer run, artifact digest, +source commit, wheel digest, signer workflow, and entry point, then installed on each clean hosted +runner without dependency resolution. Publication and latest-baseline activation reread the remote +release/hotfix ref at their mutation boundaries. Publication requires the original authorization to +remain current before every public target. After the protected activation environment gate, the +workflow issues a separate activation-only authorization, valid for at most one hour and bound to +the exact verified receipt, successor, history, original authorization, and predecessor pointer. +That renewable grant prevents an environment wait from stranding already-published exact bytes; +activation audit state is uploaded even if post-mutation verification fails. + +Hotfix closure authenticates the already published successor baseline, publication receipt, +latest-published pointer, original authorization, and obligation, then emits a separately versioned +closure overlay. The next release lineage binds that overlay digest; closure never changes the +published hotfix bytes or rewrites an activated baseline. When a later hotfix carries the +obligation, candidate-freeze authentication uses the original predecessor observation in the exact +authorized freeze while the latest baseline, receipt, and pointer independently authenticate the +current carrier. +Protected publication writes `stable-1.0-maintenance-publication-receipt.json` only for a complete, +independently verified result. Failure and partial state use the closed +`stable-1.0-maintenance-publication-failure-audit.json` schema so unavailable observations and +possible side effects are recorded without manufacturing a canonical receipt. +An interrupted operation can resume only when the observed public targets are an exact matching +prefix in canonical mutation order followed solely by absent targets; any other partial topology is +a conflict and is never overwritten or deleted automatically. +The protected evidence environment must configure exact input and Windows signer-workflow +identities. It must also configure `STABLE_CATALOG_TRUSTED_KEYS_BASE64` as a base64-encoded +`TrustedAppKeys` properties registry containing production catalog public keys only. Before +freezing, the workflow decodes that registry into a mode-`0600` temporary file, verifies the exact +candidate catalog and detached signature with `AppCatalogVerifier`, and requires the signature key +id to equal the candidate's declared `signingKeyId`. The workflow deletes the registry before job +exit and records only the catalog digest, signature digest, key id, trusted-key-registry SHA-256, +algorithm, verifier identity, and passing status. It never writes public-key bytes or raw signature +content into a JSON verification record. The exact detached signature sidecar remains a separately +frozen and published asset. + +Configure the approved publication backend source commit, wheel digest, signer workflow, +and entry point as repository-level Actions variables so the evidence-scoped independent verifier +and both publication environments receive the same immutable, public-safe identity pins. Do not +scope those four backend identity variables only to a publication environment. Keep the separate +catalog, CoreUpdater, and maintenance-state protected inputs in their purpose-specific publication +environments. Private target values are environment indirections only and are forbidden in +manifests, component outputs, failure audits, and receipts. + +Build that backend only through +`.github/workflows/stable-1.0-maintenance-publication-backend-producer.yml` at a reviewed `main` +commit. The consumer pins the producer run, artifact name and digest, source commit, deterministic +wheel digest, signer workflow, and `cryptad_stable_maintenance_backend:factory` entry point before +installing it outside the candidate import path. The deployment service accepts canonical public +HTTPS roots with or without a trailing slash and non-root endpoints with at most one terminal +slash; it rejects internal empty or dot segments and non-global destinations. Its +`verify-publication` request receives the closed, digest-bound `verificationInputs` record set +needed to construct receipts, successor state, and history without out-of-band candidate data. +See [the publication-backend protocol](publication-backend/README.md). + +The adapter materializes each target input, permanently scrubs all target-input names from its +local and ambient environments before importing the provider, and passes an opaque value only to +the one matching target operation. It also expands every concrete artifact, catalog/signature, +mirror/rollback, GitHub Release, and update-descriptor URI before authorization and rejects any +canonical cross-role collision. +The canonical signer workflows are +`.github/workflows/stable-1.0-maintenance-input-producer.yml` and +`.github/workflows/stable-1.0-maintenance-windows-package-producer.yml`. The former authenticates an +exact-digest public-safe phase ZIP from a secret protected-environment HTTPS locator. It rejects any +non-global DNS answer, pins the connection to the validated numeric endpoints, verifies the actual +peer, and uses the original hostname for TLS certificate verification before transmitting an +optional bearer credential. The complete extracted tree is allowlisted: only the canonical phase +manifest and its referenced protected inputs may survive into the attested artifact, so unrelated +root-level or sibling files fail intake. The latter builds the Windows EXE once, +Authenticode-signs and verifies it, rechecks the immutable tracked source, and attests the exact +EXE and producer receipt. Neither +workflow publishes release state. + +The protected workflow performs current-time revalidation before exact-byte public mutations and +independent receipt verification afterward. See the [Stable 1.0 maintenance release and security +hotfix path](../../docs/stable-1.0-maintenance-release-and-hotfix-path.md) for required inputs, +lineage, evidence, authorization, private secret boundaries, idempotency, and recovery. diff --git a/tools/release-certification/cryptad_certification/cli.py b/tools/release-certification/cryptad_certification/cli.py index 15df3610718..c70735730fb 100644 --- a/tools/release-certification/cryptad_certification/cli.py +++ b/tools/release-certification/cryptad_certification/cli.py @@ -28,6 +28,7 @@ "stable-readiness", "stable-rc", "stable-ga", + "stable-maintenance", ) MULTI_NODE_ACTIONS = ( "plan", @@ -60,6 +61,7 @@ "stable-readiness", "stable-rc", "stable-ga", + "stable-maintenance", "migration", ) @@ -263,6 +265,144 @@ def _validate_stable_ga_manifest(manifest: RunManifest) -> None: ) +def _validate_stable_maintenance_manifest(manifest: RunManifest) -> None: + """Reject Stable maintenance runs whose class, mode, or immutable inputs are ambiguous.""" + + if manifest.release.profile != "stable-review": + raise ValueError("stable-maintenance requires release.profile stable-review") + version = manifest.release.version + if version is None or re.fullmatch(r"[1-9][0-9]*", version) is None: + raise ValueError( + "stable-maintenance requires a canonical positive integer release.version" + ) + release_class = manifest.policies.get("releaseClass") + if release_class not in {"maintenance", "security-hotfix"}: + raise ValueError( + "stable-maintenance requires policies.releaseClass maintenance or security-hotfix" + ) + expected_branch = f"{'release' if release_class == 'maintenance' else 'hotfix'}/{version}" + if manifest.policies.get("candidateSourceBranch") != expected_branch: + raise ValueError( + f"stable-maintenance {release_class} requires policies.candidateSourceBranch " + f"{expected_branch}" + ) + commit = manifest.policies.get("candidateSourceCommit") + if not isinstance(commit, str) or re.fullmatch(r"[0-9a-f]{40,64}", commit) is None: + raise ValueError( + "stable-maintenance requires a canonical policies.candidateSourceCommit" + ) + if manifest.policies.get("candidateSourceRef") != f"commit:{commit}": + raise ValueError( + "stable-maintenance requires policies.candidateSourceRef to be commit:" + ) + base_commit = manifest.policies.get("candidateBaseCommit") + if ( + not isinstance(base_commit, str) + or re.fullmatch(r"[0-9a-f]{40,64}", base_commit) is None + or base_commit == commit + ): + raise ValueError( + "stable-maintenance requires a distinct canonical policies.candidateBaseCommit" + ) + if manifest.policies.get("catalogChannel") != "stable": + raise ValueError("stable-maintenance requires policies.catalogChannel stable") + if manifest.policies.get("publicationIntent") != ( + "prepare-explicit-protected-publication" + ): + raise ValueError( + "stable-maintenance requires explicit protected publication intent" + ) + required_inputs = { + "stableGaPromotionSummary", + "stableGaValidation", + "stableGaAuthorizationSummary", + "stableGaPublicationPlan", + "stableGaPublicationReceipt", + "stableGaChecksums", + "stableGaProvenance", + "stableGaMaintenanceBaseline", + "predecessorPublicationReceipt", + "predecessorBaseline", + "maintenanceCandidate", + "maintenanceCandidateFreeze", + "maintenanceCandidateAssets", + "maintenanceCandidateChecksums", + "maintenanceCandidateProvenance", + "maintenanceEvidence", + "maintenancePolicy", + } + missing = sorted(required_inputs.difference(manifest.inputs)) + if missing: + raise ValueError( + "stable-maintenance requires explicit immutable inputs: " + + ", ".join(missing) + ) + mode = manifest.commands.get("stable-maintenance", {}).get( + "mode", "validate-only" + ) + allowed_modes = { + "validate-only", + "prepare-authorization", + "close-hotfix-follow-up", + } + if mode not in allowed_modes: + raise ValueError( + "stable-maintenance command mode must be validate-only, " + "prepare-authorization, or close-hotfix-follow-up" + ) + has_authorization = "stableMaintenanceAuthorization" in manifest.inputs + if mode == "prepare-authorization" and has_authorization: + raise ValueError( + "stable-maintenance prepare-authorization must not include authorization" + ) + if mode == "prepare-authorization" and ( + "stableMaintenancePublicationReceipt" in manifest.inputs + or "coreUpdatePublicationReceipt" in manifest.inputs + ): + raise ValueError( + "stable-maintenance prepare-authorization cannot verify publication receipts" + ) + if mode == "validate-only" and not has_authorization: + raise ValueError( + "stable-maintenance validate-only requires inputs.stableMaintenanceAuthorization" + ) + if mode == "close-hotfix-follow-up": + required_closure = { + "stableMaintenanceAuthorization", + "hotfixFollowUpObligation", + "hotfixFollowUpEvidence", + } + missing_closure = sorted(required_closure.difference(manifest.inputs)) + if release_class != "security-hotfix" or missing_closure: + raise ValueError( + "stable-maintenance close-hotfix-follow-up requires security-hotfix and inputs: " + + ", ".join(missing_closure) + ) + receipt_inputs = { + "stableMaintenancePublicationReceipt", + "coreUpdatePublicationReceipt", + } + present_receipts = receipt_inputs.intersection(manifest.inputs) + if present_receipts and present_receipts != receipt_inputs: + raise ValueError( + "stable-maintenance publication verification requires both maintenance and " + "CoreUpdater receipts" + ) + predecessor_build = manifest.policies.get("expectedPredecessorBuild") + if ( + not isinstance(predecessor_build, str) + or re.fullmatch(r"[1-9][0-9]*", predecessor_build) is None + or ( + mode != "close-hotfix-follow-up" + and int(version) <= int(predecessor_build) + ) + ): + raise ValueError( + "stable-maintenance requires a canonical latest predecessor and a strictly " + "higher release build outside follow-up closure" + ) + + def _run_command(args: argparse.Namespace) -> int: command = str(args.command) if getattr(args, "self_test", False): @@ -276,6 +416,8 @@ def _run_command(args: argparse.Namespace) -> int: _validate_stable_rc_manifest(manifest) if command == "stable-ga": _validate_stable_ga_manifest(manifest) + if command == "stable-maintenance": + _validate_stable_maintenance_manifest(manifest) prepare_run_root(manifest) previous = Path.cwd() diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_ga_core.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_ga_core.py index 08a08626add..ab4d02aa8a8 100644 --- a/tools/release-certification/cryptad_certification/engines/stable_1_0_ga_core.py +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_ga_core.py @@ -255,7 +255,7 @@ def is_public_https_uri(value: Any) -> bool: if not isinstance(value, str) or not value: return False parsed = urlparse(value) - hostname = parsed.hostname or "" + hostname = (parsed.hostname or "").rstrip(".").casefold() try: safe_port = parsed.port in {None, 443} except ValueError: @@ -265,7 +265,9 @@ def is_public_https_uri(value: Any) -> bool: or not parsed.netloc or not hostname or hostname in {"localhost", "localhost.localdomain"} - or hostname.endswith(".local") + or hostname.endswith( + (".localhost", ".local", ".invalid", ".test", ".example") + ) or parsed.username is not None or parsed.password is not None or not safe_port @@ -277,6 +279,8 @@ def is_public_https_uri(value: Any) -> bool: try: return ipaddress.ip_address(hostname).is_global except ValueError: + if "." not in hostname: + return False try: addresses = { row[4][0] @@ -288,9 +292,12 @@ def is_public_https_uri(value: Any) -> bool: } except OSError: return False - return bool(addresses) and all( - ipaddress.ip_address(address).is_global for address in addresses - ) + try: + return bool(addresses) and all( + ipaddress.ip_address(address).is_global for address in addresses + ) + except ValueError: + return False def canonical_publication_targets(context: RunContext) -> dict[str, Any]: diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance.py new file mode 100644 index 00000000000..63489948cd3 --- /dev/null +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance.py @@ -0,0 +1,2047 @@ +"""Canonical side-effect-free Stable 1.0 maintenance and hotfix certification.""" + +from __future__ import annotations + +import datetime as dt +import json +import os +import shutil +from pathlib import Path +from typing import Any, Iterable +from urllib.parse import quote, urlsplit, urlunsplit + +from cryptad_certification.io import write_json, write_text +from cryptad_certification.models import RunContext +from cryptad_certification.schema_validation import validate_schema +from cryptad_certification.workspace import reset_confined_directory + +from .stable_1_0_ga_core import ( + _has_unambiguous_publication_path, + canonical_artifact_base_uri, + canonical_public_https_uri, + is_public_https_uri, +) +from .stable_1_0_maintenance_artifacts import ( + build_redaction_report, + render_go_no_go, + render_release_notes, +) +from .stable_1_0_maintenance_compatibility import ( + _known_limitations_digest, + build_comparison, + close_hotfix_follow_up, + validate_production_evidence, +) +from .stable_1_0_maintenance_core import ( + AUDIT_CHECKSUMS_FILE, + AUTHORIZATION_FILE, + AUTHORIZATION_SCHEMA, + AUTHORIZATION_SCOPE, + CANDIDATE_FILE, + CANDIDATE_FREEZE_FILE, + CHECKSUMS_FILE, + COMPARISON_FILE, + CORE_INFO_FILE, + CORE_PLAN_FILE, + CORE_PLAN_SCHEMA, + CORE_RECEIPT_FILE, + CORE_RECEIPT_SCHEMA, + FOLLOW_UP_CLOSURE_FILE, + FOLLOW_UP_CLOSURE_SCHEMA, + FOLLOW_UP_FILE, + HISTORY_FILE, + KNOWN_LIMITATIONS_FILE, + LATEST_POINTER_FILE, + LINEAGE_FILE, + LINEAGE_SCHEMA, + PROVENANCE_FILE, + PUBLICATION_PLAN_FILE, + PUBLICATION_PLAN_SCHEMA, + PUBLICATION_RECEIPT_FILE, + PUBLICATION_RECEIPT_SCHEMA, + REDACTION_REPORT_FILE, + RELEASE_NOTES_FILE, + REPORT_FILE, + SCHEMA_VERSION, + STABLE_MILESTONE, + SUCCESSOR_BASELINE_FILE, + SUCCESSOR_SCHEMA, + SUMMARY_FILE, + TOOL_NAME, + TOOL_VERSION, + VALIDATION_FILE, + VALIDATION_SCHEMA, + Candidate, + GaRoot, + Predecessor, + add_blockers, + authenticate_candidate, + authenticate_ga_root, + authenticate_predecessor, + build_core_info, + canonical_policy, + file_digest, + load_json_input, + parse_timestamp, + receipt_identity, + semantic_digest, + successor_baseline_identity, +) +from .stable_1_0_rc_core import ValidationState + +_PASS_REDACTION = {"status": "pass", "findingCount": 0, "findings": []} + + +def _now() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + + +def _timestamp(value: Any) -> str: + parsed = parse_timestamp(value) + if parsed is None: + raise ValueError("Stable maintenance generated timestamp is malformed") + return parsed.replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _https(value: Any) -> bool: + canonical = canonical_public_https_uri(value) + return ( + is_public_https_uri(canonical) + and "replace" not in canonical.lower() + and "example.invalid" not in canonical.lower() + and "\\" not in canonical + ) + + +def _public_child(base: str, name: str) -> str: + parsed = urlsplit(base) + path = parsed.path.rstrip("/") + "/" + quote(name, safe="._-") + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) + + +def _catalog_signature_uri(catalog_uri: str, signature_name: Any) -> str: + """Place the detached catalog signature beside the authenticated catalog URI.""" + + if not isinstance(signature_name, str) or Path(signature_name).name != signature_name: + raise ValueError("stable catalog detached signature filename is unsafe") + parsed = urlsplit(catalog_uri) + parent = parsed.path.rpartition("/")[0] + path = parent + "/" + quote(signature_name, safe="._-") + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) + + +def _copy_exact(source: Path, destination: Path, digest: str) -> None: + if destination.is_symlink() or destination.exists(): + raise ValueError("exact-byte candidate output already exists") + shutil.copyfile(source, destination) + os.chmod(destination, 0o644) + if file_digest(destination) != digest: + destination.unlink(missing_ok=True) + raise ValueError("exact-byte candidate copy changed after freeze") + + +def _write_checksums(path: Path, members: Iterable[Path]) -> None: + rows: dict[str, str] = {} + for member in members: + if member.name in rows: + raise ValueError(f"checksum artifact basename is ambiguous: {member.name}") + rows[member.name] = file_digest(member).removeprefix("sha256:") + write_text(path, "\n".join(f"{rows[name]} {name}" for name in sorted(rows))) + + +def _public_checksum_payload_paths(candidate: Candidate, out: Path) -> dict[str, Path]: + """Return public payloads whose digests do not depend on the checksum manifest.""" + + paths = dict(candidate.asset_paths) + generated = { + RELEASE_NOTES_FILE: out / RELEASE_NOTES_FILE, + KNOWN_LIMITATIONS_FILE: out / KNOWN_LIMITATIONS_FILE, + PROVENANCE_FILE: out / PROVENANCE_FILE, + CORE_INFO_FILE: out / CORE_INFO_FILE, + } + duplicates = sorted(paths.keys() & generated.keys()) + if duplicates: + raise ValueError(f"public payload basename is ambiguous: {duplicates[0]}") + paths.update(generated) + return paths + + +def _canonical_json_input(path: Path, value: dict[str, Any]) -> bool: + expected = ( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + + b"\n" + ) + try: + return path.read_bytes() == expected + except OSError: + return False + + +def _schema_gate( + state: ValidationState, + issue_id: str, + value: dict[str, Any], + schema: str, + remediation: str, +) -> None: + add_blockers(state, issue_id, validate_schema(value, schema), remediation) + + +def _targets(context: RunContext, state: ValidationState) -> tuple[dict[str, Any], str]: + metadata = context.manifest.policies.get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} + artifact_base = context.manifest.policies.get("artifactBaseUri") + mirrors = metadata.get("catalogMirrorUris") + mirror_uris = ( + [ + canonical_public_https_uri(item.strip()) + for item in mirrors.split(",") + if item.strip() + ] + if isinstance(mirrors, str) + else [] + ) + targets = { + "repository": "crypta-network/cryptad", + "tag": f"v{context.manifest.release.version}", + "githubReleasePageUri": canonical_public_https_uri( + metadata.get("githubReleasePageUri") + ), + "deploymentServicePublicUri": canonical_public_https_uri( + metadata.get("deploymentServicePublicUri") + ), + "latestPointerPublicUri": canonical_public_https_uri( + metadata.get("latestPointerPublicUri") + ), + "artifactBaseUri": canonical_artifact_base_uri( + canonical_public_https_uri(artifact_base) + ), + "catalogPrimaryUri": canonical_public_https_uri( + metadata.get("catalogPrimaryUri") + ), + "catalogMirrorUris": sorted(mirror_uris), + "catalogRollbackUri": canonical_public_https_uri( + metadata.get("catalogRollbackUri") + ), + "coreUpdatePublicUri": canonical_public_https_uri( + metadata.get("coreUpdatePublicUri") + ), + "coreUpdateEdition": int(str(context.manifest.release.version)), + } + expected_github_release_page = ( + "https://github.com/crypta-network/cryptad/releases/tag/" + f"{targets['tag']}" + ) + uris = [ + targets["githubReleasePageUri"], + targets["deploymentServicePublicUri"], + targets["latestPointerPublicUri"], + targets["artifactBaseUri"], + targets["catalogPrimaryUri"], + *targets["catalogMirrorUris"], + targets["catalogRollbackUri"], + targets["coreUpdatePublicUri"], + ] + errors: list[str] = [] + if targets["githubReleasePageUri"] != expected_github_release_page: + errors.append( + "GitHub Release page URI must exactly match the fixed repository and build tag" + ) + if not targets["catalogMirrorUris"]: + errors.append("publication targets require at least one catalog mirror URI") + # The exact GitHub URL above is a fixed public target. Avoid a DNS dependency for that + # repository constant while retaining resolved-address validation for configurable targets. + configurable_uris = uris[1:] + if not all( + _https(item) and _has_unambiguous_publication_path(item) + for item in configurable_uris + ): + errors.append("publication targets must be distinct public credential-free HTTPS URIs") + if len(uris) != len(set(uris)): + errors.append("publication targets contain an ambiguous duplicate URI") + add_blockers( + state, + "stable-maintenance.publication-conflict", + errors, + "Provide the exact conflict-free public publication target set.", + ) + return targets, semantic_digest(targets) + + +def _concrete_publication_destination_errors( + targets: dict[str, Any], candidate: Candidate +) -> list[str]: + """Reject aliases among every concrete public object in the current plan schemas.""" + + catalog = candidate.input_value.get("stableCatalog") + catalog = catalog if isinstance(catalog, dict) else {} + product = candidate.input_value.get("product") + product = product if isinstance(product, dict) else {} + artifact_names: list[tuple[str, Any]] = [ + ("artifact:product", product.get("fileName")), + ("artifact:stable-catalog", catalog.get("fileName")), + ("artifact:stable-catalog-signature", catalog.get("signatureFileName")), + ("artifact:release-notes", RELEASE_NOTES_FILE), + ("artifact:known-limitations", KNOWN_LIMITATIONS_FILE), + ("artifact:checksums", CHECKSUMS_FILE), + ("artifact:provenance", PROVENANCE_FILE), + ("artifact:authorization", AUTHORIZATION_FILE), + ("artifact:core-info", CORE_INFO_FILE), + ] + artifact_names.extend( + (f"artifact:package:{row.get('packageKey')}", row.get("fileName")) + for row in candidate.assets + if isinstance(row, dict) + ) + destinations: list[tuple[str, Any]] = [] + for role, name in artifact_names: + if not isinstance(name, str) or Path(name).name != name: + destinations.append((role, "")) + else: + destinations.append( + (role, _public_child(str(targets.get("artifactBaseUri", "")), name)) + ) + destinations.extend( + [ + ("github-release", targets.get("githubReleasePageUri")), + ("deployment-service", targets.get("deploymentServicePublicUri")), + ("latest-maintenance-pointer", targets.get("latestPointerPublicUri")), + ("stable-catalog-primary", targets.get("catalogPrimaryUri")), + ( + "stable-catalog-signature", + _catalog_signature_uri( + str(targets.get("catalogPrimaryUri", "")), + catalog.get("signatureFileName"), + ), + ), + ("stable-catalog-rollback", targets.get("catalogRollbackUri")), + ("core-update-descriptor", targets.get("coreUpdatePublicUri")), + ] + ) + destinations.extend( + (f"stable-catalog-mirror:{index}", uri) + for index, uri in enumerate(targets.get("catalogMirrorUris", [])) + ) + + errors: list[str] = [] + roles_by_uri: dict[str, list[str]] = {} + fixed_github_release_uri = ( + "https://github.com/crypta-network/cryptad/releases/tag/" + f"{targets.get('tag')}" + ) + for role, raw_uri in destinations: + canonical_uri = canonical_public_https_uri(raw_uri) + is_public_destination = ( + canonical_uri == fixed_github_release_uri + if role == "github-release" + else _https(canonical_uri) + ) + if ( + canonical_uri != raw_uri + or not is_public_destination + or not _has_unambiguous_publication_path(canonical_uri) + ): + errors.append( + f"concrete publication destination for {role} is not canonical public HTTPS" + ) + continue + roles_by_uri.setdefault(canonical_uri, []).append(role) + collisions = [ + sorted(roles) + for roles in roles_by_uri.values() + if len(roles) > 1 + ] + for roles in sorted(collisions): + errors.append( + "concrete publication destinations collide across roles: " + + ", ".join(roles) + ) + return errors + + +def _lineage( + context: RunContext, + ga: GaRoot, + predecessor: Predecessor, + candidate: Candidate, + state: ValidationState, +) -> dict[str, Any]: + ga_predecessor = predecessor.baseline.get("schemaVersion") == 1 + latest_pointer_digest = ( + ga.root_identity_digest + if ga_predecessor + else predecessor.latest_pointer_digest + ) + release_class = ( + "stable-ga" + if ga_predecessor + else predecessor.baseline.get("release", {}).get("releaseClass") + ) + value = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-lineage", + "generatedAt": candidate.input_value.get("generatedAt"), + "stableMilestone": STABLE_MILESTONE, + "gaRoot": { + "releaseId": ga.release_id, + "buildVersion": ga.build_version, + "tag": ga.tag, + "sourceCommit": ga.source_commit, + "productDigest": ga.product_digest, + "maintenanceBaselineDigest": ga.baseline_digest, + "publicationReceiptDigest": ga.receipt_digest, + "publicationState": "publication-complete", + }, + "predecessor": { + "releaseId": predecessor.release_id, + "buildVersion": predecessor.build_version, + "tag": predecessor.tag, + "sourceCommit": predecessor.source_commit, + "productDigest": predecessor.product_digest, + "releaseClass": release_class, + "publicationReceiptDigest": predecessor.receipt_digest, + "successorBaselineDigest": ( + None + if predecessor.baseline.get("schemaVersion") == 1 + else predecessor.baseline_digest + ), + "hotfixFollowUpClosureDigest": predecessor.follow_up_closure_digest, + "publicationState": "publication-complete", + }, + "candidate": { + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "tag": f"v{context.manifest.release.version}", + "sourceBranch": candidate.source.get("branch"), + "sourceRef": candidate.source.get("ref"), + "sourceCommit": candidate.source.get("commit"), + "releaseClass": context.manifest.policies.get("releaseClass"), + }, + "chainDepth": predecessor.chain_depth + 1, + "previousLineageDigest": predecessor.previous_lineage_digest, + "latestPublishedPointerDigest": latest_pointer_digest, + "noGap": True, + "noFork": ga_predecessor or predecessor.latest_pointer_digest is not None, + "status": "pass", + "redaction": dict(_PASS_REDACTION), + } + _schema_gate( + state, + "stable-maintenance.predecessor-lineage", + value, + LINEAGE_SCHEMA, + "Regenerate lineage from the authenticated GA root and latest predecessor.", + ) + return value + + +def _known_limitations(context: RunContext, candidate: Candidate) -> dict[str, Any]: + value = candidate.input_value.get("limitations") + value = value if isinstance(value, dict) else {} + return { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-known-limitations-delta", + "generatedAt": candidate.input_value.get("generatedAt"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "knownLimitationsDigest": value.get("knownLimitationsDigest"), + "deltaDigest": value.get("deltaDigest"), + "added": sorted(value.get("addedIds", [])), + "resolved": sorted(value.get("resolvedIds", [])), + "unchanged": sorted(value.get("unchangedIds", [])), + "status": "reviewed", + "redaction": dict(_PASS_REDACTION), + } + + +def _provenance( + context: RunContext, + policy_digest: str, + ga: GaRoot, + predecessor: Predecessor, + candidate: Candidate, + lineage_digest: str, + comparison_digest: str, + evidence_digest: str, + core_info_digest: str, +) -> dict[str, Any]: + return { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-provenance", + "generatedAt": candidate.input_value.get("generatedAt"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "source": candidate.source, + "policyDigest": policy_digest, + "gaRootIdentityDigest": ga.root_identity_digest, + "gaBaselineDigest": ga.baseline_digest, + "gaPublicationReceiptDigest": ga.receipt_digest, + "predecessorBaselineDigest": predecessor.baseline_digest, + "predecessorPublicationReceiptDigest": predecessor.receipt_digest, + "predecessorProductDigest": predecessor.product_digest, + "candidateInputDigest": candidate.input_digest, + "candidateFreezeDigest": candidate.freeze_digest, + "candidateIdentityDigest": candidate.identity_digest, + "candidateProductDigest": candidate.product_digest, + "candidateChecksumsDigest": candidate.checksums_digest, + "candidateProvenanceDigest": candidate.provenance_digest, + "lineageDigest": lineage_digest, + "comparisonDigest": comparison_digest, + "evidenceDigest": evidence_digest, + "coreInfoDigest": core_info_digest, + "assets": [ + { + "name": name, + "sizeBytes": path.stat().st_size, + "digest": file_digest(path), + } + for name, path in sorted(candidate.asset_paths.items()) + ], + "builtOnce": True, + "rebuildPerformedAfterFreeze": False, + "redaction": dict(_PASS_REDACTION), + } + + +def _authorization_expected( + context: RunContext, + ga: GaRoot, + predecessor: Predecessor, + candidate: Candidate, + comparison_digest: str, + evidence_digest: str, + core_info_digest: str, + checksums_digest: str, + provenance_digest: str, + release_notes_digest: str, + targets_digest: str, + follow_up_digest: str | None, +) -> dict[str, Any]: + scope = candidate.input_value.get("changeScope") + scope = scope if isinstance(scope, dict) else {} + return { + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "candidateIdentityDigest": candidate.identity_digest, + "gaBaselineDigest": ga.baseline_digest, + "predecessorIdentityDigest": semantic_digest( + { + "releaseId": predecessor.release_id, + "buildVersion": predecessor.build_version, + "sourceCommit": predecessor.source_commit, + "productDigest": predecessor.product_digest, + "baselineDigest": predecessor.baseline_digest, + } + ), + "predecessorProductDigest": predecessor.product_digest, + "predecessorPublicationReceiptDigest": predecessor.receipt_digest, + "candidateFreezeDigest": candidate.freeze_digest, + "productDigest": candidate.product_digest, + "checksumsDigest": checksums_digest, + "provenanceDigest": provenance_digest, + "comparisonDigest": comparison_digest, + "evidenceDigest": evidence_digest, + "coreInfoDigest": core_info_digest, + "stableCatalogDigest": candidate.input_value.get("stableCatalog", {}).get("digest"), + "knownLimitationsDeltaDigest": candidate.input_value.get("limitations", {}).get("deltaDigest"), + "releaseNotesDigest": release_notes_digest, + "publicationTargetsDigest": targets_digest, + "allowedPublicationScopes": list(AUTHORIZATION_SCOPE), + "acceptedWarningIds": sorted( + row.get("warningId") + for row in candidate.input_value.get("operationalWarnings", []) + if isinstance(row, dict) + ), + "role": ( + "stable-security-release-manager" + if context.manifest.policies.get("releaseClass") == "security-hotfix" + else "stable-maintenance-release-manager" + ), + "hotfixIncidentId": scope.get("incidentId"), + "hotfixPolicyAuthorizationDigest": scope.get( + "hotfixPolicyAuthorizationDigest" + ), + "hotfixShortenedEvidenceIds": sorted( + scope.get("shortenedEvidenceIds", []) + ), + "hotfixFollowUpObligationDigest": follow_up_digest, + } + + +def _required_authorization_decision(expected: dict[str, Any]) -> str: + return "go-with-waivers" if expected.get("acceptedWarningIds") else "go" + + +def _authorization( + context: RunContext, + expected: dict[str, Any], + policy: dict[str, Any], + state: ValidationState, + prepare: bool, +) -> tuple[dict[str, Any], str, bool]: + request_identity = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-authorization-request", + **expected, + "decisionRequired": _required_authorization_decision(expected), + "redaction": dict(_PASS_REDACTION), + } + if prepare: + summary = { + **request_identity, + "status": "missing", + "authorizationRequestDigest": semantic_digest(request_identity), + } + return summary, semantic_digest(summary), False + loaded = load_json_input( + context, "stableMaintenanceAuthorization", public_authorization=True + ) + assert loaded + value = loaded.value + errors = validate_schema(value, AUTHORIZATION_SCHEMA) + if state.blockers: + errors.append("authorization cannot approve a candidate with an open blocker") + for key, expected_value in expected.items(): + if value.get(key) != expected_value: + errors.append(f"authorization field {key} does not bind the prepared candidate") + scopes = value.get("allowedPublicationScopes") + if scopes != list(AUTHORIZATION_SCOPE) or "*" in (scopes or []): + errors.append("authorization scopes are incomplete, reordered, or wildcarded") + now = _now() + authorized = parse_timestamp(value.get("authorizedAt")) + expires = parse_timestamp(value.get("expiresAt")) + if authorized is None or expires is None or authorized > now or expires <= now: + errors.append("authorization time window is not currently valid") + allowed_decisions = policy.get("authorization", {}).get("allowedDecisions", []) + expected_decision = _required_authorization_decision(expected) + if ( + value.get("decision") not in allowed_decisions + or value.get("decision") != expected_decision + ): + errors.append("authorization decision does not allow publication") + if not _canonical_json_input(loaded.path, value): + errors.append("authorization JSON bytes are not canonical deterministic JSON") + add_blockers( + state, + "stable-maintenance.authorization", + errors, + "Obtain a current closed-scope authorization for the exact prepared identity.", + ) + return value, loaded.digest, not errors + + +def _close_authorization_errors( + authorization: Any, + obligation: Any, + published_follow_up: dict[str, Any], +) -> list[str]: + """Authenticate the exact authorization for the originally obligated hotfix.""" + + errors = validate_schema(authorization.value, AUTHORIZATION_SCHEMA) + if ( + authorization.value.get("releaseId") + != obligation.value.get("releaseId") + or authorization.value.get("buildVersion") + != obligation.value.get("buildVersion") + or authorization.value.get("releaseClass") != "security-hotfix" + or authorization.value.get("candidateIdentityDigest") + != obligation.value.get("candidateIdentityDigest") + or authorization.value.get("candidateFreezeDigest") + != obligation.value.get("candidateFreezeDigest") + or authorization.value.get("productDigest") + != obligation.value.get("productDigest") + or authorization.value.get("role") != "stable-security-release-manager" + or authorization.value.get("status") != "approved" + or authorization.value.get("decision") not in {"go", "go-with-waivers"} + or authorization.digest != published_follow_up.get("authorizationDigest") + ): + errors.append( + "hotfix follow-up authorization does not bind the original published authorization" + ) + return errors + + +def _concurrent_follow_up_errors( + predecessor: Predecessor, follow_up: dict[str, Any] | None +) -> list[str]: + """Reject a second open obligation that the v2 baseline cannot represent safely.""" + + outstanding = predecessor.outstanding_follow_up + if ( + isinstance(outstanding, dict) + and outstanding.get("status") in {"open", "overdue"} + and follow_up is not None + ): + return [ + "a superseding expedited hotfix cannot create a second concurrent follow-up obligation" + ] + return [] + + +def _core_plan( + context: RunContext, + candidate: Candidate, + descriptor: dict[str, Any], + core_info_path: Path, + targets: dict[str, Any], + targets_digest: str, + authorization_digest: str, + state: ValidationState, +) -> dict[str, Any]: + value = { + "schemaVersion": 1, + "kind": "cryptad-core-update-publication-plan", + "generatedAt": candidate.input_value.get("generatedAt"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "candidateIdentityDigest": candidate.identity_digest, + "descriptorDigest": file_digest(core_info_path), + "descriptorSizeBytes": core_info_path.stat().st_size, + "packageMapDigest": semantic_digest(descriptor.get("packages")), + "edition": targets["coreUpdateEdition"], + "publicFetchUri": targets["coreUpdatePublicUri"], + "protectedInsertInputName": "CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT", + "authorizationDigest": authorization_digest, + "publicationTargetDigest": targets_digest, + "preInsertionConflictStatus": "clear", + "sideEffectsPerformed": False, + "redaction": dict(_PASS_REDACTION), + } + _schema_gate( + state, + "stable-maintenance.core-update-descriptor", + value, + CORE_PLAN_SCHEMA, + "Regenerate the protected CoreUpdater insertion plan.", + ) + return value + + +def _public_assets( + candidate: Candidate, + out: Path, + artifact_base: str, + authorization_path: Path, +) -> list[dict[str, Any]]: + roles: dict[str, str] = { + candidate.product_path.name: "product", + candidate.input_value.get("stableCatalog", {}).get("fileName"): "stable-catalog", + candidate.input_value.get("stableCatalog", {}).get("signatureFileName"): "stable-catalog-signature", + RELEASE_NOTES_FILE: "release-notes", + KNOWN_LIMITATIONS_FILE: "known-limitations", + CHECKSUMS_FILE: "checksums", + PROVENANCE_FILE: "provenance", + AUTHORIZATION_FILE: "authorization", + CORE_INFO_FILE: "core-info", + } + for package in candidate.assets: + roles[str(package.get("fileName"))] = "package" + paths = _public_checksum_payload_paths(candidate, out) + manifest_paths = { + CHECKSUMS_FILE: out / CHECKSUMS_FILE, + AUTHORIZATION_FILE: authorization_path, + } + duplicates = sorted(paths.keys() & manifest_paths.keys()) + if duplicates: + raise ValueError(f"public asset basename is ambiguous: {duplicates[0]}") + paths.update(manifest_paths) + return [ + { + "role": roles[name], + "fileName": name, + "digest": file_digest(path), + "sizeBytes": path.stat().st_size, + "publicUri": _public_child(artifact_base, name), + } + for name, path in sorted(paths.items()) + ] + + +def _publication_plan( + context: RunContext, + candidate: Candidate, + out: Path, + targets: dict[str, Any], + targets_digest: str, + authorization_path: Path, + authorization_digest: str, + authorized: bool, + state: ValidationState, +) -> dict[str, Any]: + catalog = candidate.input_value.get("stableCatalog", {}) + value = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-publication-plan", + "generatedAt": candidate.input_value.get("generatedAt"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "sourceBranch": candidate.source.get("branch"), + "sourceCommit": candidate.source.get("commit"), + "expectedTag": f"v{context.manifest.release.version}", + "githubReleasePageUri": targets["githubReleasePageUri"], + "artifactBaseUri": targets["artifactBaseUri"], + "deploymentServicePublicUri": targets["deploymentServicePublicUri"], + "latestPointerPublicUri": targets["latestPointerPublicUri"], + "candidateIdentityDigest": candidate.identity_digest, + "productDigest": candidate.product_digest, + "checksumsDigest": file_digest(out / CHECKSUMS_FILE), + "provenanceDigest": file_digest(out / PROVENANCE_FILE), + "authorizationDigest": authorization_digest, + "releaseNotesDigest": file_digest(out / RELEASE_NOTES_FILE), + "coreInfoDigest": file_digest(out / CORE_INFO_FILE), + "stableCatalogDigest": catalog.get("digest"), + "knownLimitationsDeltaDigest": candidate.input_value.get("limitations", {}).get("deltaDigest"), + "publicationTargetsDigest": targets_digest, + "assets": _public_assets( + candidate, out, targets["artifactBaseUri"], authorization_path + ), + "stableCatalogTarget": { + "catalogId": catalog.get("catalogId"), + "channel": catalog.get("channel"), + "revision": catalog.get("revision"), + "edition": catalog.get("edition"), + "digest": catalog.get("digest"), + "signatureDigest": catalog.get("signatureDigest"), + "publicUri": targets["catalogPrimaryUri"], + "signaturePublicUri": _catalog_signature_uri( + targets["catalogPrimaryUri"], catalog.get("signatureFileName") + ), + "mirrorUris": targets["catalogMirrorUris"], + "rollbackUri": targets["catalogRollbackUri"], + "mirrorSetDigest": semantic_digest(targets["catalogMirrorUris"]), + "rollbackStateDigest": semantic_digest(targets["catalogRollbackUri"]), + }, + "coreUpdateTarget": { + "edition": targets["coreUpdateEdition"], + "descriptorDigest": file_digest(out / CORE_INFO_FILE), + "publicUri": targets["coreUpdatePublicUri"], + "protectedInsertInputName": "CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT", + }, + "sideEffectsPerformed": False, + "publicationState": "publication-authorized" if authorized and not state.blockers else "validated", + "redaction": dict(_PASS_REDACTION), + } + _schema_gate( + state, + "stable-maintenance.publication-conflict", + value, + PUBLICATION_PLAN_SCHEMA, + "Regenerate the exact-byte publication plan from authenticated outputs.", + ) + return value + + +def _core_receipt_errors( + context: RunContext, + loaded: Any, + candidate: Candidate, + core_plan_path: Path, + core_plan: dict[str, Any], + core_info_path: Path, + descriptor: dict[str, Any], +) -> list[str]: + value = loaded.value + errors = validate_schema(value, CORE_RECEIPT_SCHEMA) + expected_packages = { + row.get("packageKey"): ( + row.get("digest"), + row.get("sizeBytes"), + row.get("publicChk") or row.get("storeUrl"), + ) + for row in candidate.assets + } + observed = { + row.get("packageKey"): ( + row.get("candidateAssetDigest"), + row.get("candidateAssetSizeBytes"), + row.get("publicReference"), + ) + for row in value.get("referencedPackages", []) + if isinstance(row, dict) + } + package_rows = value.get("referencedPackages", []) + package_rows = package_rows if isinstance(package_rows, list) else [] + if ( + len(expected_packages) != len(candidate.assets) + or len(observed) != len(package_rows) + or len(package_rows) != len(candidate.assets) + or any( + not isinstance(row, dict) or row.get("verificationStatus") != "pass" + for row in package_rows + ) + or + value.get("releaseId") != context.manifest.release.release_id + or value.get("buildVersion") != context.manifest.release.version + or value.get("releaseClass") != context.manifest.policies.get("releaseClass") + or value.get("candidateIdentityDigest") != candidate.identity_digest + or value.get("publicationPlanDigest") != file_digest(core_plan_path) + or value.get("descriptorDigest") != file_digest(core_info_path) + or value.get("descriptorDigest") != core_plan.get("descriptorDigest") + or value.get("fetchedDescriptorDigest") != file_digest(core_info_path) + or value.get("descriptorSizeBytes") != core_info_path.stat().st_size + or value.get("descriptorSizeBytes") != core_plan.get("descriptorSizeBytes") + or value.get("packageMapDigest") != semantic_digest(descriptor.get("packages")) + or value.get("packageMapDigest") != core_plan.get("packageMapDigest") + or value.get("edition") != core_plan.get("edition") + or value.get("publicFetchUri") != core_plan.get("publicFetchUri") + or value.get("operation") not in {"created", "verified-existing"} + or value.get("conflictStatus") != "clear" + or value.get("verificationStatus") != "pass" + or value.get("publicationState") != "publication-complete" + or observed != expected_packages + ): + errors.append( + "CoreUpdater receipt does not prove the exact authorized descriptor, edition, " + "public target, and package map" + ) + if not _canonical_json_input(loaded.path, value): + errors.append("CoreUpdater receipt JSON is not canonical deterministic JSON") + return errors + + +def _receipt_errors( + context: RunContext, + loaded: Any, + candidate: Candidate, + plan_path: Path, + plan: dict[str, Any], + core_receipt: dict[str, Any], + core_receipt_digest: str, + successor_digest: str, + history_digest: str, +) -> list[str]: + value = loaded.value + errors = validate_schema(value, PUBLICATION_RECEIPT_SCHEMA) + metadata = context.manifest.policies.get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} + expected_release_page_uri = canonical_public_https_uri( + metadata.get("githubReleasePageUri") + ) + planned_assets = { + row.get("fileName"): ( + row.get("role"), row.get("digest"), row.get("sizeBytes"), row.get("publicUri") + ) + for row in plan.get("assets", []) + if isinstance(row, dict) + } + observed_assets = { + row.get("fileName"): ( + row.get("role"), row.get("digest"), row.get("sizeBytes"), row.get("publicUri") + ) + for row in value.get("assets", []) + if isinstance(row, dict) + } + planned_rows = plan.get("assets", []) + planned_rows = planned_rows if isinstance(planned_rows, list) else [] + receipt_rows = value.get("assets", []) + receipt_rows = receipt_rows if isinstance(receipt_rows, list) else [] + observations = value.get("publicObservations") + observations = observations if isinstance(observations, dict) else {} + catalog_target = plan.get("stableCatalogTarget") + catalog_target = catalog_target if isinstance(catalog_target, dict) else {} + catalog_identity = candidate.input_value.get("stableCatalog") + catalog_identity = catalog_identity if isinstance(catalog_identity, dict) else {} + catalog_receipt = value.get("stableCatalog") + catalog_receipt = catalog_receipt if isinstance(catalog_receipt, dict) else {} + core_target = plan.get("coreUpdateTarget") + core_target = core_target if isinstance(core_target, dict) else {} + core_summary = value.get("coreUpdate") + core_summary = core_summary if isinstance(core_summary, dict) else {} + catalog_target_fields = ( + "catalogId", + "revision", + "edition", + "digest", + "signatureDigest", + "publicUri", + "signaturePublicUri", + "mirrorSetDigest", + "rollbackStateDigest", + ) + catalog_identity_fields = ( + "catalogId", + "revision", + "edition", + "digest", + "signatureDigest", + ) + core_target_fields = ("edition", "descriptorDigest", "publicUri") + if ( + len(planned_assets) != len(planned_rows) + or len(observed_assets) != len(receipt_rows) + or len(receipt_rows) != len(planned_rows) + or any( + not isinstance(row, dict) + or row.get("operation") not in {"created", "verified-existing"} + or row.get("verificationStatus") != "verified" + for row in receipt_rows + ) + or + value.get("releaseId") != context.manifest.release.release_id + or value.get("buildVersion") != context.manifest.release.version + or value.get("releaseClass") != context.manifest.policies.get("releaseClass") + or value.get("sourceCommit") != candidate.source.get("commit") + or value.get("githubReleasePageUri") != plan.get("githubReleasePageUri") + or value.get("deploymentServicePublicUri") + != plan.get("deploymentServicePublicUri") + or value.get("latestPointerPublicUri") != plan.get("latestPointerPublicUri") + or value.get("candidateIdentityDigest") != candidate.identity_digest + or value.get("productDigest") != candidate.product_digest + or value.get("checksumsDigest") != plan.get("checksumsDigest") + or value.get("provenanceDigest") != plan.get("provenanceDigest") + or value.get("authorizationDigest") != plan.get("authorizationDigest") + or value.get("coreUpdateReceiptDigest") != core_receipt_digest + or value.get("publicationPlanDigest") != file_digest(plan_path) + or value.get("releaseNotesDigest") != plan.get("releaseNotesDigest") + or value.get("coreInfoDigest") != plan.get("coreInfoDigest") + or value.get("successorBaselineDigest") != successor_digest + or value.get("releaseHistoryDigest") != history_digest + or planned_assets != observed_assets + or value.get("tag", {}).get("name") != f"v{context.manifest.release.version}" + or value.get("tag", {}).get("objectType") != "annotated" + or value.get("tag", {}).get("targetCommit") != candidate.source.get("commit") + or value.get("tag", {}).get("operation") not in {"created", "verified-existing"} + or value.get("tag", {}).get("verificationStatus") != "verified" + or value.get("githubRelease", {}).get("operation") + not in {"created", "verified-existing"} + or value.get("githubRelease", {}).get("verificationStatus") != "verified" + or value.get("githubRelease", {}).get("releaseId") + != context.manifest.release.release_id + or value.get("githubRelease", {}).get("tag") + != f"v{context.manifest.release.version}" + or value.get("githubRelease", {}).get("pageUri") + != expected_release_page_uri + or value.get("githubRelease", {}).get("notesDigest") != plan.get("releaseNotesDigest") + or any(catalog_receipt.get(key) != catalog_target.get(key) for key in catalog_target_fields) + or any( + catalog_target.get(key) != catalog_identity.get(key) + for key in catalog_identity_fields + ) + or catalog_target.get("digest") != plan.get("stableCatalogDigest") + or catalog_receipt.get("signatureDigest") != catalog_identity.get("signatureDigest") + or catalog_receipt.get("operation") + not in {"created", "verified-existing"} + or catalog_receipt.get("verificationStatus") != "verified" + or any(core_summary.get(key) != core_target.get(key) for key in core_target_fields) + or core_summary.get("descriptorDigest") != core_receipt.get("descriptorDigest") + or core_summary.get("packageMapDigest") != core_receipt.get("packageMapDigest") + or core_receipt.get("edition") != core_target.get("edition") + or core_receipt.get("publicFetchUri") != core_target.get("publicUri") + or core_summary.get("operation") + not in {"created", "verified-existing"} + or core_summary.get("verificationStatus") != "verified" + or set(observations.values()) != {"verified"} + or value.get("publicationState") != "publication-complete" + or value.get("finalVerificationStatus") != "pass" + or value.get("failureCategory") is not None + ): + errors.append("publication receipt does not prove exact complete idempotent public state") + if not _canonical_json_input(loaded.path, value): + errors.append("publication receipt JSON is not canonical deterministic JSON") + return errors + + +def _history_entry( + context: RunContext, + candidate: Candidate, + ga: GaRoot, + predecessor: Predecessor, + receipt_identity_digest: str, + core_info_digest: str, + evidence_digest: str, +) -> dict[str, Any]: + return { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-history-entry", + "generatedAt": candidate.input_value.get("generatedAt"), + "stableMilestone": STABLE_MILESTONE, + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "tag": f"v{context.manifest.release.version}", + "sourceCommit": candidate.source.get("commit"), + "releaseClass": context.manifest.policies.get("releaseClass"), + "chainDepth": predecessor.chain_depth + 1, + "gaBaselineDigest": ga.baseline_digest, + "previousBaselineDigest": predecessor.baseline_digest, + "previousLineageDigest": predecessor.previous_lineage_digest, + "candidateIdentityDigest": candidate.identity_digest, + "productDigest": candidate.product_digest, + "publicationReceiptIdentityDigest": receipt_identity_digest, + "coreInfoDigest": core_info_digest, + "evidenceDigest": evidence_digest, + "status": "published-and-verified", + "redaction": dict(_PASS_REDACTION), + } + + +def _successor( + context: RunContext, + ga: GaRoot, + predecessor: Predecessor, + candidate: Candidate, + policy_digest: str, + evidence: dict[str, Any], + evidence_digest: str, + receipt: dict[str, Any], + history_digest: str, + follow_up: dict[str, Any] | None, + follow_up_digest: str | None, +) -> dict[str, Any]: + receipt_id = receipt_identity(receipt) + limitations = candidate.input_value.get("limitations", {}) + current_limitation_ids = sorted( + { + item + for field in ("addedIds", "unchangedIds") + for item in ( + limitations.get(field) if isinstance(limitations.get(field), list) else [] + ) + if isinstance(item, str) + } + ) + security = candidate.input_value.get("security", {}) + support = candidate.input_value.get("support", {}) + inherited_follow_up = predecessor.outstanding_follow_up + inherited_follow_up = ( + inherited_follow_up + if isinstance(inherited_follow_up, dict) + and inherited_follow_up.get("status") in {"open", "overdue"} + else None + ) + effective_follow_up = inherited_follow_up or follow_up + effective_follow_up_digest = ( + inherited_follow_up.get("obligationDigest") + if inherited_follow_up is not None + else follow_up_digest + ) + hotfix_follow_up = { + "status": ( + effective_follow_up.get("status", "open") + if effective_follow_up + else "not-required" + ), + "generatedAt": ( + effective_follow_up.get("generatedAt") + if effective_follow_up + else candidate.input_value.get("generatedAt") + ), + "obligationDigest": effective_follow_up_digest, + "deadline": ( + effective_follow_up.get("deadline") if effective_follow_up else None + ), + "closureEvidenceDigest": ( + effective_follow_up.get("closureEvidenceDigest") + if effective_follow_up + else None + ), + "blocksRoutineMaintenance": bool(effective_follow_up), + } + if effective_follow_up: + hotfix_follow_up.update( + { + "obligatedReleaseId": effective_follow_up.get( + "obligatedReleaseId", effective_follow_up.get("releaseId") + ), + "obligatedBuildVersion": effective_follow_up.get( + "obligatedBuildVersion", effective_follow_up.get("buildVersion") + ), + "obligatedProductDigest": effective_follow_up.get( + "obligatedProductDigest", effective_follow_up.get("productDigest") + ), + "obligatedCandidateIdentityDigest": effective_follow_up.get( + "obligatedCandidateIdentityDigest", + effective_follow_up.get("candidateIdentityDigest"), + ), + "obligatedCandidateFreezeDigest": effective_follow_up.get( + "obligatedCandidateFreezeDigest", + effective_follow_up.get("candidateFreezeDigest"), + ), + "obligatedCandidateFrozenAt": effective_follow_up.get( + "obligatedCandidateFrozenAt", + effective_follow_up.get("candidateFrozenAt"), + ), + "obligatedPredecessorBuild": effective_follow_up.get( + "obligatedPredecessorBuild", + effective_follow_up.get("predecessorBuild"), + ), + "obligatedPredecessorProductDigest": effective_follow_up.get( + "obligatedPredecessorProductDigest", + effective_follow_up.get("predecessorProductDigest"), + ), + "authorizationDigest": ( + effective_follow_up.get("authorizationDigest") + if inherited_follow_up is not None + else receipt.get("authorizationDigest") + ), + } + ) + baseline = { + "schemaVersion": 2, + "kind": "stable-1.0-maintenance-successor-baseline", + "generatedAt": candidate.input_value.get("generatedAt"), + "stableMilestone": STABLE_MILESTONE, + "status": "published", + "gaRoot": { + "releaseId": ga.release_id, + "buildVersion": ga.build_version, + "tag": ga.tag, + "sourceCommit": ga.source_commit, + "productDigest": ga.product_digest, + "maintenanceBaselineDigest": ga.baseline_digest, + "publicationReceiptDigest": ga.receipt_digest, + }, + "previousBaselineDigest": predecessor.baseline_digest, + "chainDepth": predecessor.chain_depth + 1, + "previousLineageDigest": predecessor.previous_lineage_digest, + "publication": { + "receiptIdentityDigest": receipt_id, + "publicationState": "publication-complete", + "verificationStatus": "pass", + }, + "release": { + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "tag": f"v{context.manifest.release.version}", + "sourceCommit": candidate.source.get("commit"), + "releaseClass": context.manifest.policies.get("releaseClass"), + "productDigest": candidate.product_digest, + "publicationReceiptIdentityDigest": receipt_id, + "checksumsDigest": receipt.get("checksumsDigest"), + "provenanceDigest": receipt.get("provenanceDigest"), + "coreInfoDigest": receipt.get("coreInfoDigest"), + }, + "platformApi": { + key: candidate.input_value.get("platformApi", {}).get(key) + for key in ( + "baselineName", + "baselineDigest", + "baselineContractVersion", + "currentContractVersion", + "currentContractDigest", + "stableSurfaceDigest", + "compatibilityWindowPolicyDigest", + "deprecationHistoryDigest", + "deprecationHistory", + ) + }, + "stableCatalog": { + key: candidate.input_value.get("stableCatalog", {}).get(key) + for key in ( + "catalogId", + "channel", + "revision", + "edition", + "digest", + "signatureFileName", + "signatureSizeBytes", + "signatureDigest", + "signingKeyId", + ) + }, + "firstPartyApps": [ + { + key: row.get(key) + for key in ( + "appId", + "version", + "channel", + "supportLevel", + "bundleDigest", + "reviewReceiptDigest", + "appSigningKeyId", + "reviewerKeyId", + "manifestDigest", + "permissionSetDigest", + "appDataSchemaVersion", + "supportMetadataDigest", + ) + } + for row in candidate.input_value.get("firstPartyApps", []) + ], + "contentFormatProfiles": [ + { + key: row.get(key) + for key in ( + "profileId", + "version", + "status", + "descriptorDigest", + "canonicalizationRulesDigest", + "maximumSizePolicyDigest", + "signaturePayloadRulesDigest", + ) + } + for row in candidate.input_value.get("contentFormatProfiles", []) + ], + "limitations": { + "currentDigest": _known_limitations_digest(set(current_limitation_ids)), + "currentIds": current_limitation_ids, + "gaBaselineDigest": semantic_digest(ga.baseline.get("limitations")), + "predecessorDigest": semantic_digest(predecessor.baseline.get("limitations")), + }, + "security": { + "currentDigest": semantic_digest(security), + "gaBaselineDigest": semantic_digest(ga.baseline.get("securityBaseline")), + "predecessorDigest": semantic_digest( + predecessor.baseline.get("security", predecessor.baseline.get("securityBaseline")) + ), + }, + "support": { + "currentDigest": semantic_digest(support), + "gaBaselineDigest": semantic_digest(ga.baseline.get("supportBaseline")), + "predecessorDigest": semantic_digest( + predecessor.baseline.get("support", predecessor.baseline.get("supportBaseline")) + ), + }, + "legacyBoundaries": candidate.input_value.get("legacyBoundaries"), + "evidenceWindowPolicy": { + "windowClass": evidence.get("windowClass"), + "policyDigest": policy_digest, + "requiredEvidenceDigest": semantic_digest( + sorted(row.get("evidenceId") for row in evidence.get("evidenceRows", [])) + ), + "completedEvidenceDigest": evidence_digest, + }, + "hotfixFollowUp": hotfix_follow_up, + "releaseHistoryDigest": history_digest, + "redaction": dict(_PASS_REDACTION), + } + identity = successor_baseline_identity(baseline) + history = list(predecessor.lineage_history) + history.append( + { + "chainDepth": predecessor.chain_depth + 1, + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "tag": f"v{context.manifest.release.version}", + "sourceCommit": candidate.source.get("commit"), + "releaseClass": context.manifest.policies.get("releaseClass"), + "productDigest": candidate.product_digest, + "baselineIdentityDigest": identity, + "publicationReceiptIdentityDigest": receipt_id, + "previousLineageDigest": predecessor.previous_lineage_digest, + } + ) + baseline["lineage"] = { + "gaBaselineDigest": ga.baseline_digest, + "gaPublicationReceiptDigest": ga.receipt_digest, + "chainDepth": predecessor.chain_depth + 1, + "lineageDigest": semantic_digest(history), + "history": history, + } + return baseline + + +def _verify_receipts( + context: RunContext, + out: Path, + ga: GaRoot, + predecessor: Predecessor, + candidate: Candidate, + policy_digest: str, + evidence: dict[str, Any], + evidence_digest: str, + descriptor: dict[str, Any], + core_plan: dict[str, Any], + plan: dict[str, Any], + follow_up: dict[str, Any] | None, + follow_up_digest: str | None, + state: ValidationState, +) -> tuple[str, dict[str, Any] | None]: + receipt_loaded = load_json_input( + context, "stableMaintenancePublicationReceipt", required=False + ) + core_loaded = load_json_input(context, "coreUpdatePublicationReceipt", required=False) + if receipt_loaded is None and core_loaded is None: + return "publication-authorized", None + if receipt_loaded is None or core_loaded is None or state.blockers: + add_blockers( + state, + "stable-maintenance.publication-verification", + ["publication receipts are incomplete or prepublication validation failed"], + "Recover protected publication state without changing authorized bytes.", + ) + return "publication-verification-failed", None + core_errors = _core_receipt_errors( + context, + core_loaded, + candidate, + out / CORE_PLAN_FILE, + core_plan, + out / CORE_INFO_FILE, + descriptor, + ) + add_blockers( + state, + "stable-maintenance.core-update-descriptor", + core_errors, + "Verify the exact fetched descriptor and every candidate package reference.", + ) + receipt_id = receipt_identity(receipt_loaded.value) + history = _history_entry( + context, + candidate, + ga, + predecessor, + receipt_id, + file_digest(out / CORE_INFO_FILE), + evidence_digest, + ) + write_json(out / HISTORY_FILE, history) + history_digest = file_digest(out / HISTORY_FILE) + successor = _successor( + context, + ga, + predecessor, + candidate, + policy_digest, + evidence, + evidence_digest, + receipt_loaded.value, + history_digest, + follow_up, + follow_up_digest, + ) + _schema_gate( + state, + "stable-maintenance.successor-baseline", + successor, + SUCCESSOR_SCHEMA, + "Regenerate the v2 successor from exact verified publication state.", + ) + write_json(out / SUCCESSOR_BASELINE_FILE, successor) + receipt_errors = _receipt_errors( + context, + receipt_loaded, + candidate, + out / PUBLICATION_PLAN_FILE, + plan, + core_loaded.value, + core_loaded.digest, + file_digest(out / SUCCESSOR_BASELINE_FILE), + history_digest, + ) + add_blockers( + state, + "stable-maintenance.publication-verification", + receipt_errors, + "Record partial/conflicting state truthfully and recover without overwrite or deletion.", + ) + if state.blockers: + (out / SUCCESSOR_BASELINE_FILE).unlink(missing_ok=True) + (out / HISTORY_FILE).unlink(missing_ok=True) + return "publication-verification-failed", None + _copy_exact(receipt_loaded.path, out / PUBLICATION_RECEIPT_FILE, receipt_loaded.digest) + _copy_exact(core_loaded.path, out / CORE_RECEIPT_FILE, core_loaded.digest) + pointer = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-latest-published", + "generatedAt": candidate.input_value.get("generatedAt"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "baselineDigest": file_digest(out / SUCCESSOR_BASELINE_FILE), + "baselineIdentityDigest": successor_baseline_identity(successor), + "publicationReceiptDigest": receipt_loaded.digest, + "publicationReceiptIdentityDigest": receipt_id, + "lineageDigest": successor["lineage"]["lineageDigest"], + "historyDigest": history_digest, + "compareAndSwapPredecessorBaselineDigest": predecessor.baseline_digest, + "status": "active", + "redaction": dict(_PASS_REDACTION), + } + write_json(out / LATEST_POINTER_FILE, pointer) + return "publication-complete", successor + + +def _validation( + context: RunContext, + state: ValidationState, + mode: str, + candidate: Candidate, + lineage_digest: str, + comparison_digest: str, + evidence: dict[str, Any], + evidence_digest: str, + core_info_digest: str, + checksums_digest: str, + provenance_digest: str, + authorization_digest: str, + authorization_valid: bool, + publication_state: str, +) -> dict[str, Any]: + evidence_results = [ + { + "evidenceId": row.get("evidenceId"), + "status": row.get("status"), + "evidenceDigest": row.get("evidenceDigest"), + "candidateBound": True, + "predecessorBound": True, + "fresh": row.get("fresh"), + "production": row.get("production"), + } + for row in evidence.get("evidenceRows", []) + if isinstance(row, dict) + ] + decision = ( + "no-go" + if state.blockers + else ("go-with-waivers" if state.warnings else "go") + ) + value = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-validation", + "generatedAt": candidate.input_value.get("generatedAt"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "mode": mode, + "lineageDigest": lineage_digest, + "candidateIdentityDigest": candidate.identity_digest, + "comparisonDigest": comparison_digest, + "evidenceDigest": evidence_digest, + "coreInfoDigest": core_info_digest, + "checksumsDigest": checksums_digest, + "provenanceDigest": provenance_digest, + "evidenceResults": evidence_results, + "blockers": [ + { + "id": row.get("id"), + "category": row.get("evidenceId", row.get("id")), + "message": row.get("summary"), + "waivable": False, + } + for row in state.blockers + ], + "warnings": [ + { + "id": row.get("id"), + "category": row.get("evidenceId", row.get("id")), + "message": row.get("summary"), + "waivable": True, + } + for row in state.warnings + ], + "waivers": ( + [ + { + "warningId": row.get("id"), + "allowlisted": True, + "authorizationDigest": authorization_digest, + } + for row in state.warnings + ] + if authorization_valid + else [] + ), + "decision": decision, + "status": "fail" if state.blockers else "pass", + "publicationState": publication_state, + "promotionReady": bool( + not state.blockers + and authorization_valid + and publication_state in {"publication-authorized", "publication-complete"} + ), + "redaction": dict(_PASS_REDACTION), + } + _schema_gate( + state, + "stable-maintenance.validation-schema", + value, + VALIDATION_SCHEMA, + "Regenerate the complete maintenance validation artifact.", + ) + return value + + +def _summary( + context: RunContext, + state: ValidationState, + candidate: Candidate | None, + publication_state: str, + authorization_valid: bool, + artifacts: dict[str, str], + redaction: dict[str, Any], +) -> dict[str, Any]: + promotion_ready = bool( + candidate + and not state.blockers + and authorization_valid + and publication_state in {"publication-authorized", "publication-complete"} + ) + return { + "schemaVersion": SCHEMA_VERSION, + "kind": "stable-1.0-maintenance-promotion", + "tool": TOOL_NAME, + "toolVersion": TOOL_VERSION, + "generatedAt": ( + candidate.input_value.get("generatedAt") if candidate else _now().isoformat().replace("+00:00", "Z") + ), + "releaseId": context.manifest.release.release_id, + "version": context.manifest.release.version, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "stableMilestone": STABLE_MILESTONE, + "status": "fail" if state.blockers else "pass", + "promotionReady": promotion_ready, + "nonRelease": publication_state != "publication-complete", + "decision": ( + "no-go" + if state.blockers + else ("go-with-waivers" if state.warnings else "go") + ), + "publicationState": publication_state, + "blockers": state.blockers, + "warnings": state.warnings, + "waivers": [], + "redaction": redaction, + "artifacts": artifacts, + } + + +def run(context: RunContext) -> tuple[int, Path, Path]: + """Run the native maintenance engine and fail closed without public side effects.""" + + out = reset_confined_directory( + context.component_dir / "artifacts" / "legacy", + context.run_root, + "Stable maintenance native output", + ) + summary_path = out / SUMMARY_FILE + report_path = out / REPORT_FILE + state = ValidationState() + try: + code = _run(context, out, state) + except Exception: # noqa: BLE001 - protected release inputs must fail closed + state.block( + "stable-maintenance.execution-input", + "stable-maintenance.execution-input", + "Stable maintenance rejected a malformed, unsafe, or unauthenticated protected input.", + "Correct the protected candidate-bound input and restart certification.", + ) + out = reset_confined_directory( + context.component_dir / "artifacts" / "legacy", + context.run_root, + "Stable maintenance failed native output", + ) + _write_fail_closed(context, out, state) + code = 1 + return code, summary_path, report_path + + +def _run(context: RunContext, out: Path, state: ValidationState) -> int: + mode = context.manifest.commands.get("stable-maintenance", {}).get( + "mode", "validate-only" + ) + prepare = mode == "prepare-authorization" + closing_follow_up = mode == "close-hotfix-follow-up" + policy_loaded = load_json_input(context, "maintenancePolicy") + assert policy_loaded + add_blockers( + state, + "stable-maintenance.policy", + canonical_policy(context, policy_loaded), + "Use the exact checked-in Stable 1.0 maintenance policy.", + ) + policy = policy_loaded.value + ga = authenticate_ga_root(context, state) + obligation = ( + load_json_input(context, "hotfixFollowUpObligation") + if closing_follow_up + else None + ) + freeze_predecessor_observation: dict[str, Any] | None = None + if closing_follow_up: + assert obligation + freeze_input = load_json_input(context, "maintenanceCandidateFreeze") + assert freeze_input + observation = freeze_input.value.get("predecessorObservation") + freeze_predecessor_observation = ( + dict(observation) if isinstance(observation, dict) else {} + ) + original_predecessor_errors: list[str] = [] + if ( + freeze_predecessor_observation.get("buildVersion") + != obligation.value.get("predecessorBuild") + or freeze_predecessor_observation.get("productDigest") + != obligation.value.get("predecessorProductDigest") + or freeze_predecessor_observation.get("status") != "latest-published" + ): + original_predecessor_errors.append( + "hotfix follow-up candidate freeze is not bound to its original predecessor" + ) + add_blockers( + state, + "stable-maintenance.hotfix-follow-up", + original_predecessor_errors, + "Use the exact originally authorized hotfix freeze and predecessor observation.", + ) + predecessor = authenticate_predecessor( + context, ga, state, allow_non_successor_build=closing_follow_up + ) + candidate = authenticate_candidate( + context, + predecessor, + policy, + state, + allow_published_product=closing_follow_up, + freeze_predecessor_observation=freeze_predecessor_observation, + ) + + if mode == "close-hotfix-follow-up": + assert obligation + published_follow_up = predecessor.baseline.get("hotfixFollowUp") + published_follow_up = ( + published_follow_up if isinstance(published_follow_up, dict) else {} + ) + close_authorization = load_json_input( + context, "stableMaintenanceAuthorization", public_authorization=True + ) + assert close_authorization + close_errors = _close_authorization_errors( + close_authorization, obligation, published_follow_up + ) + add_blockers( + state, + "stable-maintenance.authorization", + close_errors, + "Use the original exact-hotfix security release authorization.", + ) + publication_binding_errors: list[str] = [] + if ( + candidate.input_value.get("releaseId") != obligation.value.get("releaseId") + or candidate.input_value.get("buildVersion") + != obligation.value.get("buildVersion") + or candidate.product_digest != obligation.value.get("productDigest") + or candidate.identity_digest + != obligation.value.get("candidateIdentityDigest") + or candidate.freeze_digest != obligation.value.get("candidateFreezeDigest") + or candidate.frozen_at != obligation.value.get("candidateFrozenAt") + or predecessor.latest_pointer_digest is None + or published_follow_up.get("status") not in {"open", "overdue"} + or published_follow_up.get("obligationDigest") != obligation.digest + or published_follow_up.get("obligatedReleaseId") + != obligation.value.get("releaseId") + or published_follow_up.get("obligatedBuildVersion") + != obligation.value.get("buildVersion") + or published_follow_up.get("obligatedProductDigest") + != obligation.value.get("productDigest") + or published_follow_up.get("obligatedCandidateIdentityDigest") + != obligation.value.get("candidateIdentityDigest") + or published_follow_up.get("obligatedCandidateFreezeDigest") + != obligation.value.get("candidateFreezeDigest") + or published_follow_up.get("obligatedCandidateFrozenAt") + != obligation.value.get("candidateFrozenAt") + or published_follow_up.get("obligatedPredecessorBuild") + != obligation.value.get("predecessorBuild") + or published_follow_up.get("obligatedPredecessorProductDigest") + != obligation.value.get("predecessorProductDigest") + ): + publication_binding_errors.append( + "hotfix follow-up closure is not bound to the original obligated " + "hotfix and current activated carrier" + ) + add_blockers( + state, + "stable-maintenance.hotfix-follow-up", + publication_binding_errors, + "Select the exact activated hotfix baseline, receipt, pointer, and obligation.", + ) + closure = close_hotfix_follow_up(context, ga, candidate, state) + closure.update( + { + "closedAt": closure.get("generatedAt"), + "releaseClass": "security-hotfix", + "successorBaselineDigest": predecessor.baseline_digest, + "publicationReceiptDigest": predecessor.receipt_digest, + "publicationReceiptIdentityDigest": receipt_identity( + predecessor.receipt + ), + "authorizationDigest": published_follow_up.get("authorizationDigest"), + "latestPublishedPointerDigest": predecessor.latest_pointer_digest, + "owner": obligation.value.get("owner"), + "approver": obligation.value.get("approver"), + } + ) + add_blockers( + state, + "stable-maintenance.hotfix-follow-up", + validate_schema(closure, FOLLOW_UP_CLOSURE_SCHEMA), + "Regenerate the versioned closure overlay from exact published inputs.", + ) + write_json(out / FOLLOW_UP_CLOSURE_FILE, closure) + redaction = build_redaction_report(((FOLLOW_UP_CLOSURE_FILE, closure),)) + artifacts = {"hotfixFollowUpClosure": FOLLOW_UP_CLOSURE_FILE} + summary = _summary( + context, + state, + candidate, + "publication-complete", + False, + artifacts, + redaction, + ) + summary["nonRelease"] = True + write_json(out / REDACTION_REPORT_FILE, redaction) + write_json(out / SUMMARY_FILE, summary) + write_text(out / REPORT_FILE, render_go_no_go(summary)) + return 0 if not state.blockers else 1 + + targets, targets_digest = _targets(context, state) + add_blockers( + state, + "stable-maintenance.publication-conflict", + _concrete_publication_destination_errors(targets, candidate), + "Use distinct canonical public HTTPS destinations for every concrete publication object.", + ) + candidate_freeze_input = load_json_input(context, "maintenanceCandidateFreeze") + assert candidate_freeze_input + _copy_exact( + candidate_freeze_input.path, + out / CANDIDATE_FREEZE_FILE, + candidate_freeze_input.digest, + ) + lineage = _lineage(context, ga, predecessor, candidate, state) + write_json(out / LINEAGE_FILE, lineage) + write_json(out / CANDIDATE_FILE, candidate.identity) + comparison = build_comparison(context, ga, predecessor, candidate, policy, state) + write_json(out / COMPARISON_FILE, comparison) + evidence, evidence_digest, follow_up = validate_production_evidence( + context, ga, predecessor, candidate, policy, state + ) + add_blockers( + state, + "stable-maintenance.hotfix-follow-up", + _concurrent_follow_up_errors(predecessor, follow_up), + "Use the full evidence window for the superseding hotfix or close the predecessor obligation first.", + ) + follow_up_digest: str | None = None + if follow_up is not None: + write_json(out / FOLLOW_UP_FILE, follow_up) + follow_up_digest = file_digest(out / FOLLOW_UP_FILE) + descriptor, _core_identity = build_core_info(context, candidate, state) + write_json(out / CORE_INFO_FILE, descriptor) + known = _known_limitations(context, candidate) + write_json(out / KNOWN_LIMITATIONS_FILE, known) + notes = render_release_notes( + context.manifest.release.release_id, + str(context.manifest.release.version), + str(context.manifest.policies.get("releaseClass")), + predecessor.build_version, + candidate.input_value, + "validated", + ) + write_text(out / RELEASE_NOTES_FILE, notes) + provenance = _provenance( + context, + policy_loaded.digest, + ga, + predecessor, + candidate, + file_digest(out / LINEAGE_FILE), + file_digest(out / COMPARISON_FILE), + evidence_digest, + file_digest(out / CORE_INFO_FILE), + ) + write_json(out / PROVENANCE_FILE, provenance) + _write_checksums( + out / CHECKSUMS_FILE, + _public_checksum_payload_paths(candidate, out).values(), + ) + expected = _authorization_expected( + context, + ga, + predecessor, + candidate, + file_digest(out / COMPARISON_FILE), + evidence_digest, + file_digest(out / CORE_INFO_FILE), + file_digest(out / CHECKSUMS_FILE), + file_digest(out / PROVENANCE_FILE), + file_digest(out / RELEASE_NOTES_FILE), + targets_digest, + follow_up_digest, + ) + authorization, authorization_digest, authorization_valid = _authorization( + context, expected, policy, state, prepare + ) + authorization_input = load_json_input( + context, + "stableMaintenanceAuthorization", + required=False, + public_authorization=True, + ) + if authorization_input is not None and authorization_valid: + _copy_exact(authorization_input.path, out / AUTHORIZATION_FILE, authorization_input.digest) + authorization_path = out / AUTHORIZATION_FILE + else: + write_json(out / AUTHORIZATION_FILE, authorization) + authorization_path = out / AUTHORIZATION_FILE + authorization_digest = file_digest(authorization_path) + core_plan = _core_plan( + context, + candidate, + descriptor, + out / CORE_INFO_FILE, + targets, + targets_digest, + authorization_digest, + state, + ) + write_json(out / CORE_PLAN_FILE, core_plan) + plan = _publication_plan( + context, + candidate, + out, + targets, + targets_digest, + authorization_path, + authorization_digest, + authorization_valid, + state, + ) + write_json(out / PUBLICATION_PLAN_FILE, plan) + publication_state = "validated" if not authorization_valid else "publication-authorized" + successor: dict[str, Any] | None = None + if authorization_valid: + publication_state, successor = _verify_receipts( + context, + out, + ga, + predecessor, + candidate, + policy_loaded.digest, + evidence, + evidence_digest, + descriptor, + core_plan, + plan, + follow_up, + follow_up_digest, + state, + ) + validation = _validation( + context, + state, + mode, + candidate, + file_digest(out / LINEAGE_FILE), + file_digest(out / COMPARISON_FILE), + evidence, + evidence_digest, + file_digest(out / CORE_INFO_FILE), + file_digest(out / CHECKSUMS_FILE), + file_digest(out / PROVENANCE_FILE), + authorization_digest, + authorization_valid, + publication_state, + ) + write_json(out / VALIDATION_FILE, validation) + artifacts = { + "lineage": LINEAGE_FILE, + "candidate": CANDIDATE_FILE, + "candidateFreeze": CANDIDATE_FREEZE_FILE, + "comparison": COMPARISON_FILE, + "validation": VALIDATION_FILE, + "authorization": AUTHORIZATION_FILE, + "knownLimitations": KNOWN_LIMITATIONS_FILE, + "releaseNotes": RELEASE_NOTES_FILE, + "publicationPlan": PUBLICATION_PLAN_FILE, + "checksums": CHECKSUMS_FILE, + "auditChecksums": AUDIT_CHECKSUMS_FILE, + "provenance": PROVENANCE_FILE, + "coreInfo": CORE_INFO_FILE, + "coreUpdatePublicationPlan": CORE_PLAN_FILE, + "redactionReport": REDACTION_REPORT_FILE, + "goNoGo": REPORT_FILE, + } + if follow_up is not None: + artifacts["hotfixFollowUp"] = FOLLOW_UP_FILE + if successor is not None: + artifacts.update( + { + "publicationReceipt": PUBLICATION_RECEIPT_FILE, + "coreUpdatePublicationReceipt": CORE_RECEIPT_FILE, + "successorBaseline": SUCCESSOR_BASELINE_FILE, + "releaseHistory": HISTORY_FILE, + "latestPublishedPointer": LATEST_POINTER_FILE, + } + ) + redaction_items: list[tuple[str, Any]] = [ + (LINEAGE_FILE, lineage), + (CANDIDATE_FILE, candidate.identity), + (CANDIDATE_FREEZE_FILE, candidate_freeze_input.value), + (COMPARISON_FILE, comparison), + (VALIDATION_FILE, validation), + (AUTHORIZATION_FILE, authorization), + (KNOWN_LIMITATIONS_FILE, known), + (RELEASE_NOTES_FILE, notes), + (PROVENANCE_FILE, provenance), + (CHECKSUMS_FILE, (out / CHECKSUMS_FILE).read_text(encoding="utf-8")), + (CORE_INFO_FILE, descriptor), + (CORE_PLAN_FILE, core_plan), + (PUBLICATION_PLAN_FILE, plan), + ] + if follow_up is not None: + redaction_items.append((FOLLOW_UP_FILE, follow_up)) + if successor is not None: + redaction_items.extend( + [ + (PUBLICATION_RECEIPT_FILE, load_json_input(context, "stableMaintenancePublicationReceipt").value), + (CORE_RECEIPT_FILE, load_json_input(context, "coreUpdatePublicationReceipt").value), + (SUCCESSOR_BASELINE_FILE, successor), + ] + ) + redaction = build_redaction_report(redaction_items) + if redaction.get("status") != "pass": + add_blockers( + state, + "stable-maintenance.support-redaction", + ["generated public maintenance artifact set failed redaction"], + "Remove unsafe input metadata and restart candidate certification.", + ) + publication_state = "publication-verification-failed" if successor else "validated" + write_json(out / REDACTION_REPORT_FILE, redaction) + if not state.blockers: + for name, source in sorted(candidate.asset_paths.items()): + _copy_exact(source, out / name, file_digest(source)) + summary = _summary( + context, + state, + candidate, + publication_state, + authorization_valid, + artifacts, + redaction, + ) + write_json(out / SUMMARY_FILE, summary) + write_text(out / REPORT_FILE, render_go_no_go(summary)) + audit_members = [path for path in out.iterdir() if path.is_file() and path.name != AUDIT_CHECKSUMS_FILE] + _write_checksums(out / AUDIT_CHECKSUMS_FILE, audit_members) + if prepare and not state.blockers: + return 0 + return 0 if summary.get("promotionReady") is True else 1 + + +def _write_fail_closed(context: RunContext, out: Path, state: ValidationState) -> None: + redaction = { + "schemaVersion": 1, + "status": "fail", + "findingCount": 1, + "findings": [ + { + "category": "protected-input", + "summary": "Unsafe or malformed maintenance input was rejected.", + } + ], + "guarantees": {"unsafeInputExcluded": True}, + } + summary = _summary(context, state, None, "validated", False, {}, redaction) + write_json(out / SUMMARY_FILE, summary) + write_text(out / REPORT_FILE, render_go_no_go(summary)) + write_json(out / REDACTION_REPORT_FILE, redaction) diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_artifacts.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_artifacts.py new file mode 100644 index 00000000000..12685074123 --- /dev/null +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_artifacts.py @@ -0,0 +1,243 @@ +"""Deterministic, public-safe Stable 1.0 maintenance reports and release notes.""" + +from __future__ import annotations + +import re +import unicodedata +from pathlib import Path +from typing import Any, Iterable + +from cryptad_certification.redaction import scan_value + +from .stable_1_0_maintenance_core import CHECKSUMS_FILE, PROVENANCE_FILE +from .stable_1_0_rc_core import placeholder_findings + +_TEMPLATE = ( + Path(__file__).resolve().parents[4] + / "docs" + / "templates" + / "stable-1.0-maintenance-release-notes.md" +) +_MARKER = "" +_TOKEN_RE = re.compile(r"\{\{([a-z_]+)\}\}") +_TOKENS = ( + "identity", + "classification", + "fixes", + "security", + "platform_api", + "catalog_apps", + "migration_recovery", + "packages", + "core_updater", + "limitations", + "support", + "verification", + "publication_status", +) +_PUNCTUATION = frozenset("!#*+-<>()[]\\^_`{|}~") + + +def _scalar(value: Any) -> str: + if value is None or isinstance(value, (dict, list, bool)): + raise ValueError("maintenance release-note scalar is malformed") + text = str(value).strip() + if not text or any( + unicodedata.category(character).startswith("C") + or unicodedata.category(character) in {"Zl", "Zp"} + for character in text + ): + raise ValueError("maintenance release-note scalar is empty or unsafe") + if placeholder_findings(text) or scan_value(text): + raise ValueError("maintenance release-note scalar is a placeholder or private value") + return text + + +def _escape(value: Any) -> str: + return "".join( + "\\" + character if character in _PUNCTUATION else character + for character in _scalar(value) + ) + + +def _code(value: Any) -> str: + text = _scalar(value) + longest = max((len(match.group()) for match in re.finditer(r"`+", text)), default=0) + fence = "`" * (longest + 1) + padding = " " if text.startswith("`") or text.endswith("`") else "" + return f"{fence}{padding}{text}{padding}{fence}" + + +def _load_template() -> str: + if _TEMPLATE.is_symlink() or not _TEMPLATE.is_file(): + raise ValueError("maintenance release-note template is missing or unsafe") + value = _TEMPLATE.read_text(encoding="utf-8") + if not value.startswith(_MARKER + "\n") or tuple(_TOKEN_RE.findall(value)) != _TOKENS: + raise ValueError("maintenance release-note template contract is invalid") + if scan_value(value) or placeholder_findings(value): + raise ValueError("maintenance release-note template is not public-safe") + return value + + +def _bullet_rows(values: Any, *, empty: str) -> str: + if not isinstance(values, list) or not values: + return empty + return "\n".join(f"- {_escape(value)}" for value in values) + + +def render_release_notes( + release_id: str, + build_version: str, + release_class: str, + predecessor_build: str, + candidate: dict[str, Any], + publication_state: str, +) -> str: + """Render one strict maintenance/hotfix note from candidate-bound public facts.""" + + scope = candidate.get("changeScope") if isinstance(candidate.get("changeScope"), dict) else {} + api = candidate.get("platformApi") if isinstance(candidate.get("platformApi"), dict) else {} + catalog = candidate.get("stableCatalog") if isinstance(candidate.get("stableCatalog"), dict) else {} + limitations = candidate.get("limitations") if isinstance(candidate.get("limitations"), dict) else {} + packages = candidate.get("packages") if isinstance(candidate.get("packages"), list) else [] + incident = scope.get("incidentId") + security = ( + f"Critical advisory {_code(incident)} is addressed. Public details remain limited to the " + "incident-safe advisory; protected exploit and key material are never release assets." + if release_class == "security-hotfix" + else "No expedited security-hotfix policy is used by this routine maintenance release." + ) + package_rows = [ + f"{row.get('os')} {row.get('arch')} {row.get('packageType')} ({row.get('packageKey')})" + for row in packages + if isinstance(row, dict) + ] + blocks = { + "identity": ( + f"Cryptad Stable 1.0 build {_code(build_version)} uses annotated tag " + f"{_code('v' + build_version)} and follows published predecessor build " + f"{_code(predecessor_build)}. Release record: {_code(release_id)}." + ), + "classification": ( + "This is a routine Stable 1.0 maintenance release." + if release_class == "maintenance" + else "This is a narrowly authorized Stable 1.0 critical security hotfix." + ), + "fixes": _bullet_rows( + scope.get("publicUserVisibleFixes"), + empty="Candidate scope contains compatibility-preserving maintenance fixes only.", + ), + "security": security, + "platform_api": ( + f"Platform API baseline {_code(api.get('baselineName'))} is preserved. Current contract " + f"version is {_code(api.get('currentContractVersion'))}; stable-removal and deprecation-clock " + "checks passed against both GA and the predecessor." + ), + "catalog_apps": ( + f"Stable catalog {_code(catalog.get('catalogId'))} advances to edition " + f"{_code(catalog.get('edition'))}, revision {_code(catalog.get('revision'))}. " + "The seven first-party app ids, stable channel, review/signing trust, and support " + "commitments remain enforced." + ), + "migration_recovery": ( + "Protected evidence passed predecessor and required direct-GA upgrades, daemon and app " + "rollback, app-data migration, backup-before-update, restore-after-failure, grant " + "revalidation, durable-state preservation, and operator recovery." + ), + "packages": _bullet_rows(package_rows, empty="No package target was declared."), + "core_updater": ( + f"CoreUpdater discovers integer build {_code(build_version)} from the package-based " + "descriptor and selects only an authenticated AppEnv OS/architecture package key." + ), + "limitations": ( + f"Added: {_code(limitations.get('addedCount'))}; resolved: " + f"{_code(limitations.get('resolvedCount'))}; unchanged: " + f"{_code(limitations.get('unchangedCount'))}. See the candidate-bound limitations delta." + ), + "support": ( + "Take a backup before updating and retain the predecessor package until validation " + "completes. Attach only the redacted operator support bundle; never attach raw content, " + "raw app data, identity data, keys, insert URIs, cookies, or tokens." + ), + "verification": ( + f"Verify every asset with {_code(CHECKSUMS_FILE)} and {_code(PROVENANCE_FILE)}. Any " + "post-authorization byte change requires a new candidate freeze and authorization." + ), + "publication_status": ( + f"Prepared state: {_code(publication_state)}. These notes do not claim public release " + "completion until the protected operation and independent receipt verification pass." + ), + } + return _TOKEN_RE.sub(lambda match: blocks[match.group(1)], _load_template()) + + +def render_go_no_go(summary: dict[str, Any]) -> str: + """Render a compact deterministic maintenance decision report.""" + + lines = [ + "# Stable 1.0 maintenance go/no-go", + "", + f"- Release: {_code(summary.get('releaseId'))}", + f"- Build/tag: {_code(summary.get('buildVersion'))} / {_code('v' + str(summary.get('buildVersion')))}", + f"- Class: {_code(summary.get('releaseClass'))}", + f"- Decision: **{_escape(summary.get('decision'))}**", + f"- Publication state: {_code(summary.get('publicationState'))}", + f"- Exact-byte publication ready: {_code(str(summary.get('promotionReady')).lower())}", + "", + "## Blockers", + "", + ] + blockers = summary.get("blockers") + if isinstance(blockers, list) and blockers: + for row in blockers: + if isinstance(row, dict): + lines.append(f"- {_code(row.get('id'))}: {_escape(row.get('summary', row.get('message')))}") + else: + lines.append("No blocker is open.") + lines.extend(["", "## Warnings", ""]) + warnings = summary.get("warnings") + if isinstance(warnings, list) and warnings: + for row in warnings: + if isinstance(row, dict): + lines.append(f"- {_code(row.get('id'))}: {_escape(row.get('summary', row.get('message')))}") + else: + lines.append("No warning is open.") + lines.extend( + [ + "", + "Publication is a separate protected operation. A validation result alone never creates ", + "a tag, GitHub Release, catalog edition, CoreUpdater insert, or announcement.", + ] + ) + return "\n".join(lines) + + +def build_redaction_report(items: Iterable[tuple[str, Any]]) -> dict[str, Any]: + """Scan a complete public artifact set and attribute every finding.""" + + findings: list[dict[str, Any]] = [] + for artifact, value in items: + for finding in scan_value(value): + findings.append({"artifact": artifact, **finding}) + for finding in placeholder_findings(value): + findings.append( + { + "artifact": artifact, + "category": "placeholder", + "summary": str(finding), + } + ) + return { + "schemaVersion": 1, + "status": "pass" if not findings else "fail", + "findingCount": len(findings), + "findings": findings, + "guarantees": { + "privateKeysExcluded": not findings, + "privateInsertUrisExcluded": not findings, + "tokensAndHeadersExcluded": not findings, + "rawContentAndAppDataExcluded": not findings, + "identityMaterialExcluded": not findings, + "absoluteRunnerPathsExcluded": not findings, + }, + } diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_compatibility.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_compatibility.py new file mode 100644 index 00000000000..c0df27f8045 --- /dev/null +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_compatibility.py @@ -0,0 +1,1208 @@ +"""Stable 1.0 compatibility and production-evidence gates for maintenance releases.""" + +from __future__ import annotations + +import datetime as dt +from typing import Any + +from cryptad_certification.models import RunContext +from cryptad_certification.schema_validation import validate_schema + +from .stable_1_0_maintenance_core import ( + ALLOWED_OPERATIONAL_WARNING_IDS, + COMPARISON_SCHEMA, + EVIDENCE_SCHEMA, + FOLLOW_UP_SCHEMA, + REQUIRED_PRODUCTION_EVIDENCE, + Candidate, + GaRoot, + OPERATIONAL_WARNING_EVIDENCE_IDS, + Predecessor, + add_blockers, + load_json_input, +) +from .stable_1_0_rc_core import ValidationState, parse_timestamp, semantic_digest + + +def _rows_by(rows: Any, key: str) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + if not isinstance(rows, list): + return result + for row in rows: + if not isinstance(row, dict) or not isinstance(row.get(key), str): + continue + identity = row[key] + if identity in result: + return {} + result[identity] = row + return result + + +def _ga_evidence_binding_errors( + evidence_id: str, row: dict[str, Any], ga: GaRoot +) -> list[str]: + ga_fields = ( + row.get("gaReleaseId"), + row.get("gaBuild"), + row.get("gaProductDigest"), + ) + if evidence_id == "stable-maintenance.direct-ga-upgrade": + if ga_fields != (ga.release_id, ga.build_version, ga.product_digest): + return ["direct-GA upgrade evidence is not bound to the authenticated GA root"] + elif ga_fields != (None, None, None): + return [f"non-GA evidence {evidence_id} carries an unexpected GA source identity"] + return [] + + +def _predecessor_section(predecessor: Predecessor, name: str, ga_name: str | None = None) -> Any: + """Return the current predecessor state, falling back to the GA v1 field name.""" + + if predecessor.baseline.get("schemaVersion") == 1: + return predecessor.baseline.get(ga_name or name) + return predecessor.baseline.get(name) + + +def _issue(issue_id: str, category: str, message: str, *, waivable: bool = False) -> dict[str, Any]: + return { + "id": issue_id, + "category": category, + "message": message.rstrip(".") + ".", + "waivable": waivable, + } + + +def _section( + ga_value: Any, + predecessor_value: Any, + candidate_value: Any, + blockers: list[dict[str, Any]], + warnings: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "status": "fail" if blockers else ("warn" if warnings else "pass"), + "gaDeltaDigest": semantic_digest({"from": ga_value, "to": candidate_value}), + "predecessorDeltaDigest": semantic_digest( + {"from": predecessor_value, "to": candidate_value} + ), + "blockerCount": len(blockers), + "warningCount": len(warnings), + } + + +def _platform_api_errors(ga: Any, predecessor: Any, candidate: Any) -> list[str]: + if not all(isinstance(value, dict) for value in (ga, predecessor, candidate)): + return ["Platform API comparison inputs are malformed"] + errors: list[str] = [] + immutable = ( + "baselineName", + "baselineDigest", + "baselineContractVersion", + "stableSurfaceDigest", + "compatibilityWindowPolicyDigest", + ) + for field in immutable: + if candidate.get(field) != ga.get(field): + errors.append(f"Platform API long-term baseline field {field} changed") + if predecessor.get("stableSurfaceDigest") != ga.get("stableSurfaceDigest"): + errors.append("Platform API predecessor changed the frozen Stable 1.0 surface") + if candidate.get("baselineName") != "1.0": + errors.append("Platform API baseline is not Stable 1.0") + current = candidate.get("currentContractVersion") + previous = predecessor.get("currentContractVersion") + if type(current) is not int or type(previous) is not int or current < previous: + errors.append("Platform API current contract version is not monotonic") + closed_empty = ( + "removedStableEndpoints", + "removedStableCapabilities", + "breakingStableChanges", + ) + for field in closed_empty: + if candidate.get(field) != []: + errors.append(f"Platform API candidate declares forbidden {field}") + closed_false = ( + "criticalRemovalWaiverAttempt", + "deprecationClockReset", + "experimentalMislabelledStable", + ) + for field in closed_false: + if candidate.get(field) is not False: + errors.append(f"Platform API gate {field} did not remain false") + if candidate.get("additionsBackwardCompatible") is not True: + errors.append("Platform API additions are not proven backward compatible") + if candidate.get("thirdPartyCompatibilityStatus") != "pass": + errors.append("Platform API third-party stable samples did not pass") + if ga.get("criticalStableRemovalWaiverAllowed") is not False: + errors.append("Authenticated GA baseline does not prohibit critical removal waivers") + minimum_deprecation = ga.get("minimumDeprecationWindowContractVersions") + minimum_removal = ga.get("minimumRemovalWindowContractVersions") + if type(minimum_deprecation) is not int or minimum_deprecation < 2: + errors.append("GA deprecation window policy is invalid") + if type(minimum_removal) is not int or minimum_removal < 2: + errors.append("GA removal window policy is invalid") + errors.extend(_deprecation_history_errors(ga, predecessor, candidate)) + return errors + + +def _deprecation_history_errors( + ga: dict[str, Any], predecessor: dict[str, Any], candidate: dict[str, Any] +) -> list[str]: + """Authenticate descriptor deprecation clocks instead of trusting a reset flag.""" + + errors: list[str] = [] + current_history = candidate.get("deprecationHistory") + if not isinstance(current_history, list): + return ["Platform API deprecation history is missing or malformed"] + if candidate.get("deprecationHistoryDigest") != semantic_digest(current_history): + errors.append("Platform API deprecation history digest does not bind the history rows") + + predecessor_history = predecessor.get("deprecationHistory", []) + if not isinstance(predecessor_history, list): + errors.append("Platform API predecessor deprecation history is malformed") + predecessor_history = [] + predecessor_digest = predecessor.get("deprecationHistoryDigest") + if predecessor_digest is not None and predecessor_digest != semantic_digest( + predecessor_history + ): + errors.append("Platform API predecessor deprecation history digest is invalid") + + def history_map(rows: list[Any], label: str) -> dict[tuple[str, str], dict[str, Any]]: + result: dict[tuple[str, str], dict[str, Any]] = {} + for row in rows: + if not isinstance(row, dict): + errors.append(f"Platform API {label} deprecation history row is malformed") + continue + kind = row.get("kind") + identity = row.get("identity") + if ( + kind not in {"capability", "endpoint"} + or not isinstance(identity, str) + or not identity + ): + errors.append(f"Platform API {label} deprecation history identity is invalid") + continue + key = (kind, identity) + if key in result: + errors.append( + f"Platform API {label} deprecation history contains duplicate identities" + ) + continue + result[key] = row + return result + + previous_rows = history_map(predecessor_history, "predecessor") + current_rows = history_map(current_history, "candidate") + if predecessor_history != [ + previous_rows[key] for key in sorted(previous_rows) + ]: + errors.append("Platform API predecessor deprecation history is not canonically ordered") + if current_history != [current_rows[key] for key in sorted(current_rows)]: + errors.append("Platform API candidate deprecation history is not canonically ordered") + current_version = candidate.get("currentContractVersion") + previous_version = predecessor.get("currentContractVersion") + minimum_deprecation = ga.get("minimumDeprecationWindowContractVersions") + minimum_removal = ga.get("minimumRemovalWindowContractVersions") + if not all( + type(value) is int + for value in ( + current_version, + previous_version, + minimum_deprecation, + minimum_removal, + ) + ): + return errors + + for key, row in current_rows.items(): + deprecated_since = row.get("deprecatedSinceContractVersion") + removal_version = row.get("removalContractVersion") + stability = row.get("stability") + if type(deprecated_since) is not int or deprecated_since < 1: + errors.append( + f"Platform API deprecation history {key} has an invalid start version" + ) + continue + if deprecated_since > current_version: + errors.append(f"Platform API deprecation history {key} starts in the future") + if stability not in {"deprecated", "scheduled-for-removal"}: + errors.append( + f"Platform API deprecation history {key} has an invalid stability state" + ) + if removal_version is not None: + if type(removal_version) is not int or removal_version <= deprecated_since: + errors.append( + f"Platform API deprecation history {key} has an invalid removal version" + ) + elif ( + removal_version - deprecated_since < minimum_deprecation + or removal_version - current_version < minimum_removal + ): + errors.append( + f"Platform API deprecation history {key} shortens a required window" + ) + elif stability == "scheduled-for-removal": + errors.append(f"Platform API deprecation history {key} lacks a removal version") + + previous_row = previous_rows.get(key) + if previous_row is None: + if deprecated_since <= previous_version: + errors.append( + f"Platform API deprecation history {key} backdates a newly recorded clock" + ) + continue + if deprecated_since != previous_row.get("deprecatedSinceContractVersion"): + errors.append( + f"Platform API deprecation history {key} reset its original clock" + ) + previous_removal = previous_row.get("removalContractVersion") + if ( + type(previous_removal) is int + and type(removal_version) is int + and removal_version < previous_removal + ): + errors.append(f"Platform API deprecation history {key} moved removal earlier") + + missing = sorted(set(previous_rows) - set(current_rows)) + if missing: + errors.append("Platform API candidate dropped predecessor deprecation history rows") + return errors + + +def _content_profile_errors(ga: Any, predecessor: Any, candidate: Any) -> list[str]: + ga_map = _rows_by(ga, "profileId") + predecessor_map = _rows_by(predecessor, "profileId") + current_map = _rows_by(candidate, "profileId") + errors: list[str] = [] + if not ga_map or set(current_map) != set(ga_map) or set(predecessor_map) != set(ga_map): + return ["Stable v1 content-profile set changed"] + immutable = ( + "version", + "status", + "descriptorDigest", + "canonicalizationRulesDigest", + "maximumSizePolicyDigest", + "signaturePayloadRulesDigest", + ) + for profile_id in sorted(ga_map): + current = current_map[profile_id] + for field in immutable: + if current.get(field) != ga_map[profile_id].get(field): + errors.append(f"content profile {profile_id} changed frozen {field}") + if current.get("existingValidDocumentsAccepted") is not True: + errors.append(f"content profile {profile_id} rejects existing valid documents") + if current.get("acceptanceStatus") != "pass": + errors.append(f"content profile {profile_id} parser/verifier compatibility failed") + return errors + + +def _limitation_anchor_ids(value: Any, label: str) -> tuple[set[str], list[str]]: + """Return authenticated limitation membership from a GA v1 or successor v2 anchor.""" + + if not isinstance(value, dict): + return set(), [f"{label} known-limitations anchor is malformed"] + current_ids = value.get("currentIds") + if isinstance(current_ids, list): + if ( + any(not isinstance(item, str) or not item for item in current_ids) + or len(set(current_ids)) != len(current_ids) + or current_ids != sorted(current_ids) + ): + return set(), [f"{label} known-limitations membership is malformed"] + current = set(current_ids) + if value.get("currentDigest") != _known_limitations_digest(current): + return current, [f"{label} known-limitations membership digest is invalid"] + return current, [] + allowed = value.get("allowedLimitations") + if not isinstance(allowed, list): + return set(), [f"{label} known-limitations membership is missing"] + ids: list[str] = [] + for row in allowed: + if not isinstance(row, dict) or not isinstance(row.get("id"), str) or not row["id"]: + return set(), [f"{label} known-limitations membership is malformed"] + ids.append(row["id"]) + if len(set(ids)) != len(ids): + return set(ids), [f"{label} known-limitations membership contains duplicate ids"] + return set(ids), [] + + +def _known_limitations_digest(ids: set[str]) -> str: + """Digest the complete current limitation membership in canonical id order.""" + + return semantic_digest({"limitationIds": sorted(ids)}) + + +def _limitation_delta_digest( + predecessor_ids: set[str], + added_ids: set[str], + resolved_ids: set[str], + unchanged_ids: set[str], +) -> str: + """Digest one exact predecessor-to-candidate limitation transition.""" + + return semantic_digest( + { + "predecessorIds": sorted(predecessor_ids), + "addedIds": sorted(added_ids), + "resolvedIds": sorted(resolved_ids), + "unchangedIds": sorted(unchanged_ids), + "currentIds": sorted(added_ids | unchanged_ids), + } + ) + + +def _limitation_errors(ga: Any, predecessor: Any, value: Any) -> list[str]: + if not isinstance(value, dict): + return ["known-limitations delta is malformed"] + errors: list[str] = [] + _ga_ids, ga_errors = _limitation_anchor_ids(ga, "GA") + predecessor_ids, predecessor_errors = _limitation_anchor_ids( + predecessor, "predecessor" + ) + errors.extend(ga_errors) + errors.extend(predecessor_errors) + delta_sets: dict[str, set[str]] = {} + for label, count in ( + ("added", value.get("addedCount")), + ("resolved", value.get("resolvedCount")), + ("unchanged", value.get("unchangedCount")), + ): + rows = value.get(label + "Ids") + if ( + not isinstance(rows, list) + or any(not isinstance(item, str) or not item for item in rows) + or len(set(rows)) != len(rows) + or rows != sorted(rows) + or type(count) is not int + or len(rows) != count + ): + errors.append(f"known-limitations {label} ids do not match their count") + continue + delta_sets[label] = set(rows) + if len(delta_sets) == 3 and not predecessor_errors: + added_ids = delta_sets["added"] + resolved_ids = delta_sets["resolved"] + unchanged_ids = delta_sets["unchanged"] + if ( + added_ids & resolved_ids + or added_ids & unchanged_ids + or resolved_ids & unchanged_ids + ): + errors.append("known-limitations delta id sets are not disjoint") + if resolved_ids | unchanged_ids != predecessor_ids: + errors.append( + "known-limitations resolved and unchanged ids do not partition the predecessor" + ) + if added_ids & predecessor_ids: + errors.append("known-limitations added ids already exist in the predecessor") + current_ids = added_ids | unchanged_ids + if value.get("knownLimitationsDigest") != _known_limitations_digest(current_ids): + errors.append("known-limitations current membership digest is invalid") + if value.get("deltaDigest") != _limitation_delta_digest( + predecessor_ids, added_ids, resolved_ids, unchanged_ids + ): + errors.append("known-limitations delta digest is invalid") + if value.get("changesReviewed") is not True or value.get("noHiddenLimitations") is not True: + errors.append("known-limitations delta is not reviewed and complete") + return errors + + +def _security_support_legacy_errors( + ga: GaRoot, predecessor: Predecessor, candidate: Candidate +) -> tuple[list[str], list[str], list[str]]: + security = candidate.input_value.get("security") + support = candidate.input_value.get("support") + legacy = candidate.input_value.get("legacyBoundaries") + security_errors: list[str] = [] + support_errors: list[str] = [] + legacy_errors: list[str] = [] + if not isinstance(security, dict) or security.get("signingKeysUncompromised") is not True: + security_errors.append("security signing-key state is not uncompromised") + if isinstance(security, dict): + for field in ("advisoryStatus", "denylistStatus", "securityEvidenceStatus"): + if security.get(field) != "pass": + security_errors.append(f"security {field} failed") + if not isinstance(support, dict) or support.get("redactionStatus") != "pass": + support_errors.append("support diagnostics or redaction failed") + if isinstance(support, dict): + for field in ("supportStatus", "diagnosticsStatus"): + if support.get(field) != "pass": + support_errors.append(f"support {field} failed") + if isinstance(support, dict) and support.get("supportCommitmentReduced") is not False: + support_errors.append("support commitment was reduced") + expected = { + "pluginRuntime": "removed", + "inCorePluginApi": "removed", + "legacyAdminMutationRoutes": "disabled", + "fproxyBrowse": "retained", + "contentFiltering": "retained", + "emergencyFallbackRoutes": "retained", + } + if not isinstance(legacy, dict) or any(legacy.get(key) != value for key, value in expected.items()): + legacy_errors.append("Stable legacy boundary was expanded or a retained route was removed") + ga_legacy = ga.baseline.get("legacyBoundaries") + predecessor_legacy = _predecessor_section(predecessor, "legacyBoundaries") + if not isinstance(ga_legacy, dict) or not isinstance(predecessor_legacy, dict): + legacy_errors.append("authenticated legacy boundary anchor is malformed") + return security_errors, support_errors, legacy_errors + + +def build_comparison( + context: RunContext, + ga: GaRoot, + predecessor: Predecessor, + candidate: Candidate, + policy: dict[str, Any], + state: ValidationState, +) -> dict[str, Any]: + """Compare the exact candidate to both GA and the immediate predecessor.""" + + ga_platform = ga.baseline.get("platformApi") + predecessor_platform = _predecessor_section(predecessor, "platformApi") + current_platform = candidate.input_value.get("platformApi") + ga_catalog = ga.baseline.get("stableCatalog") + predecessor_catalog = _predecessor_section(predecessor, "stableCatalog") + ga_apps = ga.baseline.get("firstPartyApps") + predecessor_apps = _predecessor_section(predecessor, "firstPartyApps") + # Candidate is frozen, but Python's frozen dataclass deliberately prevents hidden + # comparison state. Perform the app comparison inline with an explicit helper view. + catalog_errors = _catalog_errors(ga_catalog, predecessor_catalog, candidate.input_value) + app_errors = _app_errors( + ga_apps, + predecessor_apps, + candidate.input_value.get("firstPartyApps"), + policy, + ) + profile_errors = _content_profile_errors( + ga.baseline.get("contentFormatProfiles"), + _predecessor_section(predecessor, "contentFormatProfiles"), + candidate.input_value.get("contentFormatProfiles"), + ) + platform_errors = _platform_api_errors(ga_platform, predecessor_platform, current_platform) + limitation_errors = _limitation_errors( + ga.baseline.get("limitations"), + _predecessor_section(predecessor, "limitations"), + candidate.input_value.get("limitations"), + ) + security_errors, support_errors, legacy_errors = _security_support_legacy_errors( + ga, predecessor, candidate + ) + categories = { + "platformApi": platform_errors, + "catalogAndApps": [*catalog_errors, *app_errors], + "contentProfiles": profile_errors, + "limitations": limitation_errors, + "security": security_errors, + "support": support_errors, + "legacyBoundaries": legacy_errors, + } + issue_ids = { + "platformApi": "stable-maintenance.platform-api-compatibility", + "catalogAndApps": "stable-maintenance.catalog-app-compatibility", + "contentProfiles": "stable-maintenance.content-profile-compatibility", + "limitations": "stable-maintenance.known-limitations", + "security": "stable-maintenance.security", + "support": "stable-maintenance.support-redaction", + "legacyBoundaries": "stable-maintenance.legacy-boundaries", + } + comparison_blockers: list[dict[str, Any]] = [] + sections: dict[str, Any] = {} + values = { + "platformApi": (ga_platform, predecessor_platform, current_platform), + "catalogAndApps": ( + {"catalog": ga_catalog, "apps": ga_apps}, + {"catalog": predecessor_catalog, "apps": predecessor_apps}, + { + "catalog": candidate.input_value.get("stableCatalog"), + "apps": candidate.input_value.get("firstPartyApps"), + }, + ), + "contentProfiles": ( + ga.baseline.get("contentFormatProfiles"), + _predecessor_section(predecessor, "contentFormatProfiles"), + candidate.input_value.get("contentFormatProfiles"), + ), + "limitations": ( + ga.baseline.get("limitations"), + _predecessor_section(predecessor, "limitations"), + candidate.input_value.get("limitations"), + ), + "security": ( + ga.baseline.get("securityBaseline"), + _predecessor_section(predecessor, "security", "securityBaseline"), + candidate.input_value.get("security"), + ), + "support": ( + ga.baseline.get("supportBaseline"), + _predecessor_section(predecessor, "support", "supportBaseline"), + candidate.input_value.get("support"), + ), + "legacyBoundaries": ( + ga.baseline.get("legacyBoundaries"), + _predecessor_section(predecessor, "legacyBoundaries"), + candidate.input_value.get("legacyBoundaries"), + ), + } + for category, errors in categories.items(): + issues = [_issue(issue_ids[category], category, message) for message in errors] + comparison_blockers.extend(issues) + ga_value, prior_value, current_value = values[category] + sections[category] = _section(ga_value, prior_value, current_value, issues, []) + add_blockers( + state, + issue_ids[category], + errors, + "Restore the Stable 1.0 GA and predecessor compatibility contract.", + ) + result = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-comparison", + "generatedAt": candidate.input_value.get("generatedAt"), + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "gaBaselineDigest": ga.baseline_digest, + "predecessorBaselineDigest": predecessor.baseline_digest, + "candidateIdentityDigest": candidate.identity_digest, + "sections": sections, + "compatible": not comparison_blockers, + "decision": "go" if not comparison_blockers else "no-go", + "blockers": comparison_blockers, + "warnings": [], + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + } + add_blockers( + state, + "stable-maintenance.comparison-schema", + validate_schema(result, COMPARISON_SCHEMA), + "Regenerate the complete GA and predecessor comparison artifact.", + ) + return result + + +def _catalog_errors(ga: Any, predecessor: Any, candidate_value: dict[str, Any]) -> list[str]: + current = candidate_value.get("stableCatalog") + current = current if isinstance(current, dict) else {} + ga = ga if isinstance(ga, dict) else {} + predecessor = predecessor if isinstance(predecessor, dict) else {} + errors: list[str] = [] + if current.get("catalogId") != ga.get("catalogId") or current.get("channel") != "stable": + errors.append("stable catalog identity or channel differs from GA") + for field in ("edition", "revision"): + if type(current.get(field)) is not int or type(predecessor.get(field)) is not int or current[field] < predecessor[field]: + errors.append(f"stable catalog {field} was downgraded") + predecessor_identity = { + "digest": predecessor.get("digest", predecessor.get("catalogDigest")), + "signatureDigest": predecessor.get("signatureDigest"), + "signingKeyId": predecessor.get("signingKeyId"), + } + catalog_identity_changed = any( + current.get(field) != predecessor_identity[field] + for field in ("digest", "signatureDigest", "signingKeyId") + ) + catalog_version_advanced = any( + type(current.get(field)) is int + and type(predecessor.get(field)) is int + and current[field] > predecessor[field] + for field in ("edition", "revision") + ) + if catalog_identity_changed and not catalog_version_advanced: + errors.append( + "stable catalog bytes, signature, or signing key changed without advancing edition or revision" + ) + for field in ("signatureStatus", "keyTrustStatus", "mirrorStatus", "rollbackStatus", "advisoryStatus", "denylistStatus"): + if current.get(field) != "pass": + errors.append(f"stable catalog {field} failed") + transition_status = current.get("keyRotationTrustTransitionStatus") + if transition_status not in {"complete", "not-applicable"}: + errors.append("stable catalog key rotation lacks a complete trust transition") + signing_key_changed = current.get("signingKeyId") != predecessor.get( + "signingKeyId" + ) + if signing_key_changed and transition_status != "complete": + errors.append("stable catalog signing-key replacement lacks a complete trust transition") + if transition_status == "not-applicable" and signing_key_changed: + errors.append("stable catalog key rotation is incorrectly marked not-applicable") + return errors + + +def _dotted_numeric_version(value: Any) -> tuple[int, ...] | None: + """Parse a strict version that AppUpdateService can compare numerically.""" + + if not isinstance(value, str) or not value or value != value.strip(): + return None + tokens = value.split(".") + if any( + not token + or any(character < "0" or character > "9" for character in token) + for token in tokens + ): + return None + parts: list[int] = [] + for token in tokens: + significant = token.lstrip("0") or "0" + if len(significant) > 10 or ( + len(significant) == 10 and significant > "2147483647" + ): + return None + parts.append(int(significant)) + return tuple(parts) + + +def _compare_dotted_numeric_versions(left: tuple[int, ...], right: tuple[int, ...]) -> int: + """Compare dotted numeric versions with AppUpdateService's zero-padding semantics.""" + + count = max(len(left), len(right)) + for index in range(count): + left_part = left[index] if index < len(left) else 0 + right_part = right[index] if index < len(right) else 0 + if left_part != right_part: + return 1 if left_part > right_part else -1 + return 0 + + +def _support_level_ranks(policy: dict[str, Any]) -> dict[str, int]: + catalog_policy = policy.get("catalogAndApps") + catalog_policy = catalog_policy if isinstance(catalog_policy, dict) else {} + allowed = catalog_policy.get("allowedSupportLevels") + order = catalog_policy.get("supportLevelOrder") + if ( + not isinstance(allowed, list) + or not isinstance(order, list) + or not allowed + or any(not isinstance(level, str) for level in (*allowed, *order)) + or len(allowed) != len(set(allowed)) + or len(order) != len(set(order)) + or set(order) != set(allowed) + ): + return {} + return {level: rank for rank, level in enumerate(order)} + + +def _app_errors( + ga: Any, + predecessor: Any, + candidate: Any, + policy: dict[str, Any], +) -> list[str]: + ga_map = _rows_by(ga, "appId") + predecessor_map = _rows_by(predecessor, "appId") + current_map = _rows_by(candidate, "appId") + if not ga_map or set(current_map) != set(ga_map) or set(predecessor_map) != set(ga_map): + return ["first-party stable app id set was removed, substituted, or forked"] + errors: list[str] = [] + support_ranks = _support_level_ranks(policy) + if not support_ranks: + errors.append("first-party app support-level policy is malformed or not closed") + for app_id in sorted(current_map): + row = current_map[app_id] + ga_row = ga_map[app_id] + predecessor_row = predecessor_map[app_id] + if row.get("channel") != "stable": + errors.append(f"first-party app {app_id} left the stable channel") + support_levels = ( + row.get("supportLevel"), + ga_row.get("supportLevel"), + predecessor_row.get("supportLevel"), + ) + if not support_ranks or any( + level not in support_ranks for level in support_levels + ): + errors.append( + f"first-party app {app_id} has an unknown support commitment" + ) + elif ( + support_ranks[support_levels[0]] < support_ranks[support_levels[1]] + or support_ranks[support_levels[0]] + < support_ranks[support_levels[2]] + ): + errors.append(f"first-party app {app_id} reduced its support commitment") + current_version = _dotted_numeric_version(row.get("version")) + ga_version = _dotted_numeric_version(ga_row.get("version")) + predecessor_version = _dotted_numeric_version(predecessor_row.get("version")) + if current_version is None: + errors.append( + f"first-party app {app_id} candidate version is not strict dotted-numeric" + ) + if ga_version is None or predecessor_version is None: + errors.append( + f"first-party app {app_id} authenticated version anchor is not strict dotted-numeric" + ) + if current_version is not None and ga_version is not None: + ga_comparison = _compare_dotted_numeric_versions(current_version, ga_version) + if ga_comparison < 0: + errors.append(f"first-party app {app_id} version regressed below GA") + if ( + row.get("bundleDigest") != ga_row.get("bundleDigest") + and ga_comparison <= 0 + ): + errors.append( + f"first-party app {app_id} changed bundle bytes without increasing its version over GA" + ) + if current_version is not None and predecessor_version is not None: + predecessor_comparison = _compare_dotted_numeric_versions( + current_version, predecessor_version + ) + if predecessor_comparison < 0: + errors.append( + f"first-party app {app_id} version regressed below the immediate predecessor" + ) + if ( + row.get("bundleDigest") != predecessor_row.get("bundleDigest") + and predecessor_comparison <= 0 + ): + errors.append( + f"first-party app {app_id} changed bundle bytes without increasing its version over the immediate predecessor" + ) + if row.get("trustState") != "trusted": + errors.append(f"first-party app {app_id} trustState failed") + for field in ("reviewStatus", "signingStatus", "apiCompatibilityStatus", "migrationStatus", "backupRestoreStatus", "serviceGrantStatus"): + if row.get(field) != "pass": + errors.append(f"first-party app {app_id} {field} failed") + if row.get("permissionExpansion") is True and not ( + row.get("permissionConsentStatus") == "pass" and row.get("permissionRationaleStatus") == "pass" + ): + errors.append(f"first-party app {app_id} expands permissions without consent and rationale") + if row.get("reviewedBundleDigest") != row.get("bundleDigest"): + errors.append(f"first-party app {app_id} changed after review") + schema_versions = ( + ga_row.get("appDataSchemaVersion"), + predecessor_row.get("appDataSchemaVersion"), + ) + anchored_versions = [value for value in schema_versions if type(value) is int] + current_schema = row.get("appDataSchemaVersion") + if anchored_versions and ( + type(current_schema) is not int or current_schema < max(anchored_versions) + ): + errors.append(f"first-party app {app_id} downgraded its app-data schema") + return errors + + +def _duration_days(start: dt.datetime, end: dt.datetime) -> float: + return (end - start).total_seconds() / 86400.0 + + +def _operational_warning_errors( + candidate: Candidate, + evidence_by_id: dict[str, dict[str, Any]], + state: ValidationState, +) -> list[str]: + """Bind every evidence warning to the exact warning frozen with the candidate.""" + + errors: list[str] = [] + declared_by_evidence: dict[str, dict[str, Any]] = {} + warnings = candidate.input_value.get("operationalWarnings", []) + warnings = warnings if isinstance(warnings, list) else [] + for warning in warnings: + if not isinstance(warning, dict): + errors.append("candidate operational warning is malformed") + continue + warning_id = warning.get("warningId") + evidence_id = OPERATIONAL_WARNING_EVIDENCE_IDS.get(str(warning_id)) + if warning_id not in ALLOWED_OPERATIONAL_WARNING_IDS or evidence_id is None: + errors.append(f"candidate operational warning {warning_id} is not policy allowlisted") + continue + if evidence_id in declared_by_evidence: + errors.append( + f"candidate contains duplicate operational warnings for {evidence_id}" + ) + continue + declared_by_evidence[evidence_id] = warning + + warned_evidence_ids = { + evidence_id + for evidence_id, row in evidence_by_id.items() + if row.get("status") == "warn" + } + for evidence_id in sorted(warned_evidence_ids): + warning = declared_by_evidence.get(evidence_id) + evidence_digest = evidence_by_id[evidence_id].get("evidenceDigest") + if warning is None: + errors.append( + f"production evidence warning {evidence_id} was not frozen as an operational warning" + ) + continue + if warning.get("evidenceDigest") != evidence_digest: + errors.append( + f"production evidence warning {evidence_id} does not match its frozen evidence digest" + ) + continue + warning_id = str(warning.get("warningId")) + if not any(row.get("id") == warning_id for row in state.warnings): + state.warnings.append( + { + "id": warning_id, + "evidenceId": evidence_id, + "severity": "warning", + "summary": "A policy-allowlisted noncritical operational warning requires explicit acceptance.", + "waivable": True, + } + ) + + for evidence_id in sorted(set(declared_by_evidence) - warned_evidence_ids): + errors.append( + f"candidate operational warning for {evidence_id} has no matching warned evidence row" + ) + return errors + + +def validate_production_evidence( + context: RunContext, + ga: GaRoot, + predecessor: Predecessor, + candidate: Candidate, + policy: dict[str, Any], + state: ValidationState, +) -> tuple[dict[str, Any], str, dict[str, Any] | None]: + """Authenticate candidate-bound normal or narrowly shortened production evidence.""" + + loaded = load_json_input(context, "maintenanceEvidence") + assert loaded + value = loaded.value + errors = validate_schema(value, EVIDENCE_SCHEMA) + release_class = context.manifest.policies.get("releaseClass") + if ( + value.get("releaseId") != context.manifest.release.release_id + or value.get("buildVersion") != context.manifest.release.version + or value.get("releaseClass") != release_class + or value.get("candidateProductDigest") != candidate.product_digest + or value.get("candidateFreezeDigest") != candidate.freeze_digest + or value.get("predecessorBuild") != predecessor.build_version + or value.get("predecessorProductDigest") != predecessor.product_digest + or value.get("productionEvidence") is not True + or value.get("fixtureOnly") is not False + or value.get("simulatedOnly") is not False + or value.get("skipped") is not False + ): + errors.append("production evidence identity or production class is invalid") + rows = value.get("evidenceRows") + rows = rows if isinstance(rows, list) else [] + by_id = _rows_by(rows, "evidenceId") + evidence_digests = [ + row.get("evidenceDigest") for row in rows if isinstance(row, dict) + ] + if len(by_id) != len(rows) or len(evidence_digests) != len(set(evidence_digests)): + errors.append("production evidence contains duplicate ids or evidence digests") + required = set(REQUIRED_PRODUCTION_EVIDENCE) + required.add("stable-maintenance.direct-ga-upgrade") + missing = sorted(required - set(by_id)) + if missing: + errors.append("production evidence is missing required ids: " + ", ".join(missing)) + for evidence_id, row in by_id.items(): + if ( + row.get("status") not in {"pass", "warn"} + or row.get("candidateReleaseId") != context.manifest.release.release_id + or row.get("candidateBuild") != context.manifest.release.version + or row.get("candidateProductDigest") != candidate.product_digest + or row.get("candidateFreezeDigest") != candidate.freeze_digest + or row.get("predecessorBuild") != predecessor.build_version + or row.get("predecessorProductDigest") != predecessor.product_digest + or row.get("production") is not True + or row.get("environmentClass") not in {"production", "protected-production"} + or row.get("fresh") is not True + or row.get("redactionStatus") != "pass" + ): + errors.append(f"production evidence {evidence_id} is failed, stale, or candidate-mismatched") + errors.extend(_ga_evidence_binding_errors(evidence_id, row, ga)) + started = parse_timestamp(row.get("startedAt")) + ended = parse_timestamp(row.get("endedAt")) + if started is None or ended is None or ended < started: + errors.append(f"production evidence {evidence_id} has an invalid time interval") + if evidence_id in { + "stable-maintenance.live-network-interoperability", + "stable-maintenance.performance", + } and ( + row.get("nodeCount", 0) + < policy.get("evidenceWindows", {}).get("minimumNodeCount", 2) + or row.get("operationCount", 0) + < policy.get("evidenceWindows", {}).get("minimumOperationCount", 500) + ): + errors.append(f"production evidence {evidence_id} lacks node or operation counts") + errors.extend(_operational_warning_errors(candidate, by_id, state)) + start = parse_timestamp(value.get("validationStartedAt")) + end = parse_timestamp(value.get("validationEndedAt")) + frozen = parse_timestamp(candidate.frozen_at) + window_policy = policy.get("evidenceWindows", {}) + normal_seconds = window_policy.get("minimumLiveNetworkDurationSeconds", 86400) + shortened_seconds = policy.get("hotfix", {}).get( + "minimumPrepublicationDurationSeconds", 3600 + ) + window = value.get("windowClass") + change_scope = candidate.input_value.get("changeScope") + change_scope = change_scope if isinstance(change_scope, dict) else {} + shortened_ids = set(change_scope.get("shortenedEvidenceIds", [])) + allowed_shortened = set(policy.get("hotfix", {}).get("allowedShortenedScenarios", [])) + follow_up: dict[str, Any] | None = None + if start is None or end is None or end < start or frozen is None or start < frozen: + errors.append("production evidence aggregate interval is invalid") + else: + now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + maximum_age = dt.timedelta( + days=window_policy.get("maximumAgeDays", 14) + ) + if end > now or end < now - maximum_age: + errors.append("production evidence aggregate interval is stale or future-dated") + for evidence_id, row in by_id.items(): + row_start = parse_timestamp(row.get("startedAt")) + row_end = parse_timestamp(row.get("endedAt")) + if ( + row_start is not None + and row_end is not None + and ( + row_start < start + or row_end > end + or frozen is None + or row_start < frozen + ) + ): + errors.append( + f"production evidence {evidence_id} falls outside the post-freeze aggregate window" + ) + duration_seconds = (end - start).total_seconds() + if window == "normal" and duration_seconds < normal_seconds: + errors.append("normal maintenance evidence window is shorter than policy") + if window == "normal" and shortened_ids: + errors.append("normal evidence cannot claim shortened hotfix scenarios") + if window == "normal": + for evidence_id in allowed_shortened: + row = by_id.get(evidence_id, {}) + row_start = parse_timestamp(row.get("startedAt")) + row_end = parse_timestamp(row.get("endedAt")) + if ( + row_start is None + or row_end is None + or (row_end - row_start).total_seconds() < normal_seconds + ): + errors.append( + f"normal evidence {evidence_id} is shorter than the policy window" + ) + if window == "shortened-security-hotfix": + if release_class != "security-hotfix" or duration_seconds < shortened_seconds: + errors.append("shortened evidence window is not an eligible security hotfix window") + if not shortened_ids or not shortened_ids.issubset(allowed_shortened): + errors.append("hotfix shortened evidence ids are empty or outside policy") + for evidence_id in allowed_shortened: + row = by_id.get(evidence_id, {}) + row_start = parse_timestamp(row.get("startedAt")) + row_end = parse_timestamp(row.get("endedAt")) + required_seconds = ( + shortened_seconds if evidence_id in shortened_ids else normal_seconds + ) + if ( + row_start is None + or row_end is None + or (row_end - row_start).total_seconds() < required_seconds + ): + errors.append( + f"hotfix evidence {evidence_id} does not meet its authorized window" + ) + follow_up = _hotfix_follow_up(context, candidate, value, policy, end, state) + elif release_class == "maintenance" and window != "normal": + errors.append("routine maintenance requires the complete normal evidence window") + add_blockers( + state, + "stable-maintenance.production-evidence", + errors, + "Regenerate fresh production evidence for the exact candidate and predecessor.", + ) + return value, loaded.digest, follow_up + + +def _hotfix_follow_up( + context: RunContext, + candidate: Candidate, + evidence: dict[str, Any], + policy: dict[str, Any], + validation_end: dt.datetime, + state: ValidationState, +) -> dict[str, Any]: + scope = candidate.input_value.get("changeScope") + scope = scope if isinstance(scope, dict) else {} + errors: list[str] = [] + if ( + not scope.get("incidentId") + or scope.get("severity") != "critical" + or not scope.get("affectedPackageKeys") + or scope.get("hotfixPolicyAuthorizationDigest") is None + or not scope.get("followUpOwner") + or not scope.get("followUpApprover") + ): + errors.append("security hotfix lacks critical incident, affected scope, authorization, or follow-up ownership") + allowed = set(policy.get("hotfix", {}).get("allowedShortenedScenarios", [])) + requested = set(scope.get("shortenedEvidenceIds", [])) + if not requested or not requested.issubset(allowed): + errors.append("security hotfix shortened scenarios are empty or outside policy") + deadline_hours = policy.get("hotfix", {}).get("followUpDeadlineHours", 168) + deadline = validation_end + dt.timedelta(hours=deadline_hours) + obligation = { + "schemaVersion": 1, + "kind": "stable-1.0-hotfix-follow-up-obligation", + "generatedAt": evidence.get("generatedAt"), + "status": "open", + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": "security-hotfix", + "incidentId": scope.get("incidentId"), + "advisoryId": scope.get("incidentId"), + "severity": "critical", + "productDigest": candidate.product_digest, + "candidateIdentityDigest": candidate.identity_digest, + "candidateFreezeDigest": candidate.freeze_digest, + "candidateFrozenAt": candidate.frozen_at, + "predecessorBuild": evidence.get("predecessorBuild"), + "predecessorProductDigest": evidence.get("predecessorProductDigest"), + "shortenedEvidenceIds": sorted(requested), + "fullEvidenceRequired": sorted(requested), + "deadline": deadline.replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "owner": scope.get("followUpOwner"), + "approver": scope.get("followUpApprover"), + "expectedEvidenceKinds": ["stable-1.0-maintenance-evidence"], + "closureCriteria": [ + "all-required-full-window-production-evidence-pass", + "exact-published-hotfix-product-binding", + "redaction-pass", + ], + "failureBehavior": "open-release-incident-and-block-next-routine-maintenance", + "blocksRoutineMaintenance": True, + "closureEvidenceDigest": None, + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + } + errors.extend(validate_schema(obligation, FOLLOW_UP_SCHEMA)) + add_blockers( + state, + "stable-maintenance.hotfix-follow-up", + errors, + "Provide the closed critical-hotfix authorization and deterministic follow-up obligation.", + ) + return obligation + + +def close_hotfix_follow_up( + context: RunContext, + ga: GaRoot, + candidate: Candidate, + state: ValidationState, +) -> dict[str, Any]: + """Close one published hotfix obligation using full, production-only evidence.""" + + obligation_input = load_json_input(context, "hotfixFollowUpObligation") + evidence_input = load_json_input(context, "hotfixFollowUpEvidence") + policy_input = load_json_input(context, "maintenancePolicy") + assert obligation_input and evidence_input and policy_input + obligation = obligation_input.value + evidence = evidence_input.value + errors = [ + *validate_schema(obligation, FOLLOW_UP_SCHEMA), + *validate_schema(evidence, EVIDENCE_SCHEMA), + ] + obligated_release = obligation.get("releaseId") + obligated_build = obligation.get("buildVersion") + obligated_product = obligation.get("productDigest") + obligated_identity = obligation.get("candidateIdentityDigest") + obligated_freeze = obligation.get("candidateFreezeDigest") + obligated_predecessor_build = obligation.get("predecessorBuild") + obligated_predecessor_product = obligation.get("predecessorProductDigest") + if ( + obligation.get("status") != "open" + or evidence.get("releaseId") != obligated_release + or evidence.get("buildVersion") != obligated_build + or evidence.get("releaseClass") != "security-hotfix" + or evidence.get("windowClass") != "normal" + or evidence.get("candidateProductDigest") != obligated_product + or evidence.get("candidateFreezeDigest") != obligated_freeze + or evidence.get("predecessorBuild") != obligated_predecessor_build + or evidence.get("predecessorProductDigest") != obligated_predecessor_product + or evidence.get("productionEvidence") is not True + or evidence.get("fixtureOnly") is not False + or evidence.get("simulatedOnly") is not False + or evidence.get("skipped") is not False + ): + errors.append("hotfix follow-up obligation or full-window evidence identity is invalid") + rows = _rows_by(evidence.get("evidenceRows"), "evidenceId") + evidence_rows = evidence.get("evidenceRows") + evidence_rows = evidence_rows if isinstance(evidence_rows, list) else [] + required = set(obligation.get("fullEvidenceRequired", [])) + if len(rows) != len(evidence_rows): + errors.append("hotfix follow-up contains duplicate or malformed evidence rows") + for evidence_id, row in rows.items(): + errors.extend(_ga_evidence_binding_errors(evidence_id, row, ga)) + if ( + row.get("predecessorBuild") != obligated_predecessor_build + or row.get("predecessorProductDigest") != obligated_predecessor_product + ): + errors.append( + f"hotfix follow-up evidence {evidence_id} is not predecessor-bound" + ) + if not required: + errors.append("hotfix follow-up does not contain all passing full-window evidence") + started = parse_timestamp(evidence.get("validationStartedAt")) + ended = parse_timestamp(evidence.get("validationEndedAt")) + frozen = parse_timestamp(obligation.get("candidateFrozenAt")) + minimum_seconds = policy_input.value.get("evidenceWindows", {}).get( + "minimumLiveNetworkDurationSeconds", 86400 + ) + maximum_age = dt.timedelta( + days=policy_input.value.get("evidenceWindows", {}).get("maximumAgeDays", 14) + ) + now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + for evidence_id in sorted(required): + row = rows.get(evidence_id, {}) + row_started = parse_timestamp(row.get("startedAt")) + row_ended = parse_timestamp(row.get("endedAt")) + if ( + row.get("status") != "pass" + or row.get("candidateReleaseId") != obligated_release + or row.get("candidateBuild") != obligated_build + or row.get("candidateProductDigest") != obligated_product + or row.get("candidateFreezeDigest") != obligated_freeze + or row.get("predecessorBuild") != obligated_predecessor_build + or row.get("predecessorProductDigest") != obligated_predecessor_product + or row.get("production") is not True + or row.get("fresh") is not True + or row.get("redactionStatus") != "pass" + or row_started is None + or row_ended is None + or row_ended < row_started + or row_started > now + or row_ended > now + or row_ended < now - maximum_age + or (row_ended - row_started).total_seconds() < minimum_seconds + or frozen is None + or row_started < frozen + or (started is not None and row_started < started) + or (ended is not None and row_ended > ended) + ): + errors.append( + f"hotfix follow-up evidence {evidence_id} is not a complete full-window scenario" + ) + if ( + started is None + or ended is None + or frozen is None + or started < frozen + or ended < started + or started > now + or ended > now + or ended < now - maximum_age + or (ended - started).total_seconds() < minimum_seconds + ): + errors.append("hotfix follow-up full production evidence window is incomplete") + add_blockers( + state, + "stable-maintenance.hotfix-follow-up", + errors, + "Complete every obligated full-window scenario with exact-candidate production evidence.", + ) + return { + "schemaVersion": 1, + "kind": "stable-1.0-hotfix-follow-up-closure", + "generatedAt": evidence.get("generatedAt"), + "status": "closed" if not state.blockers else "rejected", + "releaseId": obligated_release, + "buildVersion": obligated_build, + "productDigest": obligated_product, + "candidateIdentityDigest": obligated_identity, + "predecessorBuild": obligated_predecessor_build, + "predecessorProductDigest": obligated_predecessor_product, + "obligationDigest": obligation_input.digest, + "fullEvidenceDigest": evidence_input.digest, + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + } diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_core.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_core.py new file mode 100644 index 00000000000..c0b62b1be7e --- /dev/null +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_maintenance_core.py @@ -0,0 +1,2410 @@ +"""Authentication, identity, archive, and updater helpers for Stable 1.0 maintenance.""" + +from __future__ import annotations + +import dataclasses +import datetime as dt +import io +import json +import re +import stat +import struct +import tarfile +import zipfile +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +from cryptad_certification.io import read_json +from cryptad_certification.models import RunContext +from cryptad_certification.redaction import scan_value +from cryptad_certification.schema_validation import validate_schema + +from .stable_1_0_ga_core import ( + _has_unambiguous_publication_path, + is_public_https_uri, + public_audit_redaction_findings, +) +from .stable_1_0_rc_core import ( + BUILD_VERSION_RE, + COMMIT_RE, + DIGEST_RE, + ValidationState, + _configured_path, + file_digest, + parse_timestamp, + placeholder_findings, + semantic_digest, +) + +SCHEMA_VERSION = 1 +TOOL_NAME = "stable-1.0-maintenance" +TOOL_VERSION = 1 +STABLE_MILESTONE = "1.0" +COMPONENT = "stable-maintenance" +RELEASE_CLASSES = ("maintenance", "security-hotfix") +COMMAND_MODES = ( + "validate-only", + "prepare-authorization", + "close-hotfix-follow-up", +) +DECISIONS = ("go", "no-go", "go-with-waivers") +MAX_NESTED_ARCHIVE_BYTES = 256 * 1024 * 1024 + +SUMMARY_FILE = "stable-1.0-maintenance-promotion-summary.json" +REPORT_FILE = "stable-1.0-maintenance-go-no-go.md" +LINEAGE_FILE = "stable-1.0-maintenance-lineage.json" +CANDIDATE_FILE = "stable-1.0-maintenance-candidate.json" +CANDIDATE_FREEZE_FILE = "stable-1.0-maintenance-candidate-freeze.json" +COMPARISON_FILE = "stable-1.0-maintenance-comparison.json" +VALIDATION_FILE = "stable-1.0-maintenance-validation.json" +AUTHORIZATION_FILE = "stable-1.0-maintenance-authorization-summary.json" +KNOWN_LIMITATIONS_FILE = "stable-1.0-maintenance-known-limitations.json" +RELEASE_NOTES_FILE = "stable-1.0-maintenance-release-notes.md" +PUBLICATION_PLAN_FILE = "stable-1.0-maintenance-publication-plan.json" +PUBLICATION_RECEIPT_FILE = "stable-1.0-maintenance-publication-receipt.json" +CHECKSUMS_FILE = "stable-1.0-maintenance-checksums.txt" +AUDIT_CHECKSUMS_FILE = "stable-1.0-maintenance-audit-checksums.txt" +PROVENANCE_FILE = "stable-1.0-maintenance-provenance.json" +SUCCESSOR_BASELINE_FILE = "stable-1.0-maintenance-successor-baseline.json" +HISTORY_FILE = "stable-1.0-maintenance-history-entry.json" +LATEST_POINTER_FILE = "stable-1.0-maintenance-latest-published.json" +FOLLOW_UP_FILE = "stable-1.0-hotfix-follow-up-obligation.json" +FOLLOW_UP_CLOSURE_FILE = "stable-1.0-hotfix-follow-up-closure.json" +CORE_INFO_FILE = "core-info.json" +CORE_PLAN_FILE = "core-update-publication-plan.json" +CORE_RECEIPT_FILE = "core-update-publication-receipt.json" +REDACTION_REPORT_FILE = "redaction-report.json" + +GA_BASELINE_SCHEMA = "stable-1.0-maintenance-baseline-v1.schema.json" +GA_PROMOTION_SCHEMA = "stable-1.0-ga-promotion-v1.schema.json" +GA_VALIDATION_SCHEMA = "stable-1.0-ga-validation-v1.schema.json" +GA_AUTHORIZATION_SCHEMA = "stable-1.0-ga-authorization-v1.schema.json" +GA_PUBLICATION_PLAN_SCHEMA = "stable-1.0-ga-publication-plan-v1.schema.json" +GA_PUBLICATION_RECEIPT_SCHEMA = "stable-1.0-ga-publication-receipt-v1.schema.json" +CANDIDATE_INPUT_SCHEMA = "stable-1.0-maintenance-candidate-input-v1.schema.json" +CANDIDATE_FREEZE_SCHEMA = "stable-1.0-maintenance-candidate-freeze-v1.schema.json" +EVIDENCE_SCHEMA = "stable-1.0-maintenance-evidence-v1.schema.json" +LINEAGE_SCHEMA = "stable-1.0-maintenance-lineage-v1.schema.json" +COMPARISON_SCHEMA = "stable-1.0-maintenance-comparison-v1.schema.json" +VALIDATION_SCHEMA = "stable-1.0-maintenance-validation-v1.schema.json" +AUTHORIZATION_SCHEMA = "stable-1.0-maintenance-authorization-v1.schema.json" +PUBLICATION_PLAN_SCHEMA = "stable-1.0-maintenance-publication-plan-v1.schema.json" +PUBLICATION_RECEIPT_SCHEMA = "stable-1.0-maintenance-publication-receipt-v1.schema.json" +PUBLICATION_FAILURE_AUDIT_SCHEMA = ( + "stable-1.0-maintenance-publication-failure-audit-v1.schema.json" +) +SUCCESSOR_SCHEMA = "stable-1.0-maintenance-successor-baseline-v2.schema.json" +FOLLOW_UP_SCHEMA = "stable-1.0-hotfix-follow-up-obligation-v1.schema.json" +FOLLOW_UP_CLOSURE_SCHEMA = "stable-1.0-hotfix-follow-up-closure-v1.schema.json" +CORE_INFO_SCHEMA = "cryptad-core-info-v1.schema.json" +CORE_PLAN_SCHEMA = "cryptad-core-update-publication-plan-v1.schema.json" +CORE_RECEIPT_SCHEMA = "cryptad-core-update-publication-receipt-v1.schema.json" + +AUTHORIZATION_SCOPE = ( + "tag:create-or-verify", + "github-release:create-or-verify", + "artifact-base:publish-or-verify", + "stable-catalog:publish-or-verify", + "core-update:insert-or-verify", + "successor-baseline:activate", + "release-history:append", +) +GA_PUBLIC_ASSET_NAMES = { + "stable-1.0-ga-release-notes.md", + "stable-1.0-ga-known-limitations.json", + "stable-1.0-ga-provenance.json", + "stable-1.0-maintenance-baseline.json", + "stable-1.0-ga-checksums.txt", +} +REQUIRED_PRODUCTION_EVIDENCE = ( + "stable-maintenance.installation-packaging", + "stable-maintenance.upgrade-rollback-migration-backup", + "stable-maintenance.live-network-interoperability", + "stable-maintenance.performance", + "stable-maintenance.sandbox", + "stable-maintenance.security", + "stable-maintenance.support-redaction", +) +ALLOWED_OPERATIONAL_WARNING_IDS = frozenset( + { + "stable-maintenance.catalog-mirror-latency-warning", + "stable-maintenance.performance-comparable-runner-warning", + } +) +OPERATIONAL_WARNING_EVIDENCE_IDS = { + "stable-maintenance.catalog-mirror-latency-warning": ( + "stable-maintenance.catalog-app-compatibility" + ), + "stable-maintenance.performance-comparable-runner-warning": ( + "stable-maintenance.performance" + ), +} + + +@dataclasses.dataclass(frozen=True) +class LoadedJson: + """One exact, strict, public-safe JSON input.""" + + key: str + path: Path + value: dict[str, Any] + digest: str + + +@dataclasses.dataclass(frozen=True) +class GaRoot: + """Authenticated immutable Stable 1.0 GA chain root.""" + + baseline: dict[str, Any] + baseline_digest: str + receipt: dict[str, Any] + receipt_digest: str + release_id: str + build_version: str + source_commit: str + product_digest: str + tag: str + root_identity_digest: str + + +@dataclasses.dataclass(frozen=True) +class Predecessor: + """Authenticated immediate predecessor and chain state.""" + + baseline: dict[str, Any] + baseline_digest: str + receipt: dict[str, Any] + receipt_digest: str + release_id: str + build_version: str + source_commit: str + product_digest: str + tag: str + chain_depth: int + previous_lineage_digest: str + lineage_history: list[dict[str, Any]] + outstanding_follow_up: dict[str, Any] | None + latest_pointer_digest: str | None = None + follow_up_closure_digest: str | None = None + + +@dataclasses.dataclass(frozen=True) +class Candidate: + """Authenticated one-time maintenance candidate and its exact assets.""" + + source: dict[str, Any] + input_value: dict[str, Any] + input_digest: str + freeze_digest: str + frozen_at: str + product_path: Path + product_digest: str + assets: list[dict[str, Any]] + asset_paths: dict[str, Path] + checksums_digest: str + provenance_digest: str + identity: dict[str, Any] + identity_digest: str + + +def add_blockers( + state: ValidationState, + issue_id: str, + errors: list[str], + remediation: str, +) -> None: + """Append deduplicated, non-waivable blockers for one evidence identity.""" + + existing = { + (row.get("id"), row.get("summary")) + for row in state.blockers + if isinstance(row, dict) + } + for error in errors: + summary = error.rstrip(".") + "." + if (issue_id, summary) in existing: + continue + state.block(issue_id, issue_id, summary, remediation) + existing.add((issue_id, summary)) + + +def _regular_file(path: Path) -> bool: + try: + mode = path.stat(follow_symlinks=False).st_mode + except OSError: + return False + return stat.S_ISREG(mode) and not path.is_symlink() + + +def configured_path( + context: RunContext, + key: str, + *, + required: bool = True, + directory: bool = False, +) -> Path | None: + """Resolve one workspace-confined input and reject every symlinked component.""" + + raw = context.manifest.inputs.get(key) + if isinstance(raw, str) and Path(raw).is_absolute(): + raise ValueError(f"Stable maintenance input path must be workspace-relative: {key}") + path = _configured_path(context, key) + if path is None: + if required: + raise ValueError(f"required Stable maintenance input is missing: {key}") + return None + if directory: + if path.is_symlink() or not path.is_dir(): + raise ValueError(f"Stable maintenance input directory is missing or unsafe: {key}") + elif not _regular_file(path): + raise ValueError(f"Stable maintenance input is missing or unsafe: {key}") + return path + + +def load_json_input( + context: RunContext, + key: str, + *, + required: bool = True, + public_authorization: bool = False, +) -> LoadedJson | None: + """Load a duplicate-safe, placeholder-free, public-safe JSON input.""" + + path = configured_path(context, key, required=required) + if path is None: + return None + value = read_json(path) + if not isinstance(value, dict): + raise ValueError(f"Stable maintenance input must be a JSON object: {key}") + findings = _maintenance_public_redaction_findings(value) + if findings or placeholder_findings(value): + raise ValueError( + f"Stable maintenance input is redaction-unsafe or contains placeholders: {key}" + ) + return LoadedJson(key, path, value, file_digest(path)) + + +def _maintenance_public_redaction_view(value: Any) -> Any: + """Rename only closed public approval labels while retaining recursive value scans.""" + + if isinstance(value, dict): + safe_labels = { + "authorization": "publicApprovalPolicy", + "authorizationDigest": "publicApprovalDigest", + "authorizationId": "publicApprovalId", + "authorizationRequestDigest": "publicApprovalRequestDigest", + "authorizedAt": "publicApprovalTime", + "allowedPublicationScopes": "allowedPublicOperationScopes", + "approverIdentity": "publicApproverLabel", + "hotfixPolicyAuthorizationDigest": "hotfixPublicApprovalDigest", + } + return { + safe_labels.get(str(key), str(key)): _maintenance_public_redaction_view(child) + for key, child in value.items() + } + if isinstance(value, list): + return [_maintenance_public_redaction_view(child) for child in value] + return value + + +def _maintenance_public_redaction_findings(value: Any) -> list[dict[str, str]]: + return public_audit_redaction_findings(_maintenance_public_redaction_view(value)) + + +def canonical_policy(context: RunContext, supplied: LoadedJson) -> list[str]: + """Require the exact checked-in authoritative maintenance policy bytes.""" + + canonical = ( + context.workspace_root + / "tools" + / "release-certification" + / "stable-1.0-maintenance-policy.json" + ) + errors: list[str] = [] + if read_json(canonical) != supplied.value or file_digest(canonical) != supplied.digest: + errors.append( + "Stable maintenance policy is not the exact checked-in authoritative policy" + ) + if ( + supplied.value.get("schemaVersion") != 1 + or supplied.value.get("kind") != "stable-1.0-maintenance-policy" + or supplied.value.get("stableMilestone") != STABLE_MILESTONE + or supplied.value.get("component") != COMPONENT + or supplied.value.get("profile") != "stable-review" + or tuple(supplied.value.get("releaseClasses", [])) != RELEASE_CLASSES + ): + errors.append("Stable maintenance policy identity is invalid") + return errors + + +def _checksum_rows(path: Path) -> tuple[dict[str, str], list[str]]: + """Parse one deterministic single-basename SHA-256 checksum file.""" + + values: dict[str, str] = {} + errors: list[str] = [] + try: + rows = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return {}, ["checksum file is not readable UTF-8"] + for number, row in enumerate(rows, start=1): + digest, separator, name = row.partition(" ") + relative = Path(name) + if ( + not separator + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + or not name + or name in values + or relative.is_absolute() + or len(relative.parts) != 1 + or ".." in relative.parts + ): + errors.append(f"checksum line {number} is malformed") + continue + values[name] = "sha256:" + digest + canonical = [ + f"{digest.removeprefix('sha256:')} {name}" + for name, digest in sorted(values.items()) + ] + if rows != canonical: + errors.append("checksum rows are not in canonical deterministic order") + return values, errors + + +def authenticate_ga_root(context: RunContext, state: ValidationState) -> GaRoot: + """Authenticate PR-284 GA publication and the immutable v1 baseline as one graph.""" + + promotion = load_json_input(context, "stableGaPromotionSummary") + validation = load_json_input(context, "stableGaValidation") + authorization = load_json_input( + context, "stableGaAuthorizationSummary", public_authorization=True + ) + plan = load_json_input(context, "stableGaPublicationPlan") + receipt = load_json_input(context, "stableGaPublicationReceipt") + provenance = load_json_input(context, "stableGaProvenance") + baseline = load_json_input(context, "stableGaMaintenanceBaseline") + checksums_path = configured_path(context, "stableGaChecksums") + assert all( + item is not None + for item in ( + promotion, + validation, + authorization, + plan, + receipt, + provenance, + baseline, + checksums_path, + ) + ) + assert promotion and validation and authorization and plan and receipt and provenance and baseline + assert checksums_path is not None + errors: list[str] = [] + for value, schema, label in ( + (promotion.value, GA_PROMOTION_SCHEMA, "promotion summary"), + (validation.value, GA_VALIDATION_SCHEMA, "validation"), + (authorization.value, GA_AUTHORIZATION_SCHEMA, "authorization"), + (plan.value, GA_PUBLICATION_PLAN_SCHEMA, "publication plan"), + (receipt.value, GA_PUBLICATION_RECEIPT_SCHEMA, "publication receipt"), + (baseline.value, GA_BASELINE_SCHEMA, "maintenance baseline"), + ): + errors.extend(f"Stable GA {label}: {item}" for item in validate_schema(value, schema)) + base_release = baseline.value.get("release") + base_release = base_release if isinstance(base_release, dict) else {} + release_id = str(base_release.get("releaseId", "")) + build_version = str(base_release.get("buildVersion", "")) + source_commit = str(base_release.get("sourceCommit", "")) + product_digest = str(base_release.get("rcProductDigest", "")) + tag = str(base_release.get("tag", "")) + if ( + baseline.value.get("status") != "prepared" + or baseline.value.get("stableMilestone") != STABLE_MILESTONE + or BUILD_VERSION_RE.fullmatch(build_version) is None + or COMMIT_RE.fullmatch(source_commit) is None + or DIGEST_RE.fullmatch(product_digest) is None + or tag != f"v{build_version}" + ): + errors.append("Stable GA maintenance baseline release identity is invalid") + promotion_value = promotion.value + promotion_selected = promotion_value.get("selectedRc") + promotion_selected = promotion_selected if isinstance(promotion_selected, dict) else {} + promotion_payload = promotion_value.get("payloadIdentity") + promotion_payload = promotion_payload if isinstance(promotion_payload, dict) else {} + promotion_catalog = promotion_value.get("stableCatalog") + promotion_catalog = promotion_catalog if isinstance(promotion_catalog, dict) else {} + if ( + promotion_value.get("status") != "pass" + or promotion_value.get("promotionReady") is not True + or promotion_value.get("nonRelease") is not False + or promotion_value.get("publicationState") != "publication-complete" + or promotion_value.get("releaseId") != release_id + or promotion_value.get("buildVersion") != build_version + or promotion_value.get("sourceCommit") != source_commit + or promotion_value.get("expectedTag") != tag + or promotion_value.get("expectedReleaseBranch") != f"release/{build_version}" + or promotion_value.get("gaValidationDigest") != validation.digest + or promotion_selected.get("productDistributionDigest") != product_digest + or promotion_payload.get("rcProductDigest") != product_digest + or promotion_payload.get("gaProductDigest") != product_digest + or promotion_payload.get("bitIdentical") is not True + or promotion_payload.get("rebuildPerformed") is not False + ): + errors.append("Stable GA promotion is not a completed matching GA result") + validation_value = validation.value + validation_selected = validation_value.get("selectedRc") + validation_selected = validation_selected if isinstance(validation_selected, dict) else {} + validation_payload = validation_value.get("payloadIdentity") + validation_payload = validation_payload if isinstance(validation_payload, dict) else {} + validation_authorization = validation_value.get("authorization") + validation_authorization = ( + validation_authorization if isinstance(validation_authorization, dict) else {} + ) + if ( + validation_value.get("releaseId") != release_id + or validation_value.get("buildVersion") != build_version + or validation_value.get("profile") != "stable-review" + or validation_value.get("component") != "stable-ga" + or validation_value.get("sourceCommit") != source_commit + or validation_value.get("status") != "pass" + or validation_value.get("promotionReady") is not True + or validation_value.get("nonRelease") is not False + or validation_value.get("decision") not in {"go", "go-with-waivers"} + or validation_selected.get("productDistributionDigest") != product_digest + or validation_payload.get("rcProductDigest") != product_digest + or validation_payload.get("gaProductDigest") != product_digest + or validation_payload.get("bitIdentical") is not True + or validation_payload.get("rebuildPerformed") is not False + or validation_authorization.get("status") != "authorized" + or validation_authorization.get("authorizationId") + != authorization.value.get("authorizationId") + or promotion_value.get("gaAuthorizationDigest") + != validation_authorization.get("authorizationDigest") + or validation_authorization.get("publicationTargetsDigest") + != authorization.value.get("publicationTargetsDigest") + or validation_authorization.get("allowedPublicationScope") + != authorization.value.get("allowedPublicationScope") + ): + errors.append("Stable GA validation does not bind the exact authorized GA result") + authorization_value = authorization.value + if ( + authorization_value.get("releaseId") != release_id + or authorization_value.get("buildVersion") != build_version + or authorization_value.get("sourceCommit") != source_commit + or authorization_value.get("productDistributionDigest") != product_digest + or authorization_value.get("freezeDigest") + != promotion_selected.get("freezeDigest") + or authorization_value.get("archiveDigest") + != promotion_selected.get("archiveDigest") + or authorization_value.get("catalogDigest") + != promotion_catalog.get("catalogDigest") + or authorization_value.get("catalogRevision") + != promotion_catalog.get("revision") + or authorization_value.get("publicationTargetsDigest") + != promotion_value.get("publicationTargetsDigest") + or authorization_value.get("status") != "authorized" + or authorization_value.get("authorizationRole") != "stable-release-manager" + or authorization_value.get("allowedPublicationScope") + != [ + "git-tag", + "github-release", + "release-assets", + "stable-catalog-confirmation", + "post-publication-verification", + ] + ): + errors.append("Stable GA authorization does not bind the exact promotion identity") + plan_value = plan.value + plan_catalog = plan_value.get("catalog") + plan_catalog = plan_catalog if isinstance(plan_catalog, dict) else {} + if ( + plan_value.get("releaseId") != release_id + or plan_value.get("buildVersion") != build_version + or plan_value.get("sourceCommit") != source_commit + or plan_value.get("expectedTag") != tag + or plan_value.get("expectedReleaseBranch") != f"release/{build_version}" + or plan_value.get("publicationState") != "publication-authorized" + or plan_value.get("sideEffectsPerformed") is not False + or plan_value.get("publicationTargetsDigest") + != promotion_value.get("publicationTargetsDigest") + or plan_value.get("promotionIdentityDigest") + != base_release.get("gaPromotionDigest") + or plan_value.get("releaseNotesDigest") + != promotion_value.get("releaseNotesDigest") + or plan_catalog.get("catalogDigest") != promotion_catalog.get("catalogDigest") + or plan_catalog.get("revision") != promotion_catalog.get("revision") + or plan_catalog.get("signatureDigest") + != promotion_catalog.get("signatureDigest") + or plan_catalog.get("signingKeyId") != promotion_catalog.get("signingKeyId") + ): + errors.append("Stable GA publication plan is not the exact authorized promotion plan") + receipt_value = receipt.value + tag_value = receipt_value.get("tag") + tag_value = tag_value if isinstance(tag_value, dict) else {} + observations = receipt_value.get("publicStateObservation") + observations = observations if isinstance(observations, dict) else {} + assets_observation = observations.get("releaseAssets") + assets_observation = assets_observation if isinstance(assets_observation, dict) else {} + if ( + receipt_value.get("publicationState") != "publication-complete" + or receipt_value.get("operation") not in {"created", "verified-existing"} + or receipt_value.get("finalVerificationStatus") != "pass" + or receipt_value.get("releaseId") != release_id + or receipt_value.get("buildVersion") != build_version + or receipt_value.get("sourceCommit") != source_commit + or receipt_value.get("productDistributionDigest") != product_digest + or receipt_value.get("freezeDigest") != promotion_selected.get("freezeDigest") + or receipt_value.get("archiveDigest") != promotion_selected.get("archiveDigest") + or receipt_value.get("gaPromotionSummaryDigest") + != base_release.get("gaPromotionDigest") + or receipt_value.get("releaseNotesDigest") != plan_value.get("releaseNotesDigest") + or receipt_value.get("artifactBaseUri") != plan_value.get("artifactBaseUri") + or tag_value.get("name") != tag + or tag_value.get("targetCommit") != source_commit + or tag_value.get("annotated") is not True + or tag_value.get("verificationStatus") != "pass" + or observations.get("tag", {}).get("status") != "verified" + or observations.get("githubRelease", {}).get("status") != "verified" + or assets_observation.get("status") != "verified" + or assets_observation.get("missingPlannedAssets") != [] + or assets_observation.get("unexpectedCount") != 0 + ): + errors.append("Stable GA publication receipt does not prove complete exact public state") + receipt_catalog = receipt_value.get("catalog") + receipt_catalog = receipt_catalog if isinstance(receipt_catalog, dict) else {} + if ( + receipt_catalog.get("catalogId") != plan_catalog.get("catalogId") + or receipt_catalog.get("channel") != "stable" + or receipt_catalog.get("revision") != plan_catalog.get("revision") + or receipt_catalog.get("catalogDigest") != plan_catalog.get("catalogDigest") + or receipt_catalog.get("signatureDigest") != plan_catalog.get("signatureDigest") + or receipt_catalog.get("signingKeyId") != plan_catalog.get("signingKeyId") + or receipt_catalog.get("verificationStatus") != "pass" + ): + errors.append("Stable GA publication receipt catalog is not the planned stable catalog") + baseline_digest = baseline.digest + if promotion_value.get("maintenanceBaselineDigest") != baseline_digest: + errors.append("Stable GA promotion does not bind the exact maintenance baseline bytes") + plan_assets = plan.value.get("assets") + receipt_assets = receipt_value.get("assets") + promotion_assets = promotion_value.get("plannedPublicArtifacts") + if not all(isinstance(item, list) for item in (plan_assets, receipt_assets, promotion_assets)): + errors.append("Stable GA public asset records are malformed") + plan_assets = [] + receipt_assets = [] + promotion_assets = [] + if any( + not isinstance(row, dict) or row.get("verificationStatus") != "pass" + for row in receipt_assets + ): + errors.append("Stable GA publication receipt contains an unverified release asset") + + def asset_map(rows: list[Any]) -> dict[str, tuple[Any, Any]]: + mapped: dict[str, tuple[Any, Any]] = {} + for row in rows: + if not isinstance(row, dict) or not isinstance(row.get("name"), str): + continue + name = row["name"] + if name in mapped: + errors.append(f"Stable GA public asset set contains duplicate {name}") + mapped[name] = (row.get("sizeBytes"), row.get("digest")) + return mapped + planned = asset_map(plan_assets) + observed = asset_map(receipt_assets) + promoted = asset_map(promotion_assets) + if planned != observed or planned != promoted: + errors.append("Stable GA plan, promotion, and receipt asset sets differ") + if planned.get("stable-1.0-maintenance-baseline.json", (None, None))[1] != baseline_digest: + errors.append("Stable GA public asset set omits the exact maintenance baseline") + checksums, checksum_errors = _checksum_rows(checksums_path) + errors.extend(f"Stable GA {item}" for item in checksum_errors) + if checksums.get("stable-1.0-maintenance-baseline.json") != baseline_digest: + errors.append("Stable GA checksums do not bind the exact maintenance baseline") + if set(checksums).union({"stable-1.0-ga-checksums.txt"}) != set(planned): + errors.append("Stable GA checksum and public asset allowlists differ") + provenance_release = provenance.value.get("releaseId") + provenance_source = provenance.value.get("source") + provenance_source = provenance_source if isinstance(provenance_source, dict) else {} + payload = provenance.value.get("payloadIdentity") + payload = payload if isinstance(payload, dict) else {} + provenance_ga = provenance.value.get("ga") + provenance_ga = provenance_ga if isinstance(provenance_ga, dict) else {} + provenance_catalog = provenance.value.get("catalogIdentity") + provenance_catalog = provenance_catalog if isinstance(provenance_catalog, dict) else {} + if ( + provenance.value.get("kind") != "stable-1.0-ga-provenance" + or provenance_release != release_id + or provenance.value.get("buildVersion") != build_version + or provenance_source.get("commit") != source_commit + or payload.get("gaProductDigest") != product_digest + or payload.get("rcProductDigest") != product_digest + or payload.get("bitIdentical") is not True + or payload.get("rebuildPerformed") is not False + or provenance_ga.get("validationFileDigest") != validation.digest + or provenance_ga.get("authorizationDigest") + != promotion_value.get("gaAuthorizationDigest") + or provenance_ga.get("validationAuthorizationIdentityDigest") + != authorization_value.get("gaValidationDigest") + or provenance_ga.get("promotionIdentityDigest") + != base_release.get("gaPromotionDigest") + or provenance_catalog.get("catalogId") != promotion_catalog.get("catalogId") + or provenance_catalog.get("revision") != promotion_catalog.get("revision") + or provenance_catalog.get("catalogDigest") + != promotion_catalog.get("catalogDigest") + or provenance_catalog.get("signatureDigest") + != promotion_catalog.get("signatureDigest") + ): + errors.append("Stable GA provenance does not bind the baseline product identity") + for value, label in ( + (promotion.value, "promotion"), + (validation.value, "validation"), + (authorization.value, "authorization"), + (plan.value, "publication plan"), + (receipt.value, "publication receipt"), + (provenance.value, "provenance"), + (baseline.value, "maintenance baseline"), + ): + redaction = value.get("redaction") if isinstance(value, dict) else None + if placeholder_findings(value) or redaction != { + "status": "pass", + "findingCount": 0, + "findings": [], + }: + errors.append(f"Stable GA {label} contains placeholders or redaction findings") + add_blockers( + state, + "stable-maintenance.ga-baseline-authentication", + errors, + "Restore the exact verified PR-284 publication artifact graph.", + ) + root_identity = { + "schemaVersion": 1, + "kind": "stable-1.0-ga-maintenance-root-identity", + "releaseId": release_id, + "buildVersion": build_version, + "sourceCommit": source_commit, + "tag": tag, + "productDigest": product_digest, + "baselineDigest": baseline_digest, + "publicationReceiptDigest": receipt.digest, + "gaPromotionIdentityDigest": base_release.get("gaPromotionDigest"), + } + return GaRoot( + baseline.value, + baseline_digest, + receipt.value, + receipt.digest, + release_id, + build_version, + source_commit, + product_digest, + tag, + semantic_digest(root_identity), + ) + + +def _receipt_identity(receipt: dict[str, Any]) -> str: + """Return the non-circular semantic identity of one maintenance receipt.""" + + fields = { + key: receipt.get(key) + for key in ( + "schemaVersion", + "kind", + "releaseId", + "buildVersion", + "releaseClass", + "sourceCommit", + "githubReleasePageUri", + "deploymentServicePublicUri", + "latestPointerPublicUri", + "publicationState", + "candidateIdentityDigest", + "productDigest", + "checksumsDigest", + "provenanceDigest", + "authorizationDigest", + "publicationPlanDigest", + "releaseNotesDigest", + "coreInfoDigest", + "coreUpdateReceiptDigest", + "tag", + "githubRelease", + "assets", + "stableCatalog", + "coreUpdate", + "workflow", + "publicObservations", + "finalVerificationStatus", + "failureCategory", + "redaction", + ) + } + return semantic_digest(fields) + + +def successor_baseline_identity(baseline: dict[str, Any]) -> str: + """Return a non-circular identity for one v2 successor baseline.""" + + return semantic_digest( + { + key: baseline.get(key) + for key in ( + "schemaVersion", + "kind", + "generatedAt", + "stableMilestone", + "status", + "gaRoot", + "previousBaselineDigest", + "chainDepth", + "previousLineageDigest", + "publication", + "release", + "platformApi", + "stableCatalog", + "firstPartyApps", + "contentFormatProfiles", + "limitations", + "security", + "support", + "legacyBoundaries", + "evidenceWindowPolicy", + "hotfixFollowUp", + "releaseHistoryDigest", + "redaction", + ) + } + ) + + +def authenticate_predecessor( + context: RunContext, + ga: GaRoot, + state: ValidationState, + *, + allow_non_successor_build: bool = False, +) -> Predecessor: + """Authenticate GA or the latest activated v2 successor as immediate predecessor.""" + + baseline = load_json_input(context, "predecessorBaseline") + receipt = load_json_input(context, "predecessorPublicationReceipt") + assert baseline and receipt + errors: list[str] = [] + if baseline.value.get("schemaVersion") == 1: + pointer = load_json_input( + context, "latestPublishedMaintenancePointer", required=False + ) + if pointer is not None: + errors.append( + "GA predecessor forbids a latest-maintenance pointer that is not part of the authenticated GA root" + ) + if baseline.digest != ga.baseline_digest or receipt.digest != ga.receipt_digest: + errors.append("first maintenance predecessor is not the exact authenticated GA root") + release_id = ga.release_id + build_version = ga.build_version + source_commit = ga.source_commit + product_digest = ga.product_digest + tag = ga.tag + chain_depth = 0 + previous_lineage_digest = ga.root_identity_digest + history = [ + { + "chainDepth": 0, + "releaseId": ga.release_id, + "buildVersion": ga.build_version, + "tag": ga.tag, + "sourceCommit": ga.source_commit, + "releaseClass": "stable-ga", + "productDigest": ga.product_digest, + "baselineIdentityDigest": ga.baseline_digest, + "publicationReceiptIdentityDigest": ga.receipt_digest, + "previousLineageDigest": ga.root_identity_digest, + } + ] + outstanding = None + pointer_digest: str | None = None + closure_digest: str | None = None + else: + errors.extend(validate_schema(baseline.value, SUCCESSOR_SCHEMA)) + errors.extend(validate_schema(receipt.value, PUBLICATION_RECEIPT_SCHEMA)) + release = baseline.value.get("release") + release = release if isinstance(release, dict) else {} + lineage = baseline.value.get("lineage") + lineage = lineage if isinstance(lineage, dict) else {} + publication = baseline.value.get("publication") + publication = publication if isinstance(publication, dict) else {} + release_id = str(release.get("releaseId", "")) + build_version = str(release.get("buildVersion", "")) + source_commit = str(release.get("sourceCommit", "")) + product_digest = str(release.get("productDigest", "")) + tag = str(release.get("tag", "")) + chain_depth = lineage.get("chainDepth") if type(lineage.get("chainDepth")) is int else -1 + previous_lineage_digest = str(lineage.get("lineageDigest", "")) + history_value = lineage.get("history") + history = list(history_value) if isinstance(history_value, list) else [] + outstanding_value = baseline.value.get("hotfixFollowUp") + outstanding = outstanding_value if isinstance(outstanding_value, dict) else None + if outstanding and outstanding.get("status") in {"open", "overdue"}: + required_obligation_bindings = ( + "obligatedReleaseId", + "obligatedBuildVersion", + "obligatedProductDigest", + "obligatedCandidateIdentityDigest", + "obligatedCandidateFreezeDigest", + "obligatedCandidateFrozenAt", + "obligatedPredecessorBuild", + "obligatedPredecessorProductDigest", + "authorizationDigest", + ) + if any(not outstanding.get(key) for key in required_obligation_bindings): + errors.append( + "unresolved predecessor hotfix follow-up omits original release identity bindings" + ) + if ( + baseline.value.get("kind") != "stable-1.0-maintenance-successor-baseline" + or baseline.value.get("stableMilestone") != STABLE_MILESTONE + or baseline.value.get("status") != "published" + or lineage.get("gaBaselineDigest") != ga.baseline_digest + or lineage.get("gaPublicationReceiptDigest") != ga.receipt_digest + or receipt.value.get("publicationState") != "publication-complete" + or receipt.value.get("finalVerificationStatus") != "pass" + or receipt.value.get("releaseId") != release_id + or receipt.value.get("buildVersion") != build_version + or receipt.value.get("sourceCommit") != source_commit + or receipt.value.get("productDigest") != product_digest + or receipt.value.get("successorBaselineDigest") != baseline.digest + or publication.get("receiptIdentityDigest") != _receipt_identity(receipt.value) + ): + errors.append("predecessor successor baseline and public receipt do not authenticate") + pointer = load_json_input(context, "latestPublishedMaintenancePointer", required=False) + pointer_digest = pointer.digest if pointer is not None else None + closure_digest = None + if pointer is None: + errors.append("later predecessor requires the protected latest-published pointer") + elif ( + pointer.value.get("kind") != "stable-1.0-maintenance-latest-published" + or pointer.value.get("releaseId") != release_id + or pointer.value.get("buildVersion") != build_version + or pointer.value.get("baselineDigest") != baseline.digest + or pointer.value.get("publicationReceiptDigest") != receipt.digest + or pointer.value.get("lineageDigest") != previous_lineage_digest + or pointer.value.get("status") != "active" + ): + errors.append("latest-published pointer does not select the predecessor exactly") + if len(history) != chain_depth + 1: + errors.append("predecessor lineage history depth is incomplete") + last_build = 0 + seen: set[str] = set() + for index, row in enumerate(history): + if not isinstance(row, dict): + errors.append("predecessor lineage history contains a malformed row") + continue + row_build = str(row.get("buildVersion", "")) + row_baseline = str(row.get("baselineIdentityDigest", "")) + expected_previous_lineage = ( + ga.root_identity_digest + if index <= 1 + else semantic_digest(history[:index]) + ) + if ( + row.get("chainDepth") != index + or BUILD_VERSION_RE.fullmatch(row_build) is None + or int(row_build) <= last_build + or DIGEST_RE.fullmatch(row_baseline) is None + or row_baseline in seen + or row.get("tag") != f"v{row_build}" + or COMMIT_RE.fullmatch(str(row.get("sourceCommit", ""))) is None + or DIGEST_RE.fullmatch(str(row.get("productDigest", ""))) is None + or DIGEST_RE.fullmatch( + str(row.get("publicationReceiptIdentityDigest", "")) + ) + is None + or row.get("previousLineageDigest") != expected_previous_lineage + or ( + index == 0 + and ( + row.get("releaseId") != ga.release_id + or row_build != ga.build_version + or row.get("tag") != ga.tag + or row.get("sourceCommit") != ga.source_commit + or row.get("releaseClass") != "stable-ga" + or row.get("productDigest") != ga.product_digest + or row_baseline != ga.baseline_digest + or row.get("publicationReceiptIdentityDigest") + != ga.receipt_digest + ) + ) + or (index > 0 and row.get("releaseClass") not in RELEASE_CLASSES) + ): + errors.append("predecessor lineage contains a gap, fork, or non-monotonic build") + last_build = int(row_build) if row_build.isdigit() else last_build + seen.add(row_baseline) + if not history or history[0].get("baselineIdentityDigest") != ga.baseline_digest: + errors.append("predecessor lineage is not rooted in the authenticated GA baseline") + if history and history[-1].get( + "baselineIdentityDigest" + ) != successor_baseline_identity(baseline.value): + errors.append("predecessor lineage does not end at the selected latest identity") + if history and ( + history[-1].get("releaseId") != release_id + or history[-1].get("buildVersion") != build_version + or history[-1].get("tag") != tag + or history[-1].get("sourceCommit") != source_commit + or history[-1].get("releaseClass") != release.get("releaseClass") + or history[-1].get("productDigest") != product_digest + or history[-1].get("publicationReceiptIdentityDigest") + != _receipt_identity(receipt.value) + or history[-1].get("previousLineageDigest") + != baseline.value.get("previousLineageDigest") + or lineage.get("lineageDigest") != semantic_digest(history) + ): + errors.append("predecessor lineage tip does not bind the selected published release") + expected_build = str(context.manifest.policies.get("expectedPredecessorBuild", "")) + expected_release = str(context.manifest.policies.get("expectedPredecessorReleaseId", "")) + expected_product = str(context.manifest.policies.get("expectedPredecessorProductDigest", "")) + candidate_build = str(context.manifest.release.version or "") + if ( + build_version != expected_build + or release_id != expected_release + or product_digest != expected_product + or BUILD_VERSION_RE.fullmatch(candidate_build) is None + or BUILD_VERSION_RE.fullmatch(build_version) is None + or ( + not allow_non_successor_build + and int(candidate_build) <= int(build_version) + ) + ): + errors.append("declared predecessor identity is stale, mismatched, or non-increasing") + release_class = context.manifest.policies.get("releaseClass") + if outstanding and outstanding.get("status") in {"open", "overdue"}: + closure = load_json_input(context, "hotfixFollowUpClosure", required=False) + if closure is not None: + closure_errors = validate_schema( + closure.value, FOLLOW_UP_CLOSURE_SCHEMA + ) + if ( + closure.value.get("status") != "closed" + or closure.value.get("releaseId") + != outstanding.get("obligatedReleaseId") + or closure.value.get("buildVersion") + != outstanding.get("obligatedBuildVersion") + or closure.value.get("releaseClass") != "security-hotfix" + or closure.value.get("productDigest") + != outstanding.get("obligatedProductDigest") + or closure.value.get("successorBaselineDigest") != baseline.digest + or closure.value.get("publicationReceiptDigest") != receipt.digest + or closure.value.get("publicationReceiptIdentityDigest") + != _receipt_identity(receipt.value) + or closure.value.get("authorizationDigest") + != outstanding.get("authorizationDigest") + or closure.value.get("candidateIdentityDigest") + != outstanding.get("obligatedCandidateIdentityDigest") + or closure.value.get("predecessorBuild") + != outstanding.get("obligatedPredecessorBuild") + or closure.value.get("predecessorProductDigest") + != outstanding.get("obligatedPredecessorProductDigest") + or closure.value.get("latestPublishedPointerDigest") != pointer_digest + or closure.value.get("obligationDigest") + != outstanding.get("obligationDigest") + ): + closure_errors.append( + "hotfix follow-up closure does not bind the published predecessor" + ) + if closure_errors: + errors.extend(closure_errors) + else: + closure_digest = closure.digest + outstanding = { + **outstanding, + "status": "closed", + "closureEvidenceDigest": closure.value.get("fullEvidenceDigest"), + "blocksRoutineMaintenance": False, + } + if _routine_follow_up_blocked(outstanding, release_class): + errors.append( + "routine maintenance is blocked by an unresolved hotfix follow-up obligation" + ) + add_blockers( + state, + "stable-maintenance.predecessor-lineage", + errors, + "Select the exact latest activated predecessor baseline, receipt, and pointer.", + ) + return Predecessor( + baseline.value, + baseline.digest, + receipt.value, + receipt.digest, + release_id, + build_version, + source_commit, + product_digest, + tag, + chain_depth, + previous_lineage_digest, + history, + outstanding, + pointer_digest, + closure_digest, + ) + + +def _routine_follow_up_blocked( + outstanding: dict[str, Any], + release_class: Any, + now: dt.datetime | None = None, +) -> bool: + """Return whether one unresolved follow-up blocks a routine successor.""" + + if ( + release_class != "maintenance" + or outstanding.get("status") not in {"open", "overdue"} + ): + return False + deadline = parse_timestamp(outstanding.get("deadline")) + current = now or dt.datetime.now(dt.timezone.utc) + return bool( + outstanding.get("status") == "overdue" + or deadline is None + or deadline <= current + or outstanding.get("blocksRoutineMaintenance") is True + ) + + +def _safe_member_name(name: str) -> bool: + normalized = name.replace("\\", "/") + canonical = normalized[:-1] if normalized.endswith("/") else normalized + raw_parts = canonical.split("/") + pure = PurePosixPath(canonical) + return ( + bool(name) + and bool(canonical) + and "\\" not in name + and not any(part in {"", "."} for part in raw_parts) + and re.match(r"^[A-Za-z]:", normalized) is None + and not normalized.startswith("//") + and not pure.is_absolute() + and ".." not in pure.parts + and not any(part in {"__MACOSX", ".DS_Store"} or part.startswith("._") for part in pure.parts) + ) + + +def _canonical_member_name(name: str) -> str | None: + """Return the unique extraction-path identity for one safe archive member.""" + + if not _safe_member_name(name): + return None + normalized = name.replace("\\", "/") + return normalized[:-1] if normalized.endswith("/") else normalized + + +def _safe_symlink_target(member_name: str, link_name: str) -> bool: + """Reject absolute, drive-qualified, UNC, or escaping archive symlink targets.""" + + if not _safe_member_name(member_name) or not _safe_member_name(link_name): + return False + target = PurePosixPath(member_name.replace("\\", "/")).parent / PurePosixPath( + link_name.replace("\\", "/") + ) + return ".." not in target.parts + + +def _expected_archive_mode( + name: str, *, directory: bool, symlink: bool = False +) -> int: + """Return the fixed portable permission mode for one member path and type.""" + + if symlink: + return 0o777 + if directory: + return 0o755 + normalized = name.replace("\\", "/").rstrip("/").lower() + executable_bin_member = ( + normalized.startswith("bin/") + and not normalized.endswith(".bat") + and not normalized.endswith(".exe") + ) + executable_library_member = ( + normalized in {"lib/jexec", "lib/jspawnhelper"} + or normalized.startswith("lib/libwrapper-linux-") + or normalized.startswith("lib/libwrapper-macosx-") + ) + return 0o755 if executable_bin_member or executable_library_member else 0o644 + + +def _normalized_archive_mode( + mode: int, name: str, *, directory: bool, symlink: bool = False +) -> bool: + """Require the path-derived portable permission emitted by both normalizers.""" + + return mode & 0o7777 == _expected_archive_mode( + name, directory=directory, symlink=symlink + ) + + +def _bounded_nested_bytes(stream: Any, size: int, name: str) -> tuple[bytes | None, str | None]: + """Read a nested member only after enforcing its declared and streamed size bound.""" + + if size < 0 or size > MAX_NESTED_ARCHIVE_BYTES: + return None, f"nested archive exceeds inspection policy: {name}" + data = stream.read(MAX_NESTED_ARCHIVE_BYTES + 1) + if len(data) > MAX_NESTED_ARCHIVE_BYTES or len(data) != size: + return None, f"nested archive exceeds inspection policy: {name}" + return data, None + + +def _nested_archive_errors(data: bytes, name: str, depth: int = 1) -> list[str]: + """Inspect a bounded nested archive for path, type, and further nesting attacks.""" + + errors: list[str] = [] + if depth > 3 or len(data) > MAX_NESTED_ARCHIVE_BYTES: + return [f"nested archive exceeds inspection policy: {name}"] + lowered = name.lower() + try: + if lowered.endswith((".zip", ".jar", ".war", ".ear")): + with zipfile.ZipFile(io.BytesIO(data)) as archive: + names: set[str] = set() + for member in archive.infolist(): + canonical_name = _canonical_member_name(member.filename) + if canonical_name is None: + errors.append(f"nested archive contains an unsafe member: {name}") + elif canonical_name in names: + errors.append( + f"nested archive contains a duplicate member path: {name}" + ) + else: + names.add(canonical_name) + mode = member.external_attr >> 16 + file_type = stat.S_IFMT(mode) + if not member.is_dir() and file_type not in { + 0, + stat.S_IFREG, + stat.S_IFDIR, + }: + errors.append(f"nested archive contains a special member: {name}") + if member.filename.lower().endswith( + (".zip", ".jar", ".war", ".ear", ".tar", ".tar.gz", ".tgz") + ): + with archive.open(member) as stream: + nested_data, size_error = _bounded_nested_bytes( + stream, member.file_size, member.filename + ) + if size_error: + errors.append(size_error) + elif nested_data is not None: + errors.extend( + _nested_archive_errors( + nested_data, member.filename, depth + 1 + ) + ) + elif lowered.endswith((".tar", ".tar.gz", ".tgz")): + with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as archive: + names = set() + for member in archive.getmembers(): + canonical_name = _canonical_member_name(member.name) + if canonical_name is None: + errors.append(f"nested archive contains an unsafe member: {name}") + elif canonical_name in names: + errors.append( + f"nested archive contains a duplicate member path: {name}" + ) + else: + names.add(canonical_name) + if not (member.isfile() or member.isdir() or member.issym()): + errors.append(f"nested archive contains a special member: {name}") + if member.issym() and not _safe_symlink_target( + member.name, member.linkname + ): + errors.append( + f"nested archive contains an unsafe symlink target: {name}" + ) + if member.isfile() and member.name.lower().endswith( + (".zip", ".jar", ".war", ".ear", ".tar", ".tar.gz", ".tgz") + ): + stream = archive.extractfile(member) + if stream is None: + errors.append(f"nested archive member cannot be inspected: {name}") + else: + nested_data, size_error = _bounded_nested_bytes( + stream, member.size, member.name + ) + if size_error: + errors.append(size_error) + elif nested_data is not None: + errors.extend( + _nested_archive_errors( + nested_data, member.name, depth + 1 + ) + ) + except (OSError, tarfile.TarError, zipfile.BadZipFile, RuntimeError): + errors.append(f"nested archive cannot be opened safely: {name}") + return errors + + +def _pax_metadata_errors(member: tarfile.TarInfo) -> list[str]: + """Reject PAX records except canonical extensions required by tar field limits.""" + + errors: list[str] = [] + headers = member.pax_headers + unknown = sorted(set(headers) - {"path", "linkpath", "size"}) + if unknown: + errors.append(f"archive member has noncanonical PAX metadata: {member.name}") + + def needs_text_extension(value: str, limit: int) -> bool: + try: + value.encode("ascii", "strict") + except UnicodeEncodeError: + return True + return len(value) > limit + + path = headers.get("path") + if path is not None and ( + path != member.name or not needs_text_extension(member.name, tarfile.LENGTH_NAME) + ): + errors.append(f"archive member has unnecessary PAX path metadata: {member.name}") + linkpath = headers.get("linkpath") + if linkpath is not None and ( + linkpath != member.linkname + or not needs_text_extension(member.linkname, tarfile.LENGTH_LINK) + ): + errors.append(f"archive member has unnecessary PAX link metadata: {member.name}") + size = headers.get("size") + if size is not None and ( + size != str(member.size) or member.size < 8 ** (tarfile.LENGTH_SIZE - 1) + ): + errors.append(f"archive member has unnecessary PAX size metadata: {member.name}") + return errors + + +def archive_hygiene_errors(path: Path, *, nested: bool = True) -> list[str]: + """Inspect tar/zip archives without extraction and reject unsafe members and nesting.""" + + errors: list[str] = [] + suffix = path.name.lower() + try: + if suffix.endswith((".tar.gz", ".tgz", ".tar")): + if suffix.endswith((".tar.gz", ".tgz")): + with path.open("rb") as stream: + header = stream.read(10) + if ( + len(header) < 10 + or header[:2] != b"\x1f\x8b" + or header[4:8] != b"\0\0\0\0" + or header[3] != 0 + ): + errors.append("gzip header metadata is not deterministic") + with tarfile.open(path, "r:*") as archive: + if archive.pax_headers: + errors.append("archive has noncanonical global PAX metadata") + members = archive.getmembers() + if [member.name for member in members] != sorted( + member.name for member in members + ): + errors.append("archive member order is not deterministic") + names: set[str] = set() + for member in members: + canonical_name = _canonical_member_name(member.name) + if canonical_name is None or canonical_name in names: + errors.append(f"archive contains unsafe or duplicate member {member.name}") + elif canonical_name is not None: + names.add(canonical_name) + errors.extend(_pax_metadata_errors(member)) + if not (member.isfile() or member.isdir() or member.issym()): + errors.append(f"archive contains special member {member.name}") + if ( + member.mtime != 0 + or member.uid != 0 + or member.gid != 0 + or member.uname != "root" + or member.gname != "root" + ): + errors.append(f"archive member metadata is not normalized: {member.name}") + if not _normalized_archive_mode( + member.mode, + member.name, + directory=member.isdir(), + symlink=member.issym(), + ): + errors.append(f"archive member mode is not normalized: {member.name}") + if member.issym(): + if not _safe_symlink_target(member.name, member.linkname): + errors.append(f"archive contains unsafe symlink target {member.name}") + if nested and member.isfile() and member.name.lower().endswith( + (".zip", ".jar", ".war", ".ear", ".tar", ".tar.gz", ".tgz") + ): + stream = archive.extractfile(member) + if stream is None: + errors.append(f"archive nested member cannot be inspected {member.name}") + else: + nested_data, size_error = _bounded_nested_bytes( + stream, member.size, member.name + ) + if size_error: + errors.append(size_error) + elif nested_data is not None: + errors.extend(_nested_archive_errors(nested_data, member.name)) + elif suffix.endswith(".zip"): + with zipfile.ZipFile(path) as archive: + if archive.comment: + errors.append("archive ZIP comment is not permitted") + members = archive.infolist() + if [member.filename for member in members] != sorted( + member.filename for member in members + ): + errors.append("archive member order is not deterministic") + names: set[str] = set() + for member in members: + canonical_name = _canonical_member_name(member.filename) + if canonical_name is None or canonical_name in names: + errors.append(f"archive contains unsafe or duplicate member {member.filename}") + elif canonical_name is not None: + names.add(canonical_name) + if member.comment or member.extra: + errors.append( + f"archive member ZIP metadata is not empty: {member.filename}" + ) + mode = member.external_attr >> 16 + has_unix_mode = member.create_system == 3 and mode != 0 + if not has_unix_mode: + errors.append( + f"archive member Unix mode is missing: {member.filename}" + ) + elif stat.S_ISLNK(mode): + errors.append(f"archive contains unsupported symlink {member.filename}") + if member.date_time != (1980, 1, 1, 0, 0, 0): + errors.append( + f"archive member timestamp is not normalized: {member.filename}" + ) + if has_unix_mode and ( + (member.is_dir() and not stat.S_ISDIR(mode)) + or (not member.is_dir() and not stat.S_ISREG(mode)) + ): + errors.append(f"archive contains special member {member.filename}") + if has_unix_mode and not _normalized_archive_mode( + mode, + member.filename, + directory=member.is_dir(), + ): + errors.append( + f"archive member mode is not normalized: {member.filename}" + ) + if nested and member.filename.lower().endswith( + (".zip", ".jar", ".war", ".ear", ".tar", ".tar.gz", ".tgz") + ): + with archive.open(member) as stream: + nested_data, size_error = _bounded_nested_bytes( + stream, member.file_size, member.filename + ) + if size_error: + errors.append(size_error) + elif nested_data is not None: + errors.extend( + _nested_archive_errors(nested_data, member.filename) + ) + else: + errors.append("candidate archive format is not a supported deterministic archive") + except (OSError, tarfile.TarError, zipfile.BadZipFile): + errors.append("candidate archive cannot be opened safely") + return errors + + +def _package_identity_errors(row: dict[str, Any], path: Path) -> list[str]: + """Bind the AppEnv package selector to OS, architecture, suffix, and file magic.""" + + errors: list[str] = [] + package_type = str(row.get("packageType", "")) + architecture = str(row.get("arch", "")) + package_key = str(row.get("packageKey", "")) + expected_os = { + "deb": "linux", + "rpm": "linux", + "flatpak": "linux", + "snap": "linux", + "dmg": "macos", + "pkg": "macos", + "exe": "windows", + "msi": "windows", + }.get(package_type) + if ( + expected_os is None + or row.get("os") != expected_os + or package_key != f"{architecture}.{package_type}" + or row.get("producerArchitecture") != architecture + or not path.name.lower().endswith(f".{package_type}") + ): + errors.append(f"candidate package selector or suffix is inconsistent for {package_key}") + return errors + try: + with path.open("rb") as stream: + header = stream.read(8) + trailer = b"" + if package_type == "dmg": + stream.seek(-512, 2) + trailer = stream.read(4) + except OSError: + return [f"candidate package cannot be inspected for {package_key}"] + magic_valid = { + "deb": header == b"!\n", + "rpm": header.startswith(b"\xed\xab\xee\xdb"), + "exe": header.startswith(b"MZ"), + "dmg": trailer == b"koly", + }.get(package_type, True) + if not magic_valid: + errors.append(f"candidate package file format is invalid for {package_key}") + byte_architecture = _package_byte_architecture(path, package_type) + if package_type in {"deb", "rpm", "exe"} and byte_architecture is None: + errors.append( + f"candidate package byte architecture cannot be authenticated for {package_key}" + ) + elif byte_architecture is not None and byte_architecture != architecture: + errors.append( + f"candidate package byte architecture is {byte_architecture}, not {architecture}, for {package_key}" + ) + return errors + + +def _package_byte_architecture(path: Path, package_type: str) -> str | None: + """Read an architecture from formats whose authenticated bytes expose one portably.""" + + try: + if package_type == "exe": + with path.open("rb") as stream: + if stream.read(2) != b"MZ": + return None + stream.seek(0x3C) + offset_bytes = stream.read(4) + if len(offset_bytes) != 4: + return None + pe_offset = struct.unpack(" 16 * 1024 * 1024 + or pe_offset + 6 > path.stat().st_size + ): + return None + stream.seek(pe_offset) + if stream.read(4) != b"PE\0\0": + return None + machine_bytes = stream.read(2) + if len(machine_bytes) != 2: + return None + return {0x8664: "amd64", 0xAA64: "arm64"}.get( + struct.unpack(" str | None: + """Read RPMTAG_ARCH from the main RPM header without invoking host package tools.""" + + def read_header( + stream: Any, offset: int + ) -> tuple[list[tuple[int, int, int, int]], bytes, int] | None: + stream.seek(offset) + header = stream.read(16) + if len(header) != 16 or header[:4] != b"\x8e\xad\xe8\x01": + return None + count, store_size = struct.unpack(">II", header[8:16]) + if count > 100_000 or store_size > 64 * 1024 * 1024: + return None + raw_entries = stream.read(count * 16) + store = stream.read(store_size) + if len(raw_entries) != count * 16 or len(store) != store_size: + return None + entries = [ + struct.unpack(">IIII", raw_entries[index : index + 16]) + for index in range(0, len(raw_entries), 16) + ] + return entries, store, offset + 16 + len(raw_entries) + store_size + + with path.open("rb") as stream: + if stream.read(4) != b"\xed\xab\xee\xdb": + return None + signature = read_header(stream, 96) + if signature is None: + return None + main_offset = (signature[2] + 7) & ~7 + main = read_header(stream, main_offset) + if main is None: + return None + entries, store, _ = main + for tag, value_type, offset, count in entries: + if tag != 1022 or value_type != 6 or count != 1 or offset >= len(store): + continue + end = store.find(b"\0", offset) + if end < 0: + return None + value = store[offset:end].decode("ascii", errors="strict") + return { + "x86_64": "amd64", + "amd64": "amd64", + "aarch64": "arm64", + }.get(value) + return None + + +def _deb_byte_architecture(path: Path) -> str | None: + """Read Architecture from a bounded DEB control archive when stdlib supports it.""" + + with path.open("rb") as stream: + if stream.read(8) != b"!\n": + return None + while True: + header = stream.read(60) + if not header: + return None + if len(header) != 60 or header[58:60] != b"`\n": + return None + try: + size = int(header[48:58].decode("ascii").strip()) + except ValueError: + return None + if size < 0: + return None + name = header[:16].decode("ascii", errors="ignore").strip().rstrip("/") + if name.startswith("control.tar"): + if size > 32 * 1024 * 1024: + return None + control_archive = stream.read(size) + if len(control_archive) != size: + return None + try: + with tarfile.open(fileobj=io.BytesIO(control_archive), mode="r:*") as control: + candidates = [ + member + for member in control.getmembers() + if member.isfile() and member.name.lstrip("./") == "control" + ] + if len(candidates) != 1 or candidates[0].size > 1024 * 1024: + return None + extracted = control.extractfile(candidates[0]) + if extracted is None: + return None + payload = extracted.read(candidates[0].size + 1) + except (tarfile.ReadError, EOFError): + return None + if len(payload) > candidates[0].size: + return None + for line in payload.decode("utf-8", errors="strict").splitlines(): + key, separator, value = line.partition(":") + if separator and key == "Architecture": + return {"amd64": "amd64", "arm64": "arm64"}.get( + value.strip() + ) + return None + stream.seek(size + (size & 1), 1) + + +def _asset_root(context: RunContext) -> Path: + root = configured_path(context, "maintenanceCandidateAssets", directory=True) + assert root is not None + return root.resolve() + + +def _asset_path(root: Path, name: Any) -> Path: + if not isinstance(name, str) or not name or Path(name).name != name: + raise ValueError("maintenance candidate asset filename is unsafe") + path = root / name + if not _regular_file(path) or path.resolve().parent != root: + raise ValueError(f"maintenance candidate asset is missing or unsafe: {name}") + return path + + +def _freeze_asset_identity(row: dict[str, Any]) -> dict[str, Any]: + """Select the byte and package fields that must agree with the frozen asset set.""" + + return { + key: row.get(key) + for key in ( + "role", + "fileName", + "digest", + "sizeBytes", + "packageKey", + "os", + "arch", + "producerArchitecture", + "packageType", + "publicAsset", + "signingStatus", + "notarizationStatus", + ) + } + + +def stable_catalog_verification_identity( + catalog: Mapping[str, Any], trusted_key_registry_digest: str, +) -> dict[str, Any]: + """Return the public-safe identity of one exact catalog signature verification.""" + + return { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-catalog-signature-verification", + "catalogDigest": catalog.get("digest"), + "signatureDigest": catalog.get("signatureDigest"), + "signingKeyId": catalog.get("signingKeyId"), + "trustedKeyRegistryDigest": trusted_key_registry_digest, + "signatureAlgorithm": "Ed25519", + "verifier": "network.crypta.platform.appcatalog.AppCatalogVerifier", + "cryptographicVerificationStatus": "pass", + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + } + + +def _candidate_package_notarization_errors( + row: dict[str, Any], package_key: str +) -> list[str]: + """Require notarization only for the exact macOS DMG package class.""" + + expected = "pass" if row.get("packageType") == "dmg" else "not-applicable" + if row.get("notarizationStatus") == expected: + return [] + if expected == "pass": + return [f"candidate macOS package notarization status failed for {package_key}"] + return [ + f"candidate non-DMG package claims notarization for {package_key}" + ] + + +def select_candidate_dmg_for_freeze( + packages: list[dict[str, Any]], + release_class: str, + change_scope: dict[str, Any], +) -> dict[str, Any] | None: + """Select at most one DMG, allowing omission only for a declared narrowed hotfix.""" + + dmg_packages = [ + package for package in packages if package.get("packageType") == "dmg" + ] + if len(dmg_packages) > 1: + raise ValueError("candidate contains multiple DMG packages") + if dmg_packages: + return dmg_packages[0] + if not isinstance(change_scope, dict): + raise ValueError("candidate without a DMG lacks a valid change-scope declaration") + package_keys = sorted(str(package.get("packageKey", "")) for package in packages) + affected_keys = change_scope.get("affectedPackageKeys") + narrowed_hotfix = ( + release_class == "security-hotfix" + and change_scope.get("unaffectedPackageProofStatus") == "pass" + and isinstance(affected_keys, list) + and bool(package_keys) + and package_keys == sorted(str(key) for key in affected_keys) + ) + if not narrowed_hotfix: + raise ValueError("candidate without a DMG is not a declared narrowed hotfix") + return None + + +def _candidate_freeze_errors( + context: RunContext, + freeze: LoadedJson, + candidate_value: dict[str, Any], + expected_predecessor_observation: Predecessor | Mapping[str, Any], + expected_assets: list[dict[str, Any]], + checksums_digest: str, +) -> list[str]: + """Authenticate a prior one-build freeze against candidate declarations and actual bytes.""" + + value = freeze.value + errors = validate_schema(value, CANDIDATE_FREEZE_SCHEMA) + source = candidate_value.get("source") + source = source if isinstance(source, dict) else {} + toolchain = candidate_value.get("toolchain") + toolchain = toolchain if isinstance(toolchain, dict) else {} + producer = value.get("producer") + producer = producer if isinstance(producer, dict) else {} + candidate_catalog = candidate_value.get("stableCatalog") + candidate_catalog = ( + candidate_catalog if isinstance(candidate_catalog, dict) else {} + ) + catalog_verification = value.get("stableCatalogVerification") + catalog_verification = ( + catalog_verification if isinstance(catalog_verification, dict) else {} + ) + expected_catalog_verification = stable_catalog_verification_identity( + candidate_catalog, + str(catalog_verification.get("trustedKeyRegistryDigest", "")), + ) + catalog_verification_digest = semantic_digest(expected_catalog_verification) + observation = value.get("predecessorObservation") + observation = observation if isinstance(observation, dict) else {} + if isinstance(expected_predecessor_observation, Predecessor): + expected_predecessor_observation = { + "releaseId": expected_predecessor_observation.release_id, + "buildVersion": expected_predecessor_observation.build_version, + "productDigest": expected_predecessor_observation.product_digest, + "baselineDigest": expected_predecessor_observation.baseline_digest, + "publicationReceiptDigest": ( + expected_predecessor_observation.receipt_digest + ), + "latestPublishedPointerDigest": ( + expected_predecessor_observation.latest_pointer_digest + ), + } + generated = parse_timestamp(value.get("generatedAt")) + frozen = parse_timestamp(value.get("frozenAt")) + observed = parse_timestamp(observation.get("observedAt")) + candidate_generated = parse_timestamp(candidate_value.get("generatedAt")) + candidate_product = candidate_value.get("product") + candidate_product = candidate_product if isinstance(candidate_product, dict) else {} + if ( + value.get("releaseId") != context.manifest.release.release_id + or value.get("buildVersion") != context.manifest.release.version + or value.get("releaseClass") != context.manifest.policies.get("releaseClass") + or value.get("stableMilestone") != STABLE_MILESTONE + or value.get("source") != source + or value.get("toolchain") != toolchain + or producer.get("workflowCommit") != source.get("commit") + or value.get("buildCount") != 1 + or value.get("rebuildPerformed") is not False + or value.get("checksumsDigest") != checksums_digest + or candidate_value.get("candidateFreezeDigest") != freeze.digest + or candidate_product.get("frozenAt") != value.get("frozenAt") + or generated is None + or frozen is None + or observed is None + or candidate_generated is None + or generated < frozen + or observed > frozen + or candidate_generated < generated + ): + errors.append( + "candidate freeze release, source, toolchain, producer, checksum, or time binding is invalid" + ) + if ( + observation.get("releaseId") + != expected_predecessor_observation.get("releaseId") + or observation.get("buildVersion") + != expected_predecessor_observation.get("buildVersion") + or observation.get("productDigest") + != expected_predecessor_observation.get("productDigest") + or observation.get("baselineDigest") + != expected_predecessor_observation.get("baselineDigest") + or observation.get("publicationReceiptDigest") + != expected_predecessor_observation.get("publicationReceiptDigest") + or observation.get("latestPublishedPointerDigest") + != expected_predecessor_observation.get("latestPublishedPointerDigest") + or observation.get("status") != "latest-published" + ): + errors.append("candidate freeze used a stale or substituted predecessor observation") + if catalog_verification != expected_catalog_verification: + errors.append( + "candidate freeze lacks exact cryptographic Stable catalog verification" + ) + rows = value.get("assets") + rows = rows if isinstance(rows, list) else [] + valid_rows = [row for row in rows if isinstance(row, dict)] + names = [row.get("fileName") for row in valid_rows] + package_keys = [ + row.get("packageKey") + for row in valid_rows + if row.get("role") == "package" + ] + actual_identities = sorted( + (_freeze_asset_identity(row) for row in valid_rows), + key=lambda row: str(row.get("fileName")), + ) + expected_identities = sorted( + (_freeze_asset_identity(row) for row in expected_assets), + key=lambda row: str(row.get("fileName")), + ) + if ( + len(rows) != len(names) + or len(names) != len(set(names)) + or len(package_keys) != len(set(package_keys)) + or actual_identities != expected_identities + or value.get("assetSetDigest") + != semantic_digest( + sorted(valid_rows, key=lambda row: str(row.get("fileName"))) + ) + ): + errors.append("candidate freeze contains an omitted, extra, replaced, or duplicate asset") + for row in rows: + if not isinstance(row, dict): + continue + if row.get("role") in {"stable-catalog", "stable-catalog-signature"} and ( + row.get("signingStatus") != "pass" + or row.get("signingReceiptDigest") != catalog_verification_digest + ): + errors.append( + f"candidate freeze catalog signature verification receipt is invalid for {row.get('fileName')}" + ) + notarization = row.get("notarizationStatus") + notarization_receipt = row.get("notarizationReceiptDigest") + is_dmg_package = ( + row.get("role") == "package" and row.get("packageType") == "dmg" + ) + receipt_is_digest = ( + DIGEST_RE.fullmatch(str(notarization_receipt)) is not None + ) + if is_dmg_package and ( + notarization != "pass" or not receipt_is_digest + ): + errors.append( + f"candidate freeze DMG notarization receipt is invalid for {row.get('fileName')}" + ) + elif not is_dmg_package and ( + notarization != "not-applicable" or notarization_receipt is not None + ): + errors.append( + f"candidate freeze non-DMG asset carries notarization for {row.get('fileName')}" + ) + return errors + + +def _candidate_provenance_errors( + context: RunContext, + provenance: dict[str, Any], + source: dict[str, Any], + product_digest: str, + candidate_input_digest: str, + candidate_freeze_digest: str, + expected_assets: dict[str, str], +) -> list[str]: + """Bind candidate provenance to the exact declaration, freeze, source, and asset set.""" + + provenance_assets = provenance.get("assets") + provenance_assets = provenance_assets if isinstance(provenance_assets, list) else [] + provenance_map = { + str(row.get("name")): str(row.get("digest")) + for row in provenance_assets + if isinstance(row, dict) + } + if ( + provenance.get("kind") + != "stable-1.0-maintenance-candidate-provenance" + or provenance.get("releaseId") != context.manifest.release.release_id + or provenance.get("buildVersion") != context.manifest.release.version + or provenance.get("releaseClass") + != context.manifest.policies.get("releaseClass") + or provenance.get("source") != source + or provenance.get("productDigest") != product_digest + or provenance.get("candidateInputDigest") != candidate_input_digest + or provenance.get("candidateFreezeDigest") != candidate_freeze_digest + or len(provenance_map) != len(provenance_assets) + or provenance_map != expected_assets + or provenance.get("redaction", {}).get("status") != "pass" + ): + return [ + "candidate provenance does not bind the exact input, freeze, and asset identities" + ] + return [] + + +def _package_matrix_scope_errors( + release_class: str, + package_keys: list[str], + required_keys: list[str], + change_scope: dict[str, Any], + policy: dict[str, Any], +) -> list[str]: + """Validate the complete or explicitly narrowed hotfix package matrix.""" + + if release_class != "security-hotfix": + if sorted(package_keys) == sorted(required_keys): + return [] + return ["candidate package matrix is incomplete or contains unsupported keys"] + + errors: list[str] = [] + hotfix_policy = ( + policy.get("hotfix") if isinstance(policy.get("hotfix"), dict) else {} + ) + affected_value = change_scope.get("affectedPackageKeys") + affected_keys = ( + [key for key in affected_value if isinstance(key, str)] + if isinstance(affected_value, list) + else [] + ) + affected_set = set(affected_keys) + package_set = set(package_keys) + required_set = set(required_keys) + if ( + hotfix_policy.get("requiresNonemptyAffectedPackageKeys") is not True + or not isinstance(affected_value, list) + or not affected_keys + or len(affected_keys) != len(affected_value) + or len(affected_keys) != len(affected_set) + or not affected_set.issubset(required_set) + or not affected_set.issubset(package_set) + ): + errors.append( + "security hotfix affected package keys must be a nonempty supported subset " + "of the candidate package matrix" + ) + + full_matrix = sorted(package_keys) == sorted(required_keys) + proof_status = change_scope.get("unaffectedPackageProofStatus") + if full_matrix: + expected_status = hotfix_policy.get( + "fullMatrixUnaffectedPackageProofStatus" + ) + if expected_status != "not-applicable" or proof_status != expected_status: + errors.append( + "complete security-hotfix package matrix requires " + "unaffectedPackageProofStatus=not-applicable" + ) + else: + expected_status = hotfix_policy.get( + "narrowedMatrixUnaffectedPackageProofStatus" + ) + exact_keys_required = hotfix_policy.get( + "requiresExactAffectedKeysForNarrowedMatrix" + ) + if ( + expected_status != "pass" + or exact_keys_required is not True + or proof_status != expected_status + or not package_keys + or not package_set.issubset(required_set) + or sorted(package_keys) != sorted(affected_keys) + ): + errors.append( + "narrowed security-hotfix package matrix requires " + "unaffectedPackageProofStatus=pass and exact affected-package equality" + ) + return errors + + +def authenticate_candidate( + context: RunContext, + predecessor: Predecessor, + policy: dict[str, Any], + state: ValidationState, + *, + allow_published_product: bool = False, + freeze_predecessor_observation: Mapping[str, Any] | None = None, +) -> Candidate: + """Authenticate exact candidate bytes, one-time build provenance, and package matrix.""" + + loaded = load_json_input(context, "maintenanceCandidate") + freeze = load_json_input(context, "maintenanceCandidateFreeze") + provenance = load_json_input(context, "maintenanceCandidateProvenance") + checksums_path = configured_path(context, "maintenanceCandidateChecksums") + assert loaded and freeze and provenance and checksums_path + errors = validate_schema(loaded.value, CANDIDATE_INPUT_SCHEMA) + value = loaded.value + source = value.get("source") if isinstance(value.get("source"), dict) else {} + product = value.get("product") if isinstance(value.get("product"), dict) else {} + packages = value.get("packages") if isinstance(value.get("packages"), list) else [] + toolchain = value.get("toolchain") if isinstance(value.get("toolchain"), dict) else {} + release_class = str(context.manifest.policies.get("releaseClass", "")) + build_version = str(context.manifest.release.version or "") + expected_branch = f"{'release' if release_class == 'maintenance' else 'hotfix'}/{build_version}" + if ( + value.get("releaseId") != context.manifest.release.release_id + or value.get("buildVersion") != build_version + or value.get("releaseClass") != release_class + or source.get("branch") != expected_branch + or source.get("commit") != context.manifest.policies.get("candidateSourceCommit") + or source.get("ref") != context.manifest.policies.get("candidateSourceRef") + or source.get("baseCommit") + != context.manifest.policies.get("candidateBaseCommit") + or source.get("treeState") != "clean" + or source.get("branchHeadVerified") is not True + or source.get("immutableRefVerified") is not True + or value.get("builtOnce") is not True + or value.get("rebuildCount") != 0 + or toolchain.get("dependencyVerificationStatus") != "pass" + or toolchain.get("javaMajorVersion", 0) < 25 + ): + errors.append("candidate source, class, one-time build, or toolchain identity is invalid") + if release_class == "maintenance" and source.get("baseBranch") != "develop": + errors.append("routine maintenance candidate is not based on develop") + if release_class == "security-hotfix" and ( + source.get("baseBranch") != "main" + or source.get("currentPublishedMainBaseVerified") is not True + ): + errors.append("security hotfix is not based on the currently published main state") + root = _asset_root(context) + product_path = _asset_path(root, product.get("fileName")) + product_digest = file_digest(product_path) + if ( + product.get("digest") != product_digest + or product.get("sizeBytes") != product_path.stat().st_size + or product.get("checksumsDigest") != file_digest(checksums_path) + or ( + product_digest == predecessor.product_digest + and not allow_published_product + ) + ): + errors.append("candidate product bytes, size, or predecessor distinction is invalid") + archive_format = product.get("archiveFormat") + if ( + archive_format == "tar.gz" + and not product_path.name.lower().endswith((".tar.gz", ".tgz")) + ) or (archive_format == "zip" and not product_path.name.lower().endswith(".zip")): + errors.append("candidate product filename does not match its declared archive format") + errors.extend(archive_hygiene_errors(product_path)) + catalog = value.get("stableCatalog") + catalog = catalog if isinstance(catalog, dict) else {} + catalog_path = _asset_path(root, catalog.get("fileName")) + catalog_signature_path = _asset_path(root, catalog.get("signatureFileName")) + if ( + catalog.get("digest") != file_digest(catalog_path) + or catalog.get("sizeBytes") != catalog_path.stat().st_size + ): + errors.append("stable catalog bytes or size do not match the frozen candidate") + if ( + catalog.get("signatureDigest") != file_digest(catalog_signature_path) + or catalog.get("signatureSizeBytes") != catalog_signature_path.stat().st_size + ): + errors.append( + "stable catalog detached signature bytes or size do not match the frozen candidate" + ) + assets: list[dict[str, Any]] = [] + asset_paths: dict[str, Path] = { + product_path.name: product_path, + catalog_path.name: catalog_path, + catalog_signature_path.name: catalog_signature_path, + } + package_keys: list[str] = [] + for row in packages: + if not isinstance(row, dict): + errors.append("candidate package row is malformed") + continue + name = row.get("fileName") + try: + path = _asset_path(root, name) + except ValueError as exc: + errors.append(str(exc)) + continue + key = str(row.get("packageKey", "")) + package_keys.append(key) + if ( + row.get("digest") != file_digest(path) + or row.get("sizeBytes") != path.stat().st_size + or row.get("sourceCommit") != source.get("commit") + or row.get("buildVersion") != build_version + or row.get("signingStatus") != "pass" + or row.get("installLaunchStatus") != "pass" + or row.get("upgradeStatus") != "pass" + or row.get("redactionStatus") != "pass" + ): + errors.append(f"candidate package identity or evidence failed for {key}") + errors.extend(_candidate_package_notarization_errors(row, key)) + errors.extend(_package_identity_errors(row, path)) + asset_paths[str(name)] = path + assets.append(dict(row)) + if len(package_keys) != len(set(package_keys)) or len(asset_paths) != len(packages) + 3: + errors.append("candidate package keys or asset filenames are duplicated") + matrix = policy.get("packageMatrix") if isinstance(policy.get("packageMatrix"), dict) else {} + required_keys = list(matrix.get("requiredCoreUpdatePackageKeys", [])) + change_scope = value.get("changeScope") if isinstance(value.get("changeScope"), dict) else {} + categories = change_scope.get("categories") + allowed_categories = set(policy.get("allowedMaintenanceCategories", [])) + if ( + not isinstance(categories, list) + or not categories + or not set(categories).issubset(allowed_categories) + or ( + release_class == "security-hotfix" + and "security-fixes-and-hotfixes" not in categories + ) + ): + errors.append("candidate change categories are outside the closed maintenance policy") + errors.extend( + _package_matrix_scope_errors( + release_class, + package_keys, + required_keys, + change_scope, + policy, + ) + ) + if change_scope.get("unrelatedFeatureChanges") != []: + errors.append("candidate change-scope audit found unrelated feature work") + checksums, checksum_errors = _checksum_rows(checksums_path) + errors.extend(checksum_errors) + expected_checksums = {name: file_digest(path) for name, path in asset_paths.items()} + if checksums != expected_checksums: + errors.append("candidate checksum file does not bind the exact asset set") + expected_freeze_assets = [ + { + "role": "product", + "fileName": product_path.name, + "digest": product_digest, + "sizeBytes": product_path.stat().st_size, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "notarizationStatus": "not-applicable", + }, + { + "role": "stable-catalog", + "fileName": catalog_path.name, + "digest": file_digest(catalog_path), + "sizeBytes": catalog_path.stat().st_size, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "notarizationStatus": "not-applicable", + }, + { + "role": "stable-catalog-signature", + "fileName": catalog_signature_path.name, + "digest": file_digest(catalog_signature_path), + "sizeBytes": catalog_signature_path.stat().st_size, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "notarizationStatus": "not-applicable", + }, + *[ + { + "role": "package", + "fileName": row.get("fileName"), + "digest": file_digest(asset_paths[str(row.get("fileName"))]), + "sizeBytes": asset_paths[str(row.get("fileName"))].stat().st_size, + "packageKey": row.get("packageKey"), + "os": row.get("os"), + "arch": row.get("arch"), + "producerArchitecture": row.get("producerArchitecture"), + "packageType": row.get("packageType"), + "publicAsset": True, + "signingStatus": row.get("signingStatus"), + "notarizationStatus": row.get("notarizationStatus"), + } + for row in assets + ], + ] + expected_freeze_predecessor = freeze_predecessor_observation or { + "releaseId": predecessor.release_id, + "buildVersion": predecessor.build_version, + "productDigest": predecessor.product_digest, + "baselineDigest": predecessor.baseline_digest, + "publicationReceiptDigest": predecessor.receipt_digest, + "latestPublishedPointerDigest": predecessor.latest_pointer_digest, + } + errors.extend( + _candidate_freeze_errors( + context, + freeze, + value, + expected_freeze_predecessor, + expected_freeze_assets, + file_digest(checksums_path), + ) + ) + errors.extend( + _candidate_provenance_errors( + context, + provenance.value, + source, + product_digest, + loaded.digest, + freeze.digest, + expected_checksums, + ) + ) + add_blockers( + state, + "stable-maintenance.candidate-identity", + errors, + "Rebuild and freeze one clean candidate once; do not repair authorized bytes.", + ) + for warning in value.get("operationalWarnings", []): + if isinstance(warning, dict): + warning_id = str(warning.get("warningId", "")) + state.warnings.append( + { + "id": warning_id, + "evidenceId": OPERATIONAL_WARNING_EVIDENCE_IDS.get( + warning_id, warning_id + ), + "severity": "warning", + "summary": "A policy-allowlisted noncritical operational warning requires explicit acceptance.", + "waivable": True, + } + ) + identity = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-candidate", + "generatedAt": value.get("generatedAt"), + "stableMilestone": STABLE_MILESTONE, + "releaseId": context.manifest.release.release_id, + "buildVersion": build_version, + "releaseClass": release_class, + "source": source, + "toolchain": toolchain, + "product": { + "fileName": product_path.name, + "sizeBytes": product_path.stat().st_size, + "digest": product_digest, + }, + "packages": sorted(assets, key=lambda row: str(row.get("packageKey", ""))), + "candidateInputDigest": loaded.digest, + "candidateFreezeDigest": freeze.digest, + "frozenAt": freeze.value.get("frozenAt"), + "checksumsDigest": file_digest(checksums_path), + "provenanceDigest": provenance.digest, + "platformApiDigest": semantic_digest(value.get("platformApi")), + "stableCatalogDigest": semantic_digest(value.get("stableCatalog")), + "firstPartyAppsDigest": semantic_digest(value.get("firstPartyApps")), + "contentProfilesDigest": semantic_digest(value.get("contentFormatProfiles")), + "knownLimitationsDigest": semantic_digest(value.get("limitations")), + "securityDigest": semantic_digest(value.get("security")), + "supportDigest": semantic_digest(value.get("support")), + "legacyBoundariesDigest": semantic_digest(value.get("legacyBoundaries")), + "changeScopeDigest": semantic_digest(value.get("changeScope")), + "operationalWarningsDigest": semantic_digest(value.get("operationalWarnings", [])), + "builtOnce": True, + "rebuildPerformedAfterFreeze": False, + "redaction": {"status": "pass", "findingCount": 0, "findings": []}, + } + identity_digest = semantic_digest(identity) + return Candidate( + source, + value, + loaded.digest, + freeze.digest, + str(freeze.value.get("frozenAt", "")), + product_path, + product_digest, + sorted(assets, key=lambda row: str(row.get("packageKey", ""))), + asset_paths, + file_digest(checksums_path), + provenance.digest, + identity, + identity_digest, + ) + + +def _public_reference(value: Any) -> bool: + if not isinstance(value, str) or not value or any(ord(ch) < 32 for ch in value): + return False + if value.startswith("CHK@"): + return re.fullmatch( + r"CHK@[A-Za-z0-9~-]{43},[A-Za-z0-9~-]{43},[A-Za-z0-9~-]{7}" + r"(?:/[A-Za-z0-9._~/-]+)?", + value, + ) is not None + return ( + is_public_https_uri(value) + and _has_unambiguous_publication_path(value) + and "REPLACE" not in value.upper() + and "\\" not in value + ) + + +def build_core_info( + context: RunContext, + candidate: Candidate, + state: ValidationState, +) -> tuple[dict[str, Any], dict[str, Any]]: + """Build strict deterministic CoreUpdater descriptor bytes from candidate assets.""" + + errors: list[str] = [] + packages: dict[str, dict[str, Any]] = {} + for row in candidate.assets: + key = row.get("packageKey") + direct = row.get("publicChk") + store = row.get("storeUrl") + if not isinstance(key, str) or re.fullmatch(r"(?:amd64|arm64)\.(?:deb|rpm|dmg|exe|flatpak|snap)", key) is None: + errors.append("CoreUpdater package key is not an AppEnv-compatible selector") + continue + if key in packages: + errors.append(f"CoreUpdater descriptor duplicates package key {key}") + continue + if direct is not None and store is not None: + errors.append(f"CoreUpdater package {key} has ambiguous CHK and store references") + continue + if direct is not None: + if not _public_reference(direct): + errors.append(f"CoreUpdater package {key} has an unsafe CHK") + continue + spec = {"chk": direct, "size": row.get("sizeBytes")} + elif store is not None and key.endswith((".flatpak", ".snap")): + if not _public_reference(store): + errors.append(f"CoreUpdater package {key} has an unsafe store URL") + continue + spec = {"size": row.get("sizeBytes"), "store_url": store} + else: + errors.append(f"CoreUpdater package {key} has no authenticated public reference") + continue + if type(spec.get("size")) is not int or spec["size"] <= 0: + errors.append(f"CoreUpdater package {key} has an invalid size") + packages[key] = spec + metadata = context.manifest.policies.get("metadata") + metadata = metadata if isinstance(metadata, dict) else {} + release_page = metadata.get("githubReleasePageUri") + expected_release_page = ( + "https://github.com/crypta-network/cryptad/releases/tag/" + f"v{context.manifest.release.version}" + ) + if release_page != expected_release_page: + errors.append("CoreUpdater release page URL is not public-safe") + descriptor: dict[str, Any] = { + "version": str(context.manifest.release.version), + "release_page_url": release_page, + "packages": {key: packages[key] for key in sorted(packages)}, + } + changelog = candidate.input_value.get("coreUpdateChangelog") + changelog = changelog if isinstance(changelog, dict) else {} + for source, target in ( + ("shortChk", "changelog_chk"), + ("fullChk", "fullchangelog_chk"), + ): + value = changelog.get(source) + if value is not None: + if not _public_reference(value) or not str(value).startswith("CHK@"): + errors.append(f"CoreUpdater {source} is not a public candidate-bound CHK") + else: + descriptor[target] = value + errors.extend(validate_schema(descriptor, CORE_INFO_SCHEMA)) + if set(descriptor) - { + "version", + "release_page_url", + "packages", + "changelog_chk", + "fullchangelog_chk", + }: + errors.append("CoreUpdater descriptor contains unknown or misleading fields") + add_blockers( + state, + "stable-maintenance.core-update-descriptor", + errors, + "Regenerate core-info.json from the exact candidate package map.", + ) + identity = { + "schemaVersion": 1, + "kind": "cryptad-core-info-identity", + "releaseId": context.manifest.release.release_id, + "buildVersion": context.manifest.release.version, + "releaseClass": context.manifest.policies.get("releaseClass"), + "candidateIdentityDigest": candidate.identity_digest, + "descriptor": descriptor, + "packageAssets": [ + { + "packageKey": row.get("packageKey"), + "fileName": row.get("fileName"), + "digest": row.get("digest"), + "sizeBytes": row.get("sizeBytes"), + } + for row in candidate.assets + ], + } + return descriptor, identity + + +def receipt_identity(receipt: dict[str, Any]) -> str: + """Public wrapper for the non-circular maintenance receipt identity.""" + + return _receipt_identity(receipt) diff --git a/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_artifacts.py b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_artifacts.py index f3c484e8235..76b89c82cb5 100644 --- a/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_artifacts.py +++ b/tools/release-certification/cryptad_certification/engines/stable_1_0_rc_artifacts.py @@ -7,8 +7,13 @@ import io import os import re +import shutil +import stat +import sys import tarfile +import zipfile from pathlib import Path +from pathlib import PurePosixPath from typing import Any, Iterable from cryptad_certification.io import write_json, write_text @@ -626,6 +631,175 @@ def create_deterministic_archive( temporary.unlink() +def normalize_portable_distribution_archive(archive_path: Path) -> None: + """Normalize one Gradle portable archive without extracting or rebuilding its payload.""" + + source = _regular_source(archive_path, archive_path.name) + temporary = source.with_name(f".{source.name}.normalized.tmp") + try: + if source.name.lower().endswith((".tar.gz", ".tgz", ".tar")): + _normalize_portable_tar(source, temporary) + elif source.name.lower().endswith(".zip"): + _normalize_portable_zip(source, temporary) + else: + raise ValueError(f"unsupported portable archive: {source.name}") + os.replace(temporary, source) + finally: + if temporary.exists(): + temporary.unlink() + + +def _portable_member_name(name: str) -> str: + """Return one safe normalized POSIX member name.""" + + normalized = name.replace("\\", "/").rstrip("/") + path = PurePosixPath(normalized) + if ( + not normalized + or re.match(r"^[A-Za-z]:", normalized) + or normalized.startswith("//") + or path.is_absolute() + or ".." in path.parts + or any( + part in {"__MACOSX", ".DS_Store"} or part.startswith("._") + for part in path.parts + ) + ): + raise ValueError(f"unsafe portable archive member: {name}") + return normalized + + +def _portable_mode(name: str, *, directory: bool, symlink: bool = False) -> int: + """Return the deterministic mode for one canonical portable member path.""" + + if symlink: + return 0o777 + if directory: + return 0o755 + normalized = name.lower() + executable_bin_member = ( + normalized.startswith("bin/") + and not normalized.endswith(".bat") + and not normalized.endswith(".exe") + ) + executable_library_member = ( + normalized in {"lib/jexec", "lib/jspawnhelper"} + or normalized.startswith("lib/libwrapper-linux-") + or normalized.startswith("lib/libwrapper-macosx-") + ) + return 0o755 if executable_bin_member or executable_library_member else 0o644 + + +def _normalize_portable_tar(source: Path, destination: Path) -> None: + """Write canonical tar/gzip order, time, ownership, types, modes, and header metadata.""" + + with tarfile.open(source, "r:*") as original: + members = original.getmembers() + names = [_portable_member_name(member.name) for member in members] + if len(names) != len(set(names)): + raise ValueError("portable tar contains duplicate members") + by_name = dict(zip(names, members, strict=True)) + + def write_normalized(output: Any) -> None: + with tarfile.open( + fileobj=output, + mode="w", + format=tarfile.PAX_FORMAT, + ) as normalized: + for name in sorted(names): + member = by_name[name] + info = tarfile.TarInfo(name=name) + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + if member.isfile(): + stream = original.extractfile(member) + if stream is None: + raise ValueError( + f"portable tar member cannot be read: {name}" + ) + info.type = tarfile.REGTYPE + info.size = member.size + info.mode = _portable_mode(name, directory=False) + normalized.addfile(info, stream) + elif member.isdir(): + info.type = tarfile.DIRTYPE + info.size = 0 + info.mode = _portable_mode(name, directory=True) + normalized.addfile(info) + elif member.issym(): + link = _portable_member_name(member.linkname) + resolved = PurePosixPath(name).parent / PurePosixPath(link) + if ".." in resolved.parts: + raise ValueError( + f"portable tar symlink escapes its root: {name}" + ) + info.type = tarfile.SYMTYPE + info.linkname = link + info.size = 0 + info.mode = _portable_mode( + name, directory=False, symlink=True + ) + normalized.addfile(info) + else: + raise ValueError( + f"portable tar contains a special member: {name}" + ) + + with destination.open("wb") as raw: + if source.name.lower().endswith(".tar"): + write_normalized(raw) + else: + with gzip.GzipFile( + filename="", mode="wb", fileobj=raw, mtime=0 + ) as compressed: + write_normalized(compressed) + + +def _normalize_portable_zip(source: Path, destination: Path) -> None: + """Write canonical ZIP order, timestamp, Unix modes, metadata, and compression.""" + + with zipfile.ZipFile(source) as original: + members = original.infolist() + names = [_portable_member_name(member.filename) for member in members] + if len(names) != len(set(names)): + raise ValueError("portable ZIP contains duplicate members") + by_name = dict(zip(names, members, strict=True)) + with zipfile.ZipFile( + destination, + mode="w", + compression=zipfile.ZIP_DEFLATED, + compresslevel=9, + strict_timestamps=True, + ) as normalized: + normalized.comment = b"" + for name in sorted(names): + member = by_name[name] + mode = member.external_attr >> 16 + if stat.S_ISLNK(mode): + raise ValueError(f"portable ZIP contains a symlink: {name}") + directory = member.is_dir() + target_name = f"{name}/" if directory else name + info = zipfile.ZipInfo(target_name, (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + permissions = _portable_mode(name, directory=directory) + file_type = stat.S_IFDIR if directory else stat.S_IFREG + info.external_attr = (file_type | permissions) << 16 + if directory: + info.external_attr |= 0x10 + info.compress_type = zipfile.ZIP_DEFLATED + info.extra = b"" + info.comment = b"" + if directory: + normalized.writestr(info, b"") + else: + with original.open(member) as input_stream: + with normalized.open(info, mode="w", force_zip64=True) as output_stream: + shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024) + + def verify_deterministic_archive(archive_path: Path) -> list[str]: """Reject unsafe members and non-normalized metadata in the Stable RC outer archive.""" @@ -716,6 +890,22 @@ def _regular_source(path: Path, label: str) -> Path: absolute = path.absolute() for component in (absolute, *absolute.parents): if component.is_symlink(): + # macOS exposes its temporary directory through the root-level /var + # compatibility alias. That operating-system alias precedes every + # caller-controlled path component and is safe to canonicalize; a + # symlink at the source or below that platform boundary remains + # forbidden. + darwin_root_aliases = { + Path("/tmp"): Path("/private/tmp"), + Path("/var"): Path("/private/var"), + } + if ( + sys.platform == "darwin" + and component != absolute + and darwin_root_aliases.get(component) + == component.resolve(strict=True) + ): + continue raise ValueError(f"archive source is missing or unsafe: {label}") if not absolute.is_file(): raise ValueError(f"archive source is missing or unsafe: {label}") diff --git a/tools/release-certification/cryptad_certification/io.py b/tools/release-certification/cryptad_certification/io.py index d42ffbb0fc4..45d418fea1c 100644 --- a/tools/release-certification/cryptad_certification/io.py +++ b/tools/release-certification/cryptad_certification/io.py @@ -11,9 +11,20 @@ def read_json(path: Path) -> Any: - """Read one UTF-8 JSON document.""" + """Read one UTF-8 JSON document and reject ambiguous duplicate object keys.""" - return json.loads(path.read_text(encoding="utf-8")) + def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + value: dict[str, Any] = {} + for key, item in pairs: + if key in value: + raise ValueError(f"JSON document contains duplicate field {key!r}: {path}") + value[key] = item + return value + + return json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=reject_duplicate_keys, + ) def _write_utf8(path: Path, value: str) -> None: diff --git a/tools/release-certification/cryptad_certification/legacy.py b/tools/release-certification/cryptad_certification/legacy.py index be11201a677..21decf202e6 100644 --- a/tools/release-certification/cryptad_certification/legacy.py +++ b/tools/release-certification/cryptad_certification/legacy.py @@ -32,6 +32,7 @@ "stable-readiness": "stable-1.0-readiness", "stable-rc": "stable-1.0-rc", "stable-ga": "stable-1.0-ga-promotion", + "stable-maintenance": "stable-1.0-maintenance-promotion", } V2_KIND_BY_INPUT = { "appPlatform": "app-platform-smoke", @@ -921,6 +922,16 @@ def _run_stable_ga(context: RunContext) -> tuple[int, Path, Path | None]: return engine.run(context) +def _run_stable_maintenance(context: RunContext) -> tuple[int, Path, Path | None]: + """Run side-effect-free Stable 1.0 maintenance or hotfix certification.""" + + if context.manifest.release.profile != "stable-review": + raise ValueError("stable-maintenance requires release.profile stable-review") + from .engines import stable_1_0_maintenance as engine + + return engine.run(context) + + def _run_passthrough(context: RunContext, command: str, action: str | None) -> tuple[int, Path, Path | None]: engine: Any out = _legacy_dir(context) @@ -1037,6 +1048,7 @@ def _run_passthrough(context: RunContext, command: str, action: str | None) -> t "stable-readiness": _run_stable_readiness, "stable-rc": _run_stable_rc, "stable-ga": _run_stable_ga, + "stable-maintenance": _run_stable_maintenance, } diff --git a/tools/release-certification/cryptad_certification/manifest.py b/tools/release-certification/cryptad_certification/manifest.py index 9e5786efc3e..55d21141469 100644 --- a/tools/release-certification/cryptad_certification/manifest.py +++ b/tools/release-certification/cryptad_certification/manifest.py @@ -29,6 +29,7 @@ "stable-readiness", "stable-rc", "stable-ga", + "stable-maintenance", "multi-node-beta", "security-response", } @@ -70,6 +71,29 @@ "stableGaAuthorization", "stableGaPolicy", "stableGaPublicationReceipt", + "stableGaPromotionSummary", + "stableGaValidation", + "stableGaAuthorizationSummary", + "stableGaPublicationPlan", + "stableGaChecksums", + "stableGaProvenance", + "stableGaMaintenanceBaseline", + "predecessorPublicationReceipt", + "predecessorBaseline", + "latestPublishedMaintenancePointer", + "maintenanceCandidate", + "maintenanceCandidateFreeze", + "maintenanceCandidateAssets", + "maintenanceCandidateChecksums", + "maintenanceCandidateProvenance", + "maintenanceEvidence", + "maintenancePolicy", + "stableMaintenanceAuthorization", + "stableMaintenancePublicationReceipt", + "coreUpdatePublicationReceipt", + "hotfixFollowUpObligation", + "hotfixFollowUpEvidence", + "hotfixFollowUpClosure", "stableCatalogOperations", "stableRcFreezeExceptions", "stableRcValidation", @@ -82,10 +106,16 @@ POLICY_FIELDS = { "artifactBaseUri", "candidateSourceCommit", + "candidateBaseCommit", "candidateSourceRef", + "candidateSourceBranch", "catalogChannel", "expectedPreviousReleaseId", "expectedPreviousProductDigest", + "expectedPredecessorBuild", + "expectedPredecessorReleaseId", + "expectedPredecessorProductDigest", + "releaseClass", "historyDir", "historyLabel", "metadata", @@ -130,7 +160,11 @@ "inserturi", "insert_uri", ) -PUBLIC_AUTHORIZATION_INPUT_PATH = "manifest.inputs.stableGaAuthorization" +PUBLIC_AUTHORIZATION_INPUT_PATHS = { + "manifest.inputs.stableGaAuthorization", + "manifest.inputs.stableGaAuthorizationSummary", + "manifest.inputs.stableMaintenanceAuthorization", +} class ManifestError(ValueError): @@ -149,7 +183,7 @@ def _reject_secret_fields(value: Any, path: str = "manifest") -> None: normalized = str(key).lower().replace("-", "_") child_path = f"{path}.{key}" if ( - child_path != PUBLIC_AUTHORIZATION_INPUT_PATH + child_path not in PUBLIC_AUTHORIZATION_INPUT_PATHS and any(fragment in normalized for fragment in SECRET_KEY_FRAGMENTS) ): raise ManifestError( @@ -214,16 +248,31 @@ def _validate_policies(value: Any) -> dict[str, Any]: "refreeze", }: raise ManifestError("policies.stableRcFreezeMode is invalid") + if "releaseClass" in policies and policies["releaseClass"] not in { + "maintenance", + "security-hotfix", + }: + raise ManifestError("policies.releaseClass is invalid") if "candidateSourceCommit" in policies and re.fullmatch( r"[0-9a-f]{40,64}", policies["candidateSourceCommit"] ) is None: raise ManifestError("policies.candidateSourceCommit must be a lowercase Git commit id") + if "candidateBaseCommit" in policies and re.fullmatch( + r"[0-9a-f]{40,64}", policies["candidateBaseCommit"] + ) is None: + raise ManifestError("policies.candidateBaseCommit must be a lowercase Git commit id") if "expectedPreviousProductDigest" in policies and re.fullmatch( r"sha256:[0-9a-f]{64}", policies["expectedPreviousProductDigest"] ) is None: raise ManifestError( "policies.expectedPreviousProductDigest must be a SHA-256 digest" ) + if "expectedPredecessorProductDigest" in policies and re.fullmatch( + r"sha256:[0-9a-f]{64}", policies["expectedPredecessorProductDigest"] + ) is None: + raise ManifestError( + "policies.expectedPredecessorProductDigest must be a SHA-256 digest" + ) if "publicationIntent" in policies and policies["publicationIntent"] != ( "prepare-explicit-protected-publication" ): diff --git a/tools/release-certification/cryptad_certification/selftest.py b/tools/release-certification/cryptad_certification/selftest.py index cbd198065ec..1d45ed3bc6b 100644 --- a/tools/release-certification/cryptad_certification/selftest.py +++ b/tools/release-certification/cryptad_certification/selftest.py @@ -23,6 +23,11 @@ "stable-readiness": ["cryptad_certification.tests.test_stable"], "stable-rc": ["cryptad_certification.tests.test_stable_rc"], "stable-ga": ["cryptad_certification.tests.test_stable_ga"], + "stable-maintenance": [ + "cryptad_certification.tests.test_stable_maintenance", + "cryptad_certification.tests.test_stable_maintenance_publication", + "cryptad_certification.tests.test_stable_maintenance_workflows", + ], "migration": ["cryptad_certification.tests.test_core"], } diff --git a/tools/release-certification/cryptad_certification/tests/test_core.py b/tools/release-certification/cryptad_certification/tests/test_core.py index d8a57f57e05..e12ab9d8b52 100644 --- a/tools/release-certification/cryptad_certification/tests/test_core.py +++ b/tools/release-certification/cryptad_certification/tests/test_core.py @@ -15,7 +15,12 @@ from cryptad_certification.legacy import execute as execute_engine from cryptad_certification.redaction import scan_value from cryptad_certification.tests.support import workspace_root, write_manifest -from cryptad_certification.workspace import WorkspaceError, prepare_context, prepare_run_root +from cryptad_certification.workspace import ( + WorkspaceError, + prepare_context, + prepare_run_root, + reset_confined_directory, +) class ManifestTest(unittest.TestCase): @@ -240,6 +245,24 @@ def test_component_context_rejects_a_path_outside_the_run_root(self) -> None: (manifest.output.root / "outside-component").exists() ) + def test_reset_accepts_a_canonical_alias_above_the_release_root(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + physical = root / "physical" + physical.mkdir() + alias = root / "alias" + try: + alias.symlink_to(physical, target_is_directory=True) + except OSError as exc: + self.skipTest(f"directory symlinks are unavailable: {exc}") + run_root = alias / "release" + target = run_root / "component" / "artifacts" + target.parent.mkdir(parents=True) + + result = reset_confined_directory(target, run_root, "test output") + + self.assertEqual(result, (physical / "release/component/artifacts").resolve()) + class IoSafetyTest(unittest.TestCase): def test_shared_writers_refuse_symlinked_output_files(self) -> None: diff --git a/tools/release-certification/cryptad_certification/tests/test_stable_maintenance.py b/tools/release-certification/cryptad_certification/tests/test_stable_maintenance.py new file mode 100644 index 00000000000..3e6bc5ec0d1 --- /dev/null +++ b/tools/release-certification/cryptad_certification/tests/test_stable_maintenance.py @@ -0,0 +1,4355 @@ +"""Offline contract tests for Stable 1.0 maintenance and security-hotfix releases.""" + +from __future__ import annotations + +import copy +import dataclasses +import inspect +import io +import json +import shutil +import stat +import tarfile +import tempfile +import unittest +import warnings +import zipfile +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +from cryptad_certification import selftest +from cryptad_certification.cli import ( + _validate_stable_maintenance_manifest, + build_parser, +) +from cryptad_certification.engines import ( + stable_1_0_ga, + stable_1_0_ga_core, + stable_1_0_maintenance, + stable_1_0_maintenance_compatibility as compatibility, + stable_1_0_maintenance_core as core, +) +from cryptad_certification.engines.stable_1_0_maintenance import ( + _authorization, + _authorization_expected, + _close_authorization_errors, + _concurrent_follow_up_errors, + _core_receipt_errors, + _lineage, + _public_assets, + _public_checksum_payload_paths, + _receipt_errors, + _successor, + _write_checksums, +) +from cryptad_certification.engines.stable_1_0_maintenance_compatibility import ( + _app_errors, + _catalog_errors, + _content_profile_errors, + _hotfix_follow_up, + _known_limitations_digest, + _limitation_delta_digest, + _limitation_errors, + _platform_api_errors, + build_comparison, + close_hotfix_follow_up, + validate_production_evidence, +) +from cryptad_certification.engines.stable_1_0_maintenance_core import ( + AUTHORIZATION_SCHEMA, + AUTHORIZATION_SCOPE, + CANDIDATE_FREEZE_SCHEMA, + Candidate, + GaRoot, + LoadedJson, + Predecessor, + _candidate_package_notarization_errors, + _candidate_freeze_errors, + _candidate_provenance_errors, + _package_matrix_scope_errors, + _package_identity_errors, + archive_hygiene_errors, + authenticate_ga_root, + authenticate_predecessor, + build_core_info, + receipt_identity, + select_candidate_dmg_for_freeze, + stable_catalog_verification_identity, + successor_baseline_identity, +) +from cryptad_certification.engines.stable_1_0_rc_core import ( + ValidationState, + file_digest, + semantic_digest, +) +from cryptad_certification.engines.stable_1_0_rc_artifacts import ( + normalize_portable_distribution_archive, +) +from cryptad_certification.io import read_json, write_json +from cryptad_certification.manifest import COMMAND_NAMES, INPUT_FIELDS, POLICY_FIELDS +from cryptad_certification.models import OutputSpec, ReleaseSpec, RunContext, RunManifest +from cryptad_certification.schema_validation import validate_schema +from cryptad_certification.tests.support import workspace_root +from cryptad_certification.tests.test_stable_ga import ( + NOW as GA_NOW, + _authorized_ga_run_context, + _receipt as _ga_receipt, +) + + +NOW = datetime(2026, 7, 18, 12, 0, tzinfo=timezone.utc) +FROZEN = NOW - timedelta(days=2) +RELEASE_ID = "stable-1-0-maintenance-301" +BUILD = "301" +PREDECESSOR_BUILD = "300" +GA_RELEASE_ID = "stable-1-0-ga-300" +COMMIT = "a" * 40 +PRODUCT_DIGEST = "sha256:" + "1" * 64 +PREDECESSOR_PRODUCT_DIGEST = "sha256:" + "2" * 64 +APP_IDS = ( + "queue-manager", + "publisher", + "site-publisher", + "profile-publisher", + "social-inbox", + "feed-reader", + "trust-graph", +) +PROFILE_IDS = ( + "crypta.profile.v1", + "crypta.feed.snapshot.v1", + "crypta.trust.statement.v1", + "crypta.social.message.v1", + "crypta.social.outbox.v1", +) +PACKAGE_KEYS = ( + "amd64.deb", + "amd64.dmg", + "amd64.exe", + "amd64.rpm", + "arm64.deb", + "arm64.dmg", + "arm64.exe", + "arm64.rpm", +) +REQUIRED_PACKAGE_KEYS = ( + "amd64.deb", + "amd64.dmg", + "amd64.exe", + "amd64.rpm", +) + + +def _digest(character: str) -> str: + return "sha256:" + character * 64 + + +def _timestamp(value: datetime = NOW) -> str: + return value.replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _redaction() -> dict[str, object]: + return {"status": "pass", "findingCount": 0, "findings": []} + + +def _policy() -> dict[str, object]: + path = workspace_root() / "tools/release-certification/stable-1.0-maintenance-policy.json" + value = json.loads(path.read_text(encoding="utf-8")) + assert isinstance(value, dict) + return value + + +def _app_compatibility_errors( + ga: object, predecessor: object, candidate: object +) -> list[str]: + return _app_errors(ga, predecessor, candidate, _policy()) + + +def _required_inputs() -> dict[str, str]: + names = { + "stableGaPromotionSummary", + "stableGaValidation", + "stableGaAuthorizationSummary", + "stableGaPublicationPlan", + "stableGaPublicationReceipt", + "stableGaChecksums", + "stableGaProvenance", + "stableGaMaintenanceBaseline", + "predecessorPublicationReceipt", + "predecessorBaseline", + "maintenanceCandidate", + "maintenanceCandidateFreeze", + "maintenanceCandidateAssets", + "maintenanceCandidateChecksums", + "maintenanceCandidateProvenance", + "maintenanceEvidence", + "maintenancePolicy", + } + return {name: f"inputs/{name}.json" for name in names} + + +def _context( + root: Path, + *, + release_class: str = "maintenance", + mode: str = "validate-only", + inputs: dict[str, str] | None = None, +) -> RunContext: + branch = "release" if release_class == "maintenance" else "hotfix" + manifest = RunManifest( + path=root / "maintenance.json", + release=ReleaseSpec(RELEASE_ID, BUILD, "stable-review"), + output=OutputSpec(root / "build/release-certification"), + requirements={}, + inputs={} if inputs is None else inputs, + policies={ + "artifactBaseUri": "https://93.184.216.34/artifacts/stable", + "candidateBaseCommit": "b" * 40, + "candidateSourceBranch": f"{branch}/{BUILD}", + "candidateSourceCommit": COMMIT, + "candidateSourceRef": f"commit:{COMMIT}", + "catalogChannel": "stable", + "expectedPredecessorBuild": PREDECESSOR_BUILD, + "expectedPredecessorReleaseId": "stable-1-0-ga-300", + "expectedPredecessorProductDigest": PREDECESSOR_PRODUCT_DIGEST, + "publicationIntent": "prepare-explicit-protected-publication", + "releaseClass": release_class, + "metadata": { + "githubReleasePageUri": "https://github.com/crypta-network/cryptad/releases/tag/v301", + "catalogPrimaryUri": "https://93.184.216.34/catalog/stable/catalog.json", + "catalogMirrorUris": "https://93.184.216.34/mirror/stable/catalog.json", + "catalogRollbackUri": "https://93.184.216.34/catalog/stable/history/300.json", + "coreUpdatePublicUri": "https://93.184.216.34/updates/info/301/core-info.json", + "deploymentServicePublicUri": "https://93.184.216.34/deployment/observe", + "latestPointerPublicUri": "https://93.184.216.34/maintenance/latest.json", + }, + }, + execution={}, + commands={"stable-maintenance": {"mode": mode}}, + ) + return RunContext( + workspace_root=root.resolve(), + run_root=root / "build/release-certification" / RELEASE_ID, + component="stable-maintenance", + manifest=manifest, + ) + + +def _published_ga_maintenance_context(root: Path) -> tuple[RunContext, Path]: + """Materialize one deterministic PR-284 completion graph for maintenance tests.""" + + workspace = workspace_root() + ga_context, _paths, selected = _authorized_ga_run_context(root, workspace) + output = ga_context.component_dir / "artifacts" / "legacy" + output.mkdir(parents=True) + network_result = [(None, None, None, None, ("93.184.216.34", 443))] + with mock.patch.object( + stable_1_0_ga, "_utc_now", return_value=GA_NOW + ), mock.patch.object( + stable_1_0_ga_core.socket, "getaddrinfo", return_value=network_result + ): + code = stable_1_0_ga._run(ga_context, output, ValidationState()) # noqa: SLF001 + if code != 0: + raise AssertionError("deterministic Stable GA authorization fixture failed") + plan = read_json(output / "stable-1.0-ga-publication-plan.json") + if not isinstance(plan, dict): + raise AssertionError("Stable GA fixture publication plan is malformed") + planned_assets = [ + {key: row[key] for key in ("name", "sizeBytes", "digest")} + for row in plan["assets"] + ] + receipt = _ga_receipt( + selected, + str(plan["promotionIdentityDigest"]), + str(plan["releaseNotesDigest"]), + planned_assets, + ) + receipt["artifactBaseUri"] = plan["artifactBaseUri"] + for row in receipt["assets"]: + row["publicUri"] = str(plan["artifactBaseUri"]) + str(row["name"]) + plan_catalog = plan["catalog"] + receipt_catalog = receipt["catalog"] + receipt_catalog["primary"]["publicUri"] = plan_catalog["primary"]["publicUri"] + for observed, planned in zip( + receipt_catalog["mirrors"], plan_catalog["mirrors"], strict=True + ): + observed["publicUri"] = planned["publicUri"] + receipt_catalog["rollback"]["publicUri"] = plan_catalog["rollbackUri"] + receipt_catalog["rollback"]["revision"] = plan_catalog["rollbackRevision"] + receipt_catalog["rollback"]["digest"] = plan_catalog["rollbackDigest"] + receipt_path = root / "stable-1.0-ga-publication-receipt.json" + write_json(receipt_path, receipt) + ga_context.manifest.inputs["stableGaPublicationReceipt"] = receipt_path.relative_to( + workspace + ).as_posix() + shutil.rmtree(output) + output.mkdir(parents=True) + with mock.patch.object( + stable_1_0_ga, "_utc_now", return_value=GA_NOW + ), mock.patch.object( + stable_1_0_ga_core.socket, "getaddrinfo", return_value=network_result + ): + code = stable_1_0_ga._run(ga_context, output, ValidationState()) # noqa: SLF001 + if code != 0: + raise AssertionError("deterministic Stable GA publication fixture failed") + ga_inputs = { + "stableGaPromotionSummary": "stable-1.0-ga-promotion-summary.json", + "stableGaValidation": "stable-1.0-ga-validation.json", + "stableGaAuthorizationSummary": "stable-1.0-ga-authorization-summary.json", + "stableGaPublicationPlan": "stable-1.0-ga-publication-plan.json", + "stableGaPublicationReceipt": "stable-1.0-ga-publication-receipt.json", + "stableGaChecksums": "stable-1.0-ga-checksums.txt", + "stableGaProvenance": "stable-1.0-ga-provenance.json", + "stableGaMaintenanceBaseline": "stable-1.0-maintenance-baseline.json", + } + inputs = { + key: (output / name).relative_to(workspace).as_posix() + for key, name in ga_inputs.items() + } + manifest = RunManifest( + path=root / "stable-1.0-maintenance.json", + release=ReleaseSpec(RELEASE_ID, BUILD, "stable-review"), + output=OutputSpec(root / "maintenance-output"), + requirements={}, + inputs=inputs, + policies={}, + execution={}, + commands={"stable-maintenance": {"mode": "validate-only"}}, + ) + return ( + RunContext( + workspace_root=workspace, + run_root=root / "maintenance-output" / RELEASE_ID, + component="stable-maintenance", + manifest=manifest, + ), + output, + ) + + +def _platform_api() -> dict[str, object]: + deprecation_history: list[dict[str, object]] = [] + return { + "baselineName": "1.0", + "baselineDigest": _digest("3"), + "baselineContractVersion": 23, + "currentContractVersion": 24, + "currentContractDigest": _digest("4"), + "stableSurfaceDigest": _digest("5"), + "compatibilityWindowPolicyDigest": _digest("6"), + "stableDiffDigest": _digest("7"), + "thirdPartyCompatibilityEvidenceDigest": _digest("8"), + "deprecationHistoryDigest": semantic_digest(deprecation_history), + "deprecationHistory": deprecation_history, + "removedStableEndpoints": [], + "removedStableCapabilities": [], + "breakingStableChanges": [], + "criticalRemovalWaiverAttempt": False, + "deprecationClockReset": False, + "experimentalMislabelledStable": False, + "additionsBackwardCompatible": True, + "thirdPartyCompatibilityStatus": "pass", + } + + +def _ga_platform_api() -> dict[str, object]: + value = _platform_api() + value["currentContractVersion"] = 23 + value["currentContractDigest"] = _digest("a") + value.update( + { + "minimumDeprecationWindowContractVersions": 2, + "minimumRemovalWindowContractVersions": 2, + "criticalStableRemovalWaiverAllowed": False, + } + ) + return value + + +def _catalog() -> dict[str, object]: + return { + "catalogId": "crypta-first-party", + "channel": "stable", + "revision": 8, + "edition": 8, + "digest": _digest("b"), + "signatureDigest": _digest("c"), + "signingKeyId": "catalog-production-2026", + "deltaDigest": _digest("d"), + "signatureStatus": "pass", + "keyTrustStatus": "pass", + "mirrorStatus": "pass", + "rollbackStatus": "pass", + "advisoryStatus": "pass", + "denylistStatus": "pass", + "keyRotationTrustTransitionStatus": "not-applicable", + } + + +def _limitations( + predecessor_ids: set[str] | None = None, + *, + added_ids: set[str] | None = None, + resolved_ids: set[str] | None = None, +) -> dict[str, object]: + predecessor = {"stable-known-001"} if predecessor_ids is None else predecessor_ids + added = set() if added_ids is None else added_ids + resolved = set() if resolved_ids is None else resolved_ids + unchanged = predecessor - resolved + current = added | unchanged + return { + "knownLimitationsDigest": _known_limitations_digest(current), + "deltaDigest": _limitation_delta_digest( + predecessor, added, resolved, unchanged + ), + "addedCount": len(added), + "resolvedCount": len(resolved), + "unchangedCount": len(unchanged), + "addedIds": sorted(added), + "resolvedIds": sorted(resolved), + "unchangedIds": sorted(unchanged), + "changesReviewed": True, + "noHiddenLimitations": True, + } + + +def _apps(*, candidate: bool) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for app_id in APP_IDS: + row: dict[str, object] = { + "appId": app_id, + "version": BUILD if candidate else PREDECESSOR_BUILD, + "channel": "stable", + "supportLevel": "maintained", + "bundleDigest": _digest("e"), + "reviewReceiptDigest": _digest("f"), + "appSigningKeyId": "app-production-2026", + "reviewerKeyId": "reviewer-production-2026", + "manifestDigest": _digest("0"), + "permissionSetDigest": _digest("1"), + "apiCompatibilityEvidenceDigest": _digest("2"), + "appDataSchemaVersion": 1, + "migrationEvidenceDigest": _digest("3"), + "backupRestoreEvidenceDigest": _digest("4"), + "supportMetadataDigest": _digest("5"), + } + if candidate: + row.update( + { + "trustState": "trusted", + "reviewStatus": "pass", + "signingStatus": "pass", + "apiCompatibilityStatus": "pass", + "migrationStatus": "pass", + "backupRestoreStatus": "pass", + "serviceGrantStatus": "pass", + "permissionExpansion": False, + "permissionConsentStatus": "not-applicable", + "permissionRationaleStatus": "not-applicable", + "reviewedBundleDigest": row["bundleDigest"], + } + ) + rows.append(row) + return rows + + +def _profiles(*, candidate: bool) -> list[dict[str, object]]: + rows = [] + for profile_id in PROFILE_IDS: + row: dict[str, object] = { + "profileId": profile_id, + "version": 1, + "status": "stable", + "descriptorDigest": _digest("6"), + "canonicalizationRulesDigest": _digest("7"), + "maximumSizePolicyDigest": _digest("8"), + "signaturePayloadRulesDigest": _digest("9"), + "parserVerifierCompatibilityEvidenceDigest": _digest("a"), + } + if candidate: + row.update( + {"existingValidDocumentsAccepted": True, "acceptanceStatus": "pass"} + ) + rows.append(row) + return rows + + +def _candidate_input(release_class: str = "maintenance") -> dict[str, object]: + branch = "release" if release_class == "maintenance" else "hotfix" + catalog = _catalog() + catalog["fileName"] = "stable-catalog.json" + catalog["sizeBytes"] = 1024 + catalog["signatureFileName"] = "stable-catalog.json.sig" + catalog["signatureSizeBytes"] = 256 + scope = { + "categories": ["compatible-bug-fixes"], + "changedModules": ["runtime-node"], + "publicUserVisibleFixes": ["Correct updater package selection."], + "unrelatedFeatureChanges": [], + "auditDigest": _digest("b"), + "incidentId": None, + "severity": None, + "affectedPackageKeys": [], + "shortenedEvidenceIds": ( + [ + "stable-maintenance.live-network-interoperability", + "stable-maintenance.performance", + "stable-maintenance.support-redaction", + ] + if release_class == "security-hotfix" + else [] + ), + "unaffectedPackageProofStatus": "not-applicable", + "hotfixPolicyAuthorizationDigest": None, + "followUpOwner": None, + "followUpApprover": None, + } + if release_class == "security-hotfix": + scope.update( + { + "categories": ["security-fixes-and-hotfixes"], + "incidentId": "CRYPTA-SEC-2026-001", + "severity": "critical", + "affectedPackageKeys": list(REQUIRED_PACKAGE_KEYS), + "hotfixPolicyAuthorizationDigest": _digest("c"), + "followUpOwner": "stable-security-team", + "followUpApprover": "stable-security-release-manager", + } + ) + return { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-candidate-input", + "generatedAt": _timestamp(), + "stableMilestone": "1.0", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": release_class, + "candidateFreezeDigest": _digest("f"), + "builtOnce": True, + "rebuildCount": 0, + "source": { + "branch": f"{branch}/{BUILD}", + "ref": f"commit:{COMMIT}", + "commit": COMMIT, + "baseBranch": "develop" if release_class == "maintenance" else "main", + "baseCommit": "b" * 40, + "clean": True, + "treeState": "clean", + "branchHeadVerified": True, + "immutableRefVerified": True, + "currentPublishedMainBaseVerified": release_class == "security-hotfix", + "sourceTreeDigest": _digest("d"), + }, + "toolchain": { + "javaVersion": "25.0.1", + "javaMajorVersion": 25, + "gradleVersion": "9.1.0", + "gradleWrapperDigest": _digest("e"), + "dependencyVerificationDigest": _digest("f"), + "dependencyVerificationStatus": "pass", + "buildLogicDigest": _digest("0"), + "buildTasks": ["assembleCryptadDist"], + "productionSigning": True, + "testSigning": False, + }, + "product": { + "fileName": "cryptad-301.tar.gz", + "digest": PRODUCT_DIGEST, + "sizeBytes": 1024, + "archiveFormat": "tar.gz", + "archiveIntegrityStatus": "pass", + "checksumsDigest": _digest("1"), + "frozenAt": _timestamp(FROZEN), + "rebuildPerformed": False, + }, + "packages": [], + "platformApi": _platform_api(), + "stableCatalog": catalog, + "firstPartyApps": _apps(candidate=True), + "contentFormatProfiles": _profiles(candidate=True), + "limitations": _limitations(), + "security": { + "advisoryDigest": _digest("5"), + "denylistDigest": _digest("6"), + "keyStateDigest": _digest("7"), + "securityEvidenceDigest": _digest("8"), + "signingKeysUncompromised": True, + "advisoryStatus": "pass", + "denylistStatus": "pass", + "securityEvidenceStatus": "pass", + }, + "support": { + "supportLevelDigest": _digest("9"), + "diagnosticsEvidenceDigest": _digest("a"), + "supportBundleDigest": _digest("b"), + "supportStatus": "pass", + "diagnosticsStatus": "pass", + "supportCommitmentReduced": False, + "redactionStatus": "pass", + }, + "legacyBoundaries": { + "pluginRuntime": "removed", + "inCorePluginApi": "removed", + "legacyAdminMutationRoutes": "disabled", + "fproxyBrowse": "retained", + "contentFiltering": "retained", + "emergencyFallbackRoutes": "retained", + }, + "changeScope": scope, + "coreUpdateChangelog": { + "shortChk": "CHK@" + "C" * 43 + "," + "D" * 43 + ",AAIC--8/short.txt", + "fullChk": "CHK@" + "E" * 43 + "," + "F" * 43 + ",AAIC--8/full.txt", + }, + "redaction": _redaction(), + } + + +def _packages() -> list[dict[str, object]]: + rows = [] + for index, key in enumerate(PACKAGE_KEYS): + extension = key.rsplit(".", 1)[1] + rows.append( + { + "packageKey": key, + "fileName": key.replace(".", "-") + ".pkg", + "os": "linux" if extension in {"deb", "rpm"} else ("macos" if extension == "dmg" else "windows"), + "arch": key.split(".", 1)[0], + "producerArchitecture": key.split(".", 1)[0], + "packageType": extension, + "digest": _digest(format(index, "x")), + "sizeBytes": 1000 + index, + "buildVersion": BUILD, + "sourceCommit": COMMIT, + "publicChk": ( + "CHK@" + + "A" * 43 + + "," + + "B" * 43 + + f",AAIC--{index}/cryptad-{index}.{extension}" + ), + "storeUrl": None, + "signingStatus": "pass", + "notarizationStatus": "pass" if extension == "dmg" else "not-applicable", + "installLaunchStatus": "pass", + "upgradeStatus": "pass", + "installLaunchEvidenceDigest": _digest("c"), + "upgradeEvidenceDigest": _digest("d"), + "uninstallDataRetention": "retained", + "redactionStatus": "pass", + } + ) + return rows + + +def _candidate(root: Path, release_class: str = "maintenance") -> Candidate: + value = _candidate_input(release_class) + packages = _packages() + value["packages"] = packages + product = root / "cryptad-301.tar.gz" + product.parent.mkdir(parents=True, exist_ok=True) + product.write_bytes(b"candidate-product") + source = value["source"] + assert isinstance(source, dict) + return Candidate( + source=source, + input_value=value, + input_digest=_digest("e"), + freeze_digest=_digest("f"), + frozen_at=_timestamp(FROZEN), + product_path=product, + product_digest=PRODUCT_DIGEST, + assets=packages, + asset_paths={product.name: product}, + checksums_digest=_digest("f"), + provenance_digest=_digest("0"), + identity={"kind": "stable-1.0-maintenance-candidate"}, + identity_digest=_digest("1"), + ) + + +def _ga_and_predecessor() -> tuple[GaRoot, Predecessor]: + catalog = _catalog() + catalog["edition"] = 7 + catalog["revision"] = 7 + baseline = { + "schemaVersion": 1, + "platformApi": _ga_platform_api(), + "stableCatalog": catalog, + "firstPartyApps": _apps(candidate=False), + "contentFormatProfiles": _profiles(candidate=False), + "limitations": { + "stableKnownLimitationsDigest": _digest("2"), + "allowedLimitations": [{"id": "stable-known-001"}], + }, + "securityBaseline": {"digest": _digest("3")}, + "supportBaseline": {"digest": _digest("4")}, + "legacyBoundaries": _candidate_input()["legacyBoundaries"], + } + ga = GaRoot( + baseline=baseline, + baseline_digest=_digest("5"), + receipt={"publicationState": "publication-complete"}, + receipt_digest=_digest("6"), + release_id=GA_RELEASE_ID, + build_version=PREDECESSOR_BUILD, + source_commit="b" * 40, + product_digest=PREDECESSOR_PRODUCT_DIGEST, + tag="v300", + root_identity_digest=_digest("7"), + ) + history = [ + { + "chainDepth": 0, + "releaseId": ga.release_id, + "buildVersion": ga.build_version, + "tag": ga.tag, + "sourceCommit": ga.source_commit, + "releaseClass": "stable-ga", + "productDigest": ga.product_digest, + "baselineIdentityDigest": ga.root_identity_digest, + "publicationReceiptIdentityDigest": semantic_digest(ga.receipt), + "previousLineageDigest": ga.root_identity_digest, + } + ] + predecessor = Predecessor( + baseline=baseline, + baseline_digest=ga.baseline_digest, + receipt=ga.receipt, + receipt_digest=ga.receipt_digest, + release_id=ga.release_id, + build_version=ga.build_version, + source_commit=ga.source_commit, + product_digest=ga.product_digest, + tag=ga.tag, + chain_depth=0, + previous_lineage_digest=ga.root_identity_digest, + lineage_history=history, + outstanding_follow_up=None, + ) + return ga, predecessor + + +def _evidence(release_class: str = "maintenance", *, window: str = "normal") -> dict[str, object]: + start = NOW - (timedelta(days=1) if window == "normal" else timedelta(hours=1)) + evidence_ids = [ + *core.REQUIRED_PRODUCTION_EVIDENCE, + "stable-maintenance.direct-ga-upgrade", + "stable-maintenance.candidate-identity", + "stable-maintenance.platform-api-compatibility", + ] + rows = [] + digest_characters = "0123456789abcdef" + for index, evidence_id in enumerate(evidence_ids): + direct_ga = evidence_id == "stable-maintenance.direct-ga-upgrade" + rows.append( + { + "evidenceId": evidence_id, + "status": "pass", + "candidateReleaseId": RELEASE_ID, + "candidateBuild": BUILD, + "candidateProductDigest": PRODUCT_DIGEST, + "candidateFreezeDigest": _digest("f"), + "predecessorBuild": PREDECESSOR_BUILD, + "predecessorProductDigest": PREDECESSOR_PRODUCT_DIGEST, + "gaReleaseId": GA_RELEASE_ID if direct_ga else None, + "gaBuild": PREDECESSOR_BUILD if direct_ga else None, + "gaProductDigest": PREDECESSOR_PRODUCT_DIGEST if direct_ga else None, + "startedAt": _timestamp(start), + "endedAt": _timestamp(), + "environmentClass": "production", + "production": True, + "nodeCount": 2, + "operationCount": 500, + "evidenceDigest": _digest(digest_characters[index]), + "fresh": True, + "redactionStatus": "pass", + } + ) + return { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-evidence", + "generatedAt": _timestamp(), + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": release_class, + "candidateProductDigest": PRODUCT_DIGEST, + "candidateFreezeDigest": _digest("f"), + "predecessorBuild": PREDECESSOR_BUILD, + "predecessorProductDigest": PREDECESSOR_PRODUCT_DIGEST, + "windowClass": window, + "validationStartedAt": _timestamp(start), + "validationEndedAt": _timestamp(), + "productionEvidence": True, + "fixtureOnly": False, + "simulatedOnly": False, + "skipped": False, + "evidenceRows": rows, + "redaction": _redaction(), + } + + +class StableMaintenanceRegistrationTest(unittest.TestCase): + """Command, manifest, policy, and schema registration contracts.""" + + def test_command_and_selftest_are_registered(self) -> None: + self.assertIn("stable-maintenance", COMMAND_NAMES) + self.assertEqual( + selftest.SUITE_MODULES["stable-maintenance"], + [ + "cryptad_certification.tests.test_stable_maintenance", + "cryptad_certification.tests.test_stable_maintenance_publication", + "cryptad_certification.tests.test_stable_maintenance_workflows", + ], + ) + parsed = build_parser().parse_args(["stable-maintenance", "--self-test"]) + self.assertEqual(parsed.command, "stable-maintenance") + self.assertTrue(parsed.self_test) + + def test_release_run_allowlists_match_maintenance_contract(self) -> None: + for field in ( + "maintenanceCandidate", + "maintenanceCandidateFreeze", + "maintenanceEvidence", + "stableGaMaintenanceBaseline", + "predecessorPublicationReceipt", + "coreUpdatePublicationReceipt", + "hotfixFollowUpEvidence", + ): + with self.subTest(field=field): + self.assertIn(field, INPUT_FIELDS) + for field in ( + "candidateSourceBranch", + "expectedPredecessorBuild", + "expectedPredecessorProductDigest", + "releaseClass", + ): + with self.subTest(field=field): + self.assertIn(field, POLICY_FIELDS) + + def test_policy_has_closed_integer_release_classes_and_nonwaivable_gates(self) -> None: + policy = _policy() + self.assertEqual(policy["stableMilestone"], "1.0") + self.assertEqual(policy["releaseClasses"], ["maintenance", "security-hotfix"]) + self.assertEqual( + policy["branchPolicy"]["maintenance"]["branchPattern"], + "^release/[1-9][0-9]*$", + ) + self.assertEqual( + policy["branchPolicy"]["security-hotfix"]["branchPattern"], + "^hotfix/[1-9][0-9]*$", + ) + self.assertTrue(policy["publication"]["validationIsSideEffectFree"]) + self.assertTrue( + policy["catalogAndApps"][ + "requiresCatalogVersionAdvanceForIdentityChange" + ] + ) + self.assertTrue(policy["limitations"]["requiresDisjointDeltaPartition"]) + self.assertEqual( + policy["limitations"]["successorMembershipField"], + "limitations.currentIds", + ) + self.assertEqual( + policy["packageMatrix"]["requiredCoreUpdatePackageKeys"], + list(REQUIRED_PACKAGE_KEYS), + ) + self.assertEqual( + policy["catalogAndApps"]["supportLevelOrder"], + ["local-rc", "maintained", "core"], + ) + self.assertTrue(policy["hotfix"]["requiresNonemptyAffectedPackageKeys"]) + self.assertTrue( + policy["hotfix"]["requiresExactAffectedKeysForNarrowedMatrix"] + ) + self.assertEqual( + policy["hotfix"]["fullMatrixUnaffectedPackageProofStatus"], + "not-applicable", + ) + self.assertEqual( + policy["hotfix"]["narrowedMatrixUnaffectedPackageProofStatus"], + "pass", + ) + blockers = set(policy["nonWaivableBlockers"]) + self.assertIn("stable-maintenance.candidate-identity", blockers) + self.assertIn("stable-maintenance.support-redaction", blockers) + + def test_protected_workflow_signs_and_reverifies_every_package_producer_asset( + self, + ) -> None: + policy = _policy()["packageMatrix"]["assetSigning"] + self.assertEqual(policy["method"], "sigstore-github-attestation") + self.assertTrue(policy["exactByteSubjectsRequired"]) + self.assertTrue(policy["perAssetVerificationReceiptRequired"]) + self.assertTrue(policy["producerVerificationRequired"]) + self.assertTrue(policy["freezeBoundaryReverificationRequired"]) + self.assertFalse(policy["privateKeyInputAllowed"]) + + workflow = ( + workspace_root() + / ".github/workflows/stable-1.0-maintenance-release.yml" + ).read_text(encoding="utf-8") + for required in ( + "Cryptographically attest every exact package producer asset", + "actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26", + "subject-path: build/stable-maintenance-package-output/", + "Independently verify and bind every exact package producer attestation", + 'signingMethod: "sigstore-github-attestation"', + '"gh", "attestation", "verify", str(asset)', + "product_signing_receipt = signing_receipt_by_name.get(product_path.name)", + 'package["packageType"] in {"deb", "rpm", "dmg"}', + ): + with self.subTest(required=required): + self.assertIn(required, workflow) + self.assertNotIn("CRYPTAD_LINUX_PACKAGE_SIGNING_PRIVATE_KEY", workflow) + self.assertNotIn("CRYPTAD_LINUX_PACKAGE_SIGNING_PASSPHRASE", workflow) + + def test_protected_workflow_resolves_candidate_inputs_before_reading_bytes(self) -> None: + workflow = ( + workspace_root() + / ".github/workflows/stable-1.0-maintenance-release.yml" + ).read_text(encoding="utf-8") + for required in ( + 'protected_root = Path("build/protected-inputs").resolve(strict=True)', + "resolved.relative_to(protected_root)", + 'candidate_path = protected_input(inputs["maintenanceCandidate"])', + 'source_assets = protected_input(inputs["maintenanceCandidateAssets"], directory=True)', + 'baseline_path = protected_input(inputs["predecessorBaseline"])', + 'receipt_path = protected_input(inputs["predecessorPublicationReceipt"])', + ): + with self.subTest(required=required): + self.assertIn(required, workflow) + self.assertNotIn( + 'str(path).startswith("build/protected-inputs/")', + workflow, + ) + + def test_protected_workflow_rejects_build_drift_and_omitted_predecessor(self) -> None: + workflow = ( + workspace_root() + / ".github/workflows/stable-1.0-maintenance-release.yml" + ).read_text(encoding="utf-8") + + def step(name: str) -> str: + marker = f" - name: {name}" + start = workflow.index(marker) + end = workflow.find("\n - name:", start + len(marker)) + return workflow[start:] if end == -1 else workflow[start:end] + + drift_gate = step("Reject tracked source or index drift after package build") + self.assertIn('git rev-parse HEAD^{commit}', drift_gate) + self.assertIn("git diff-index --quiet --cached HEAD --", drift_gate) + self.assertIn("git diff-files --quiet --", drift_gate) + self.assertIn("git status --short --untracked-files=no", drift_gate) + self.assertLess( + workflow.index("Reject tracked source or index drift after package build"), + workflow.index("Stage exact package producer output"), + ) + + ancestry_gate = step("Authenticate source ancestry and exact change scope") + self.assertIn(".inputs.predecessorBaseline", ancestry_gate) + self.assertIn(".release.sourceCommit", ancestry_gate) + self.assertGreaterEqual(ancestry_gate.count("git merge-base --is-ancestor"), 2) + self.assertIn( + '"$predecessor_commit" "$INPUT_CANDIDATE_COMMIT"', ancestry_gate + ) + + def test_all_new_object_schemas_are_closed(self) -> None: + names = ( + core.CANDIDATE_INPUT_SCHEMA, + core.CANDIDATE_FREEZE_SCHEMA, + core.EVIDENCE_SCHEMA, + core.LINEAGE_SCHEMA, + core.COMPARISON_SCHEMA, + core.VALIDATION_SCHEMA, + core.AUTHORIZATION_SCHEMA, + "stable-1.0-maintenance-activation-authorization-v1.schema.json", + core.PUBLICATION_PLAN_SCHEMA, + core.PUBLICATION_RECEIPT_SCHEMA, + core.PUBLICATION_FAILURE_AUDIT_SCHEMA, + core.SUCCESSOR_SCHEMA, + core.FOLLOW_UP_SCHEMA, + core.FOLLOW_UP_CLOSURE_SCHEMA, + core.CORE_INFO_SCHEMA, + core.CORE_PLAN_SCHEMA, + core.CORE_RECEIPT_SCHEMA, + ) + + def visit(value: object, path: str) -> None: + if isinstance(value, dict): + if value.get("type") == "object": + self.assertIs(value.get("additionalProperties"), False, path) + for key, child in value.items(): + visit(child, f"{path}/{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + visit(child, f"{path}/{index}") + + schema_root = workspace_root() / "tools/release-certification/schemas" + for name in names: + with self.subTest(schema=name): + visit(json.loads((schema_root / name).read_text(encoding="utf-8")), name) + + def test_valid_manifest_classes_and_modes(self) -> None: + for release_class, mode in ( + ("maintenance", "validate-only"), + ("maintenance", "prepare-authorization"), + ("security-hotfix", "validate-only"), + ("security-hotfix", "close-hotfix-follow-up"), + ): + with self.subTest(release_class=release_class, mode=mode): + inputs = _required_inputs() + if mode != "prepare-authorization": + inputs["stableMaintenanceAuthorization"] = "inputs/authorization.json" + if mode == "close-hotfix-follow-up": + inputs.update( + { + "hotfixFollowUpObligation": "inputs/obligation.json", + "hotfixFollowUpEvidence": "inputs/follow-up.json", + } + ) + with tempfile.TemporaryDirectory() as directory: + context = _context( + Path(directory), release_class=release_class, mode=mode, inputs=inputs + ) + _validate_stable_maintenance_manifest(context.manifest) + + def test_follow_up_closure_allows_a_later_carrier_predecessor(self) -> None: + inputs = { + **_required_inputs(), + "stableMaintenanceAuthorization": "inputs/authorization.json", + "hotfixFollowUpObligation": "inputs/obligation.json", + "hotfixFollowUpEvidence": "inputs/follow-up.json", + } + with tempfile.TemporaryDirectory() as directory: + context = _context( + Path(directory), + release_class="security-hotfix", + mode="close-hotfix-follow-up", + inputs=inputs, + ) + context.manifest.policies["expectedPredecessorBuild"] = str( + int(BUILD) + 1 + ) + + _validate_stable_maintenance_manifest(context.manifest) + + def test_candidate_classes_validate_against_closed_input_schema(self) -> None: + for release_class in ("maintenance", "security-hotfix"): + with self.subTest(release_class=release_class): + value = _candidate_input(release_class) + value["packages"] = _packages() + self.assertEqual( + validate_schema(value, core.CANDIDATE_INPUT_SCHEMA), [] + ) + + def test_hotfix_full_matrix_requires_valid_affected_package_subset(self) -> None: + policy = _policy() + scope = _candidate_input("security-hotfix")["changeScope"] + assert isinstance(scope, dict) + scope["affectedPackageKeys"] = ["amd64.exe"] + self.assertEqual( + _package_matrix_scope_errors( + "security-hotfix", + list(REQUIRED_PACKAGE_KEYS), + list(REQUIRED_PACKAGE_KEYS), + scope, + policy, + ), + [], + ) + + for label, affected in ( + ("empty", []), + ("unrelated", ["arm64.deb"]), + ): + with self.subTest(label=label): + invalid_scope = copy.deepcopy(scope) + invalid_scope["affectedPackageKeys"] = affected + self.assertTrue( + _package_matrix_scope_errors( + "security-hotfix", + list(REQUIRED_PACKAGE_KEYS), + list(REQUIRED_PACKAGE_KEYS), + invalid_scope, + policy, + ) + ) + + def test_hotfix_narrowed_matrix_requires_exact_affected_package_keys(self) -> None: + policy = _policy() + scope = _candidate_input("security-hotfix")["changeScope"] + assert isinstance(scope, dict) + scope["affectedPackageKeys"] = ["amd64.exe"] + scope["unaffectedPackageProofStatus"] = "pass" + self.assertEqual( + _package_matrix_scope_errors( + "security-hotfix", + ["amd64.exe"], + list(REQUIRED_PACKAGE_KEYS), + scope, + policy, + ), + [], + ) + + for label, mutate in ( + ( + "wrong-status", + lambda value: value.__setitem__( + "unaffectedPackageProofStatus", "not-applicable" + ), + ), + ( + "non-exact-affected-set", + lambda value: value.__setitem__( + "affectedPackageKeys", ["amd64.deb"] + ), + ), + ): + with self.subTest(label=label): + invalid_scope = copy.deepcopy(scope) + mutate(invalid_scope) + self.assertTrue( + _package_matrix_scope_errors( + "security-hotfix", + ["amd64.exe"], + list(REQUIRED_PACKAGE_KEYS), + invalid_scope, + policy, + ) + ) + + def test_routine_package_matrix_semantics_are_unchanged(self) -> None: + scope = _candidate_input("maintenance")["changeScope"] + assert isinstance(scope, dict) + self.assertEqual( + _package_matrix_scope_errors( + "maintenance", + list(REQUIRED_PACKAGE_KEYS), + list(REQUIRED_PACKAGE_KEYS), + scope, + _policy(), + ), + [], + ) + self.assertTrue( + _package_matrix_scope_errors( + "maintenance", + ["amd64.exe"], + list(REQUIRED_PACKAGE_KEYS), + scope, + _policy(), + ) + ) + + def test_candidate_notarization_is_scoped_only_to_dmg_packages(self) -> None: + packages = _packages() + for row in packages: + key = str(row["packageKey"]) + with self.subTest(package_key=key): + self.assertEqual( + _candidate_package_notarization_errors(row, key), [] + ) + + executable = next( + row for row in packages if row["packageType"] == "exe" + ) + executable["notarizationStatus"] = "pass" + self.assertEqual( + _candidate_package_notarization_errors( + executable, str(executable["packageKey"]) + ), + ["candidate non-DMG package claims notarization for amd64.exe"], + ) + + dmg = next(row for row in packages if row["packageType"] == "dmg") + dmg["notarizationStatus"] = "not-applicable" + self.assertEqual( + _candidate_package_notarization_errors(dmg, str(dmg["packageKey"])), + ["candidate macOS package notarization status failed for amd64.dmg"], + ) + + def test_candidate_dmg_selection_allows_only_declared_narrowed_hotfix_omission( + self, + ) -> None: + packages = [row for row in _packages() if row["arch"] == "amd64"] + selected = select_candidate_dmg_for_freeze(packages, "maintenance", {}) + self.assertIsNotNone(selected) + self.assertEqual("amd64.dmg", selected["packageKey"]) + with self.assertRaisesRegex(ValueError, "multiple DMG packages"): + select_candidate_dmg_for_freeze( + [*packages, copy.deepcopy(selected)], "security-hotfix", {} + ) + + without_dmg = [ + row for row in packages if row["packageType"] != "dmg" + ] + affected_keys = [row["packageKey"] for row in without_dmg] + narrowed_scope = { + "affectedPackageKeys": affected_keys, + "unaffectedPackageProofStatus": "pass", + } + self.assertIsNone( + select_candidate_dmg_for_freeze( + without_dmg, "security-hotfix", narrowed_scope + ) + ) + with self.assertRaisesRegex(ValueError, "not a declared narrowed hotfix"): + select_candidate_dmg_for_freeze( + without_dmg, "maintenance", narrowed_scope + ) + incomplete_scope = { + **narrowed_scope, + "affectedPackageKeys": affected_keys[:-1], + } + with self.assertRaisesRegex(ValueError, "not a declared narrowed hotfix"): + select_candidate_dmg_for_freeze( + without_dmg, "security-hotfix", incomplete_scope + ) + + def test_candidate_freeze_binds_one_build_assets_and_predecessor(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root) + candidate = _candidate_input() + _, predecessor = _ga_and_predecessor() + expected_assets = [ + { + "role": "product", + "fileName": "cryptad-301.tar.gz", + "digest": PRODUCT_DIGEST, + "sizeBytes": 1024, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "notarizationStatus": "not-applicable", + }, + { + "role": "stable-catalog", + "fileName": "stable-catalog.json", + "digest": candidate["stableCatalog"]["digest"], + "sizeBytes": 1024, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "notarizationStatus": "not-applicable", + }, + { + "role": "stable-catalog-signature", + "fileName": "stable-catalog.json.sig", + "digest": candidate["stableCatalog"]["signatureDigest"], + "sizeBytes": 256, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "notarizationStatus": "not-applicable", + }, + { + "role": "package", + "fileName": "cryptad-301-amd64.deb", + "digest": _digest("a"), + "sizeBytes": 2048, + "packageKey": "amd64.deb", + "os": "linux", + "arch": "amd64", + "producerArchitecture": "amd64", + "packageType": "deb", + "publicAsset": True, + "signingStatus": "pass", + "notarizationStatus": "not-applicable", + }, + ] + catalog_verification = stable_catalog_verification_identity( + candidate["stableCatalog"], _digest("9") + ) + catalog_verification_digest = semantic_digest(catalog_verification) + frozen_assets = [] + for index, row in enumerate(expected_assets): + signing_receipt_digest = _digest(str(index + 1)) + if row["role"] in { + "stable-catalog", + "stable-catalog-signature", + }: + signing_receipt_digest = catalog_verification_digest + frozen_assets.append( + { + **row, + "signingReceiptDigest": signing_receipt_digest, + "notarizationReceiptDigest": None, + } + ) + freeze = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-candidate-freeze", + "generatedAt": _timestamp(FROZEN), + "frozenAt": _timestamp(FROZEN), + "stableMilestone": "1.0", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "source": candidate["source"], + "toolchain": candidate["toolchain"], + "producer": { + "system": "github-actions", + "repository": "crypta-network/cryptad", + "workflowPath": ".github/workflows/stable-1.0-maintenance-release.yml", + "workflowCommit": COMMIT, + "runId": "1001", + "runAttempt": 1, + "runnerEnvironment": "github-hosted", + "producerIdentityReceiptDigest": _digest("4"), + "sourceRefReceiptDigest": _digest("5"), + "buildReceiptDigest": _digest("6"), + "authenticationStatus": "pass", + }, + "predecessorObservation": { + "releaseId": predecessor.release_id, + "buildVersion": predecessor.build_version, + "productDigest": predecessor.product_digest, + "baselineDigest": predecessor.baseline_digest, + "publicationReceiptDigest": predecessor.receipt_digest, + "latestPublishedPointerDigest": None, + "observedAt": _timestamp(FROZEN - timedelta(minutes=1)), + "status": "latest-published", + }, + "stableCatalogVerification": catalog_verification, + "buildCount": 1, + "rebuildPerformed": False, + "checksumsDigest": _digest("7"), + "assets": frozen_assets, + "assetSetDigest": semantic_digest( + sorted(frozen_assets, key=lambda row: row["fileName"]) + ), + "redaction": _redaction(), + } + freeze_path = root / "candidate-freeze.json" + write_json(freeze_path, freeze) + candidate["candidateFreezeDigest"] = file_digest(freeze_path) + loaded = LoadedJson( + "maintenanceCandidateFreeze", + freeze_path, + freeze, + file_digest(freeze_path), + ) + + errors = _candidate_freeze_errors( + context, + loaded, + candidate, + predecessor, + expected_assets, + _digest("7"), + ) + + self.assertEqual(validate_schema(freeze, CANDIDATE_FREEZE_SCHEMA), []) + self.assertEqual(errors, []) + unverified_catalog = copy.deepcopy(freeze) + unverified_catalog["stableCatalogVerification"][ + "cryptographicVerificationStatus" + ] = "fail" + unverified_path = root / "unverified-catalog.json" + write_json(unverified_path, unverified_catalog) + unverified_loaded = LoadedJson( + "maintenanceCandidateFreeze", + unverified_path, + unverified_catalog, + file_digest(unverified_path), + ) + unverified_candidate = copy.deepcopy(candidate) + unverified_candidate["candidateFreezeDigest"] = unverified_loaded.digest + self.assertIn( + "candidate freeze lacks exact cryptographic Stable catalog verification", + _candidate_freeze_errors( + context, + unverified_loaded, + unverified_candidate, + predecessor, + expected_assets, + _digest("7"), + ), + ) + wrong_catalog_receipt = copy.deepcopy(freeze) + wrong_catalog_receipt["assets"][1]["signingReceiptDigest"] = _digest( + "unverified-catalog" + ) + wrong_catalog_receipt["assetSetDigest"] = semantic_digest( + sorted( + wrong_catalog_receipt["assets"], + key=lambda row: row["fileName"], + ) + ) + wrong_catalog_receipt_path = root / "wrong-catalog-receipt.json" + write_json(wrong_catalog_receipt_path, wrong_catalog_receipt) + wrong_catalog_receipt_loaded = LoadedJson( + "maintenanceCandidateFreeze", + wrong_catalog_receipt_path, + wrong_catalog_receipt, + file_digest(wrong_catalog_receipt_path), + ) + wrong_catalog_receipt_candidate = copy.deepcopy(candidate) + wrong_catalog_receipt_candidate["candidateFreezeDigest"] = ( + wrong_catalog_receipt_loaded.digest + ) + self.assertIn( + "candidate freeze catalog signature verification receipt is invalid for stable-catalog.json", + _candidate_freeze_errors( + context, + wrong_catalog_receipt_loaded, + wrong_catalog_receipt_candidate, + predecessor, + expected_assets, + _digest("7"), + ), + ) + misbound_notarization = copy.deepcopy(freeze) + misbound_notarization["assets"][3]["notarizationStatus"] = "pass" + misbound_notarization["assets"][3]["notarizationReceiptDigest"] = _digest( + "mac-notarization" + ) + misbound_notarization["assetSetDigest"] = semantic_digest( + sorted( + misbound_notarization["assets"], + key=lambda row: row["fileName"], + ) + ) + misbound_path = root / "misbound-notarization.json" + write_json(misbound_path, misbound_notarization) + misbound_loaded = LoadedJson( + "maintenanceCandidateFreeze", + misbound_path, + misbound_notarization, + file_digest(misbound_path), + ) + misbound_candidate = copy.deepcopy(candidate) + misbound_candidate["candidateFreezeDigest"] = misbound_loaded.digest + self.assertIn( + "candidate freeze non-DMG asset carries notarization for cryptad-301-amd64.deb", + _candidate_freeze_errors( + context, + misbound_loaded, + misbound_candidate, + predecessor, + expected_assets, + _digest("7"), + ), + ) + provenance = { + "kind": "stable-1.0-maintenance-candidate-provenance", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "source": candidate["source"], + "productDigest": PRODUCT_DIGEST, + "candidateInputDigest": _digest("8"), + "candidateFreezeDigest": loaded.digest, + "assets": [ + {"name": row["fileName"], "digest": row["digest"]} + for row in expected_assets + ], + "redaction": _redaction(), + } + expected_asset_map = { + row["fileName"]: row["digest"] for row in expected_assets + } + self.assertEqual( + _candidate_provenance_errors( + context, + provenance, + candidate["source"], + PRODUCT_DIGEST, + _digest("8"), + loaded.digest, + expected_asset_map, + ), + [], + ) + substituted_provenance = copy.deepcopy(provenance) + substituted_provenance["candidateFreezeDigest"] = _digest("0") + self.assertTrue( + _candidate_provenance_errors( + context, + substituted_provenance, + candidate["source"], + PRODUCT_DIGEST, + _digest("8"), + loaded.digest, + expected_asset_map, + ) + ) + + rebuilt = copy.deepcopy(freeze) + rebuilt["buildCount"] = 2 + write_json(root / "rebuilt.json", rebuilt) + wrong_architecture = copy.deepcopy(freeze) + wrong_architecture["assets"][2]["producerArchitecture"] = "arm64" + wrong_architecture["assetSetDigest"] = semantic_digest( + sorted(wrong_architecture["assets"], key=lambda row: row["fileName"]) + ) + wrong_architecture_path = root / "wrong-architecture.json" + write_json(wrong_architecture_path, wrong_architecture) + wrong_architecture_loaded = LoadedJson( + "maintenanceCandidateFreeze", + wrong_architecture_path, + wrong_architecture, + file_digest(wrong_architecture_path), + ) + wrong_architecture_candidate = copy.deepcopy(candidate) + wrong_architecture_candidate["candidateFreezeDigest"] = ( + wrong_architecture_loaded.digest + ) + + self.assertTrue( + _candidate_freeze_errors( + context, + wrong_architecture_loaded, + wrong_architecture_candidate, + predecessor, + expected_assets, + _digest("7"), + ) + ) + candidate["candidateFreezeDigest"] = file_digest(root / "rebuilt.json") + rebuilt_loaded = LoadedJson( + "maintenanceCandidateFreeze", + root / "rebuilt.json", + rebuilt, + file_digest(root / "rebuilt.json"), + ) + self.assertTrue( + _candidate_freeze_errors( + context, + rebuilt_loaded, + candidate, + predecessor, + expected_assets, + _digest("7"), + ) + ) + + replaced = copy.deepcopy(freeze) + replaced["assets"][0]["digest"] = _digest("f") + replaced["assetSetDigest"] = semantic_digest(replaced["assets"]) + write_json(root / "replaced.json", replaced) + candidate["candidateFreezeDigest"] = file_digest(root / "replaced.json") + replaced_loaded = LoadedJson( + "maintenanceCandidateFreeze", + root / "replaced.json", + replaced, + file_digest(root / "replaced.json"), + ) + self.assertTrue( + _candidate_freeze_errors( + context, + replaced_loaded, + candidate, + predecessor, + expected_assets, + _digest("7"), + ) + ) + + def test_candidate_schema_rejects_byte_and_compatibility_drift(self) -> None: + mutations = ( + lambda value: value.__setitem__("rebuildCount", 1), + lambda value: value["source"].__setitem__("treeState", "dirty"), + lambda value: value["platformApi"].__setitem__( + "breakingStableChanges", ["remove /v1/apps"] + ), + lambda value: value["contentFormatProfiles"][0].__setitem__( + "existingValidDocumentsAccepted", False + ), + ) + for mutate in mutations: + value = _candidate_input() + value["packages"] = _packages() + mutate(value) + self.assertTrue(validate_schema(value, core.CANDIDATE_INPUT_SCHEMA)) + + def test_manifest_rejects_wrong_branch_mode_and_nonincreasing_build(self) -> None: + mutations = ( + ("wrong branch", lambda manifest: manifest.policies.__setitem__("candidateSourceBranch", "main")), + ("unsupported mode", lambda manifest: manifest.commands["stable-maintenance"].__setitem__("mode", "publish")), + ("non-increasing build", lambda manifest: manifest.policies.__setitem__("expectedPredecessorBuild", BUILD)), + ) + for label, mutate in mutations: + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + context = _context(Path(directory), inputs={**_required_inputs(), "stableMaintenanceAuthorization": "inputs/auth.json"}) + mutate(context.manifest) + with self.assertRaises(ValueError): + _validate_stable_maintenance_manifest(context.manifest) + + +class StableMaintenanceGaAuthenticationTest(unittest.TestCase): + def test_completed_ga_graph_authenticates_exact_immutable_baseline(self) -> None: + workspace = workspace_root() + (workspace / "build").mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=workspace / "build") as directory: + context, output = _published_ga_maintenance_context(Path(directory)) + state = ValidationState() + + root = authenticate_ga_root(context, state) + + self.assertEqual([], state.blockers) + self.assertEqual( + file_digest(output / "stable-1.0-maintenance-baseline.json"), + root.baseline_digest, + ) + self.assertEqual("publication-complete", root.receipt["publicationState"]) + self.assertEqual(root.baseline["release"]["rcProductDigest"], root.product_digest) + + def test_mixed_modified_or_incomplete_ga_graph_fails_closed(self) -> None: + workspace = workspace_root() + (workspace / "build").mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=workspace / "build") as directory: + context, output = _published_ga_maintenance_context(Path(directory)) + cases = ( + ( + "modified-baseline", + "stable-1.0-maintenance-baseline.json", + lambda value: value["platformApi"].update( + {"currentContractDigest": _digest("f")} + ), + ), + ( + "mixed-validation", + "stable-1.0-ga-promotion-summary.json", + lambda value: value.update({"gaValidationDigest": _digest("e")}), + ), + ( + "wrong-product-receipt", + "stable-1.0-ga-publication-receipt.json", + lambda value: value.update( + {"productDistributionDigest": _digest("d")} + ), + ), + ) + for name, filename, mutate in cases: + with self.subTest(name=name): + path = output / filename + original = read_json(path) + modified = copy.deepcopy(original) + mutate(modified) + write_json(path, modified) + state = ValidationState() + + authenticate_ga_root(context, state) + + self.assertTrue(state.blockers) + write_json(path, original) + + def test_ga_receipt_rejects_failed_asset_observation(self) -> None: + workspace = workspace_root() + (workspace / "build").mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(dir=workspace / "build") as directory: + context, output = _published_ga_maintenance_context(Path(directory)) + receipt_path = output / "stable-1.0-ga-publication-receipt.json" + receipt = read_json(receipt_path) + receipt["assets"][0]["verificationStatus"] = "fail" + write_json(receipt_path, receipt) + state = ValidationState() + + authenticate_ga_root(context, state) + + self.assertTrue( + any("unverified release asset" in row["summary"] for row in state.blockers), + state.blockers, + ) + + def test_ga_predecessor_rejects_unrelated_latest_maintenance_pointer(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + baseline = {"schemaVersion": 1, "kind": "stable-1.0-maintenance-baseline"} + receipt = {"publicationState": "publication-complete"} + pointer = { + "kind": "stable-1.0-maintenance-latest-published", + "releaseId": "forked-maintenance-999", + } + write_json(root / "baseline.json", baseline) + write_json(root / "receipt.json", receipt) + write_json(root / "pointer.json", pointer) + ga = GaRoot( + baseline=baseline, + baseline_digest=file_digest(root / "baseline.json"), + receipt=receipt, + receipt_digest=file_digest(root / "receipt.json"), + release_id="stable-1-0-ga-300", + build_version=PREDECESSOR_BUILD, + source_commit="b" * 40, + product_digest=PREDECESSOR_PRODUCT_DIGEST, + tag="v300", + root_identity_digest=_digest("7"), + ) + context = _context( + root, + inputs={ + "predecessorBaseline": "baseline.json", + "predecessorPublicationReceipt": "receipt.json", + "latestPublishedMaintenancePointer": "pointer.json", + }, + ) + state = ValidationState() + + predecessor = authenticate_predecessor(context, ga, state) + lineage = _lineage(context, ga, predecessor, _candidate(root), state) + + self.assertTrue( + any( + "GA predecessor forbids a latest-maintenance pointer" in row["summary"] + for row in state.blockers + ), + state.blockers, + ) + self.assertIsNone(predecessor.latest_pointer_digest) + self.assertEqual( + ga.root_identity_digest, + lineage["latestPublishedPointerDigest"], + ) + + +class StableMaintenanceInputSecurityTest(unittest.TestCase): + """Ambiguous JSON and archive inputs fail closed offline.""" + + def test_gradle_archive_modes_use_only_deterministic_member_paths(self) -> None: + root = Path(__file__).resolve().parents[4] + distribution = ( + root / "build-logic/src/main/kotlin/cryptad.distribution.gradle.kts" + ).read_text(encoding="utf-8") + runtime = ( + root / "build-logic/src/main/kotlin/cryptad.runtime.gradle.kts" + ).read_text(encoding="utf-8") + normalizer = ( + root / "build-logic/src/main/kotlin/cryptad/PortableArchiveNormalizer.kt" + ).read_text(encoding="utf-8") + + for script in (distribution, runtime): + self.assertNotIn("file.canExecute()", script) + self.assertEqual( + script.count( + "PortableArchiveNormalizer.unixPermissionsForMember(path)" + ), + 2, + ) + self.assertIn('normalized.startsWith("bin/")', normalizer) + self.assertIn('!normalized.endsWith(".bat")', normalizer) + self.assertIn('!normalized.endsWith(".exe")', normalizer) + self.assertIn('normalized == "lib/jspawnhelper"', normalizer) + self.assertNotIn("mode and 0x49", normalizer) + + def test_duplicate_json_keys_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "duplicate.json" + path.write_text('{"buildVersion":"301","buildVersion":"302"}\n', encoding="utf-8") + with self.assertRaisesRegex(ValueError, "duplicate field"): + read_json(path) + + def test_safe_archive_is_accepted(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "safe.zip" + with zipfile.ZipFile(path, "w") as archive: + info = zipfile.ZipInfo("cryptad/readme.txt", (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + archive.writestr(info, "safe") + self.assertEqual(archive_hygiene_errors(path), []) + + def test_archive_hygiene_rejects_path_role_mode_mismatch(self) -> None: + cases = ( + ("bin/cryptad", 0o644), + ("conf/wrapper.conf", 0o755), + ("lib/cryptad.jar", 0o755), + ) + for name, wrong_mode in cases: + for archive_format in ("tar", "zip"): + with self.subTest( + name=name, archive_format=archive_format + ), tempfile.TemporaryDirectory() as directory: + path = Path(directory) / f"wrong-mode.{archive_format}" + if archive_format == "tar": + payload = b"not-inspected" + with tarfile.open(path, "w") as archive: + info = tarfile.TarInfo(name) + info.size = len(payload) + info.mode = wrong_mode + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + archive.addfile(info, io.BytesIO(payload)) + else: + with zipfile.ZipFile(path, "w") as archive: + info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | wrong_mode) << 16 + archive.writestr(info, b"not-inspected") + + errors = archive_hygiene_errors(path, nested=False) + + self.assertTrue( + any("mode is not normalized" in error for error in errors), + errors, + ) + + def test_archive_hygiene_rejects_raw_member_aliases(self) -> None: + aliases = ("bin/./cryptad", "bin//cryptad", "./bin/cryptad") + for alias in aliases: + for archive_format in ("tar", "zip"): + with self.subTest( + alias=alias, archive_format=archive_format + ), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / f"unsafe.{archive_format}" + if archive_format == "tar": + payload = b"unsafe" + with tarfile.open(path, "w") as archive: + info = tarfile.TarInfo(alias) + info.size = len(payload) + info.mode = 0o644 + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + archive.addfile(info, io.BytesIO(payload)) + else: + with zipfile.ZipFile(path, "w") as archive: + info = zipfile.ZipInfo(alias, (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + archive.writestr(info, b"unsafe") + + errors = archive_hygiene_errors(path) + + self.assertTrue( + any("unsafe or duplicate member" in error for error in errors), + errors, + ) + + def test_zip_archive_rejects_file_directory_path_aliases(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "duplicate-path.zip" + with zipfile.ZipFile(path, "w") as archive: + file_info = zipfile.ZipInfo("same", (1980, 1, 1, 0, 0, 0)) + file_info.create_system = 3 + file_info.external_attr = 0o100644 << 16 + archive.writestr(file_info, b"payload") + directory_info = zipfile.ZipInfo("same/", (1980, 1, 1, 0, 0, 0)) + directory_info.create_system = 3 + directory_info.external_attr = 0o40755 << 16 + archive.writestr(directory_info, b"") + + errors = archive_hygiene_errors(path) + + self.assertTrue( + any("unsafe or duplicate member" in error for error in errors), errors + ) + + def test_zip_archive_rejects_comments_and_extra_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "metadata.zip" + with zipfile.ZipFile(path, "w") as archive: + archive.comment = b"archive-comment" + info = zipfile.ZipInfo("safe.txt", (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + info.comment = b"member-comment" + info.extra = b"\xfe\xca\x01\x00x" + archive.writestr(info, b"payload") + + errors = archive_hygiene_errors(path) + + self.assertTrue(any("ZIP comment" in error for error in errors), errors) + self.assertTrue(any("ZIP metadata" in error for error in errors), errors) + + def test_zip_archive_rejects_missing_unix_mode_metadata(self) -> None: + cases = ( + ("zero Unix mode", 3, 1), + ("non-Unix metadata", 0, (0o100644 << 16) | 1), + ) + for label, create_system, external_attr in cases: + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "missing-mode.zip" + with zipfile.ZipFile(path, "w") as archive: + info = zipfile.ZipInfo( + "cryptad/readme.txt", (1980, 1, 1, 0, 0, 0) + ) + info.create_system = create_system + info.external_attr = external_attr + archive.writestr(info, b"unsafe") + + errors = archive_hygiene_errors(path) + + self.assertTrue( + any("Unix mode is missing" in error for error in errors), errors + ) + + def test_gradle_portable_archives_are_normalized_deterministically(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + gzip_tar = root / "cryptad.tar.gz" + plain_tar = root / "cryptad.tar" + zip_archive = root / "cryptad.zip" + archives = (gzip_tar, plain_tar, zip_archive) + jar_bytes = io.BytesIO() + with zipfile.ZipFile(jar_bytes, "w") as jar: + jar_info = zipfile.ZipInfo( + "META-INF/MANIFEST.MF", (1980, 1, 1, 0, 0, 0) + ) + jar_info.create_system = 3 + jar_info.external_attr = 0o100644 << 16 + jar.writestr(jar_info, b"Manifest-Version: 1.0\r\n\r\n") + jar_payload = jar_bytes.getvalue() + for tar_path, mode in ((gzip_tar, "w:gz"), (plain_tar, "w")): + with tarfile.open(tar_path, mode) as archive: + for name in ("bin", "conf", "lib"): + directory_info = tarfile.TarInfo(name) + directory_info.type = tarfile.DIRTYPE + directory_info.mode = 0o40775 + directory_info.mtime = 123456 + archive.addfile(directory_info) + for name, source_mode, payload in ( + ("bin/cryptad", 0o100644, b"#!/bin/sh\n"), + ("bin/cryptad.bat", 0o100755, b"@echo off\r\n"), + ("conf/wrapper.conf", 0o100755, b"wrapper.java.command=java\n"), + ("lib/cryptad.jar", 0o100755, jar_payload), + ("lib/libwrapper-linux-x86-64.so", 0o100644, b"native"), + ): + file_info = tarfile.TarInfo(name) + file_info.mode = source_mode + file_info.mtime = 123456 + file_info.size = len(payload) + archive.addfile(file_info, io.BytesIO(payload)) + with zipfile.ZipFile(zip_archive, "w") as archive: + for name in ("bin/", "conf/", "lib/"): + directory_info = zipfile.ZipInfo(name, (2026, 7, 18, 12, 0, 0)) + directory_info.external_attr = 0o40775 << 16 + archive.writestr(directory_info, b"") + for name, source_mode, payload in ( + ("bin/cryptad", 0o100644, b"#!/bin/sh\n"), + ("bin/cryptad.bat", 0o100755, b"@echo off\r\n"), + ("conf/wrapper.conf", 0o100755, b"wrapper.java.command=java\n"), + ("lib/cryptad.jar", 0o100755, jar_payload), + ("lib/libwrapper-linux-x86-64.so", 0o100644, b"native"), + ): + file_info = zipfile.ZipInfo(name, (2026, 7, 18, 12, 0, 0)) + file_info.external_attr = source_mode << 16 + archive.writestr(file_info, payload) + + for archive in archives: + with self.subTest(archive=archive.name): + normalize_portable_distribution_archive(archive) + first_digest = file_digest(archive) + normalize_portable_distribution_archive(archive) + + self.assertEqual(file_digest(archive), first_digest) + self.assertEqual(archive_hygiene_errors(archive), []) + + self.assertFalse(plain_tar.read_bytes().startswith(b"\x1f\x8b")) + with tarfile.open(plain_tar, "r:") as archive: + modes = {member.name: member.mode for member in archive.getmembers()} + with zipfile.ZipFile(zip_archive) as archive: + zip_modes = { + member.filename.rstrip("/"): member.external_attr >> 16 & 0o777 + for member in archive.infolist() + } + expected_modes = { + "bin": 0o755, + "bin/cryptad": 0o755, + "bin/cryptad.bat": 0o644, + "conf": 0o755, + "conf/wrapper.conf": 0o644, + "lib": 0o755, + "lib/cryptad.jar": 0o644, + "lib/libwrapper-linux-x86-64.so": 0o755, + } + self.assertEqual(modes, expected_modes) + self.assertEqual(zip_modes, expected_modes) + + def test_nested_archive_size_is_rejected_before_payload_read(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "outer.tar" + payload = b"not-a-zip" + with tarfile.open(path, "w") as archive: + info = tarfile.TarInfo("payload/nested.zip") + info.size = len(payload) + info.mode = 0o644 + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + archive.addfile(info, io.BytesIO(payload)) + + with mock.patch.object(core, "MAX_NESTED_ARCHIVE_BYTES", 4): + errors = archive_hygiene_errors(path) + + self.assertTrue( + any("exceeds inspection policy" in error for error in errors), errors + ) + + def test_nested_zip_rejects_duplicate_members(self) -> None: + with tempfile.TemporaryDirectory() as directory: + nested = io.BytesIO() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(nested, "w") as archive: + archive.writestr("same.class", b"first") + archive.writestr("same.class", b"second") + path = Path(directory) / "outer.tar" + with tarfile.open(path, "w") as archive: + info = tarfile.TarInfo("payload/nested.jar") + info.size = len(nested.getvalue()) + info.mode = 0o644 + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + archive.addfile(info, io.BytesIO(nested.getvalue())) + + errors = archive_hygiene_errors(path) + + self.assertTrue( + any("nested archive contains a duplicate" in error for error in errors), + errors, + ) + + def test_gzip_archive_rejects_optional_header_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "metadata.tar.gz" + with tarfile.open(path, "w:gz") as archive: + payload = b"payload" + info = tarfile.TarInfo("safe.txt") + info.size = len(payload) + info.mode = 0o644 + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + archive.addfile(info, io.BytesIO(payload)) + normalize_portable_distribution_archive(path) + encoded = bytearray(path.read_bytes()) + encoded[3] |= 0x10 + encoded[10:10] = b"noncanonical-comment\0" + path.write_bytes(encoded) + + errors = archive_hygiene_errors(path) + + self.assertTrue( + any("gzip header metadata" in error for error in errors), errors + ) + + def test_tar_archive_rejects_noncanonical_pax_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "metadata.tar" + with tarfile.open(path, "w", format=tarfile.PAX_FORMAT) as archive: + payload = b"payload" + info = tarfile.TarInfo("safe.txt") + info.size = len(payload) + info.mode = 0o644 + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + info.pax_headers = {"comment": "secret-value"} + archive.addfile(info, io.BytesIO(payload)) + + errors = archive_hygiene_errors(path) + + self.assertTrue( + any("noncanonical PAX metadata" in error for error in errors), errors + ) + + def test_pe_machine_must_match_declared_and_producer_architecture(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "cryptad.exe" + payload = bytearray(512) + payload[:2] = b"MZ" + payload[0x3C:0x40] = (0x80).to_bytes(4, "little") + payload[0x80:0x84] = b"PE\0\0" + payload[0x84:0x86] = b"\x64\xaa" + path.write_bytes(payload) + row = { + "packageKey": "amd64.exe", + "os": "windows", + "arch": "amd64", + "producerArchitecture": "amd64", + "packageType": "exe", + } + + errors = _package_identity_errors(row, path) + + self.assertTrue(any("byte architecture is arm64" in error for error in errors)) + + def test_archive_hygiene_rejects_unsafe_members(self) -> None: + cases = ( + ("traversal", "../escape.txt"), + ("absolute", "/absolute.txt"), + ("Windows drive absolute", "C:\\Windows\\escape.txt"), + ("Windows UNC absolute", "\\\\server\\share\\escape.txt"), + ("apple metadata", "__MACOSX/value"), + ("nested archive", "payload/nested.zip"), + ) + for label, member in cases: + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "unsafe.tar" + payload = b"bad" + with tarfile.open(path, "w") as archive: + info = tarfile.TarInfo(member) + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + self.assertTrue(archive_hygiene_errors(path)) + + def test_nested_archive_rejects_windows_absolute_member_and_escaping_symlink(self) -> None: + nested_cases: tuple[tuple[str, tarfile.TarInfo], ...] = ( + ("Windows absolute member", tarfile.TarInfo("C:/Windows/escape.txt")), + ("UNC member", tarfile.TarInfo("//server/share/escape.txt")), + ("escaping symlink", tarfile.TarInfo("safe/link")), + ) + for label, nested_info in nested_cases: + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + nested = io.BytesIO() + with tarfile.open(fileobj=nested, mode="w") as archive: + if label == "escaping symlink": + nested_info.type = tarfile.SYMTYPE + nested_info.linkname = "../../../outside" + archive.addfile(nested_info) + else: + payload = b"unsafe" + nested_info.size = len(payload) + archive.addfile(nested_info, io.BytesIO(payload)) + path = Path(directory) / "outer.tar" + with tarfile.open(path, "w") as archive: + outer = tarfile.TarInfo("payload/nested.tar") + outer.size = len(nested.getvalue()) + outer.mode = 0o644 + outer.mtime = 0 + outer.uid = 0 + outer.gid = 0 + outer.uname = "root" + outer.gname = "root" + archive.addfile(outer, io.BytesIO(nested.getvalue())) + + errors = archive_hygiene_errors(path) + + self.assertTrue( + any("nested archive contains an unsafe" in error for error in errors), + errors, + ) + + def test_archive_hygiene_rejects_special_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "special.tar" + with tarfile.open(path, "w") as archive: + info = tarfile.TarInfo("device") + info.type = tarfile.CHRTYPE + archive.addfile(info) + self.assertTrue( + any("special member" in error for error in archive_hygiene_errors(path)) + ) + + def test_unknown_archive_and_mislabeled_package_fail_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + unknown = root / "candidate.bin" + unknown.write_bytes(b"not-an-archive") + package = root / "cryptad.exe" + package.write_bytes(b"MZ" + b"\0" * 32) + + archive_errors = archive_hygiene_errors(unknown) + selector_errors = _package_identity_errors( + { + "packageKey": "amd64.deb", + "os": "linux", + "arch": "amd64", + "producerArchitecture": "amd64", + "packageType": "deb", + }, + package, + ) + + self.assertTrue(archive_errors) + self.assertTrue(selector_errors) + + +class StableMaintenanceCoreInfoTest(unittest.TestCase): + """Deterministic package-to-CoreUpdater mapping tests.""" + + def test_core_info_is_sorted_candidate_bound_and_schema_valid(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root) + candidate = _candidate(root) + candidate.assets.reverse() + state = ValidationState() + descriptor, identity = build_core_info(context, candidate, state) + self.assertEqual(state.blockers, []) + self.assertEqual(list(descriptor["packages"]), sorted(PACKAGE_KEYS)) + self.assertEqual(descriptor["version"], BUILD) + self.assertNotIn("sha256", json.dumps(descriptor)) + self.assertEqual(identity["candidateIdentityDigest"], candidate.identity_digest) + self.assertEqual(validate_schema(descriptor, core.CORE_INFO_SCHEMA), []) + + def test_core_info_keeps_fixed_github_release_page_offline(self) -> None: + with tempfile.TemporaryDirectory() as directory, mock.patch.object( + stable_1_0_ga_core.socket, + "getaddrinfo", + side_effect=OSError("offline"), + ): + root = Path(directory) + context = _context(root) + state = ValidationState() + + descriptor, _identity = build_core_info( + context, _candidate(root), state + ) + + self.assertEqual(state.blockers, []) + self.assertEqual( + descriptor["release_page_url"], + "https://github.com/crypta-network/cryptad/releases/tag/v301", + ) + + context.manifest.policies["metadata"]["githubReleasePageUri"] = ( + "https://github.com/crypta-network/cryptad/releases/tag/v302" + ) + invalid_state = ValidationState() + build_core_info(context, _candidate(root), invalid_state) + self.assertTrue( + any( + "release page URL is not public-safe" in row["summary"] + for row in invalid_state.blockers + ), + invalid_state.blockers, + ) + + def test_core_info_rejects_ambiguous_or_private_package_reference(self) -> None: + mutations = ( + ("both references", lambda row: row.__setitem__("storeUrl", "https://store.crypta.network/app")), + ("private insert", lambda row: row.__setitem__("publicChk", "USK@private/insert/301")), + ("local path", lambda row: row.__setitem__("publicChk", "/tmp/package.deb")), + ) + for label, mutate in mutations: + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root) + mutate(candidate.assets[0]) + state = ValidationState() + build_core_info(_context(root), candidate, state) + self.assertTrue(state.blockers) + + def test_core_info_same_input_has_same_semantic_identity(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root) + first = build_core_info(_context(root), candidate, ValidationState()) + second = build_core_info(_context(root), candidate, ValidationState()) + self.assertEqual(semantic_digest(first), semantic_digest(second)) + + def test_core_info_rejects_reserved_or_privately_resolved_store_url(self) -> None: + private_resolution = [(None, None, None, None, ("10.0.0.8", 443))] + for store_url in ( + "https://artifact.local/apps/cryptad.flatpak", + "https://packages.crypta.network/apps/cryptad.flatpak", + ): + with self.subTest( + store_url=store_url + ), tempfile.TemporaryDirectory() as directory, mock.patch.object( + stable_1_0_ga_core.socket, + "getaddrinfo", + return_value=private_resolution, + ): + root = Path(directory) + candidate = _candidate(root) + store_package = copy.deepcopy(candidate.assets[0]) + store_package.update( + { + "packageKey": "amd64.flatpak", + "fileName": "cryptad-amd64.flatpak", + "packageType": "flatpak", + "digest": _digest("f"), + "publicChk": None, + "storeUrl": store_url, + } + ) + candidate.assets.append(store_package) + state = ValidationState() + + build_core_info(_context(root), candidate, state) + + self.assertTrue( + any("unsafe store URL" in row["summary"] for row in state.blockers), + state.blockers, + ) + + def test_publication_targets_reject_privately_resolved_dns_name(self) -> None: + private_resolution = [(None, None, None, None, ("192.168.50.10", 443))] + with tempfile.TemporaryDirectory() as directory, mock.patch.object( + stable_1_0_ga_core.socket, + "getaddrinfo", + return_value=private_resolution, + ): + context = _context(Path(directory)) + context.manifest.policies["metadata"]["catalogPrimaryUri"] = ( + "https://catalog.crypta.network/stable/catalog.json" + ) + state = ValidationState() + + stable_1_0_maintenance._targets(context, state) # noqa: SLF001 + + self.assertTrue( + any("public credential-free HTTPS" in row["summary"] for row in state.blockers), + state.blockers, + ) + + def test_publication_urls_reject_private_or_reserved_hosts(self) -> None: + for value in ( + "https://127.0.0.1/release", + "https://10.0.0.1/release", + "https://artifact.local/release", + "https://artifact.invalid/release", + "https://user:password@artifacts.crypta.network/release", + ): + with self.subTest(value=value): + self.assertFalse(stable_1_0_maintenance._https(value)) + public_resolution = [(None, None, None, None, ("93.184.216.34", 443))] + with mock.patch.object( + stable_1_0_ga_core.socket, + "getaddrinfo", + return_value=public_resolution, + ): + self.assertTrue( + stable_1_0_maintenance._https( + "https://github.com/crypta-network/cryptad/releases/tag/v301" + ) + ) + + +class StableMaintenanceCompatibilityTest(unittest.TestCase): + """Long-term GA and immediate-predecessor compatibility policy tests.""" + + def test_valid_comparison_is_go_and_deterministic(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ga, predecessor = _ga_and_predecessor() + candidate = _candidate(root) + state = ValidationState() + first = build_comparison( + _context(root), ga, predecessor, candidate, _policy(), state + ) + second = build_comparison( + _context(root), + ga, + predecessor, + candidate, + _policy(), + ValidationState(), + ) + self.assertEqual(state.blockers, []) + self.assertTrue(first["compatible"]) + self.assertEqual(first["decision"], "go") + self.assertEqual(semantic_digest(first), semantic_digest(second)) + + def test_platform_api_negative_gates(self) -> None: + ga = _ga_platform_api() + predecessor = copy.deepcopy(ga) + cases = ( + ("endpoint removal", "removedStableEndpoints", ["/v1/apps"]), + ("capability removal", "removedStableCapabilities", ["app-data"]), + ("deprecation reset", "deprecationClockReset", True), + ("critical waiver", "criticalRemovalWaiverAttempt", True), + ("experimental mislabel", "experimentalMislabelledStable", True), + ("incompatible addition", "additionsBackwardCompatible", False), + ) + for label, field, value in cases: + with self.subTest(label=label): + candidate = _platform_api() + candidate[field] = value + self.assertTrue(_platform_api_errors(ga, predecessor, candidate)) + + changed_surface = _platform_api() + changed_surface["stableSurfaceDigest"] = _digest("f") + self.assertTrue(_platform_api_errors(ga, predecessor, changed_surface)) + + def test_platform_api_deprecation_history_preserves_original_clocks(self) -> None: + ga = _ga_platform_api() + predecessor = copy.deepcopy(ga) + first_history = [ + { + "kind": "endpoint", + "identity": "GET /api/v1/example", + "stability": "deprecated", + "deprecatedSinceContractVersion": 24, + "removalContractVersion": None, + } + ] + candidate = _platform_api() + candidate["deprecationHistory"] = first_history + candidate["deprecationHistoryDigest"] = semantic_digest(first_history) + self.assertEqual(_platform_api_errors(ga, predecessor, candidate), []) + + predecessor = copy.deepcopy(candidate) + predecessor["currentContractVersion"] = 24 + successor = copy.deepcopy(candidate) + successor["currentContractVersion"] = 25 + successor["currentContractDigest"] = _digest("a") + self.assertEqual(_platform_api_errors(ga, predecessor, successor), []) + + reset = copy.deepcopy(successor) + reset["deprecationHistory"][0]["deprecatedSinceContractVersion"] = 25 + reset["deprecationHistoryDigest"] = semantic_digest(reset["deprecationHistory"]) + self.assertTrue(_platform_api_errors(ga, predecessor, reset)) + + backdated = copy.deepcopy(successor) + backdated["deprecationHistory"].append( + { + "kind": "capability", + "identity": "example.read", + "stability": "deprecated", + "deprecatedSinceContractVersion": 24, + "removalContractVersion": None, + } + ) + backdated["deprecationHistoryDigest"] = semantic_digest( + backdated["deprecationHistory"] + ) + self.assertTrue(_platform_api_errors(ga, predecessor, backdated)) + + shortened = copy.deepcopy(successor) + shortened["deprecationHistory"][0].update( + { + "stability": "scheduled-for-removal", + "removalContractVersion": 26, + } + ) + shortened["deprecationHistoryDigest"] = semantic_digest( + shortened["deprecationHistory"] + ) + self.assertTrue(_platform_api_errors(ga, predecessor, shortened)) + + wrong_digest = copy.deepcopy(successor) + wrong_digest["deprecationHistoryDigest"] = _digest("b") + self.assertTrue(_platform_api_errors(ga, predecessor, wrong_digest)) + + def test_content_profile_drift_and_rejection_fail(self) -> None: + ga = _profiles(candidate=False) + predecessor = copy.deepcopy(ga) + for field, value in ( + ("canonicalizationRulesDigest", _digest("f")), + ("signaturePayloadRulesDigest", _digest("0")), + ("existingValidDocumentsAccepted", False), + ("acceptanceStatus", "fail"), + ): + with self.subTest(field=field): + candidate = _profiles(candidate=True) + candidate[0][field] = value + self.assertTrue(_content_profile_errors(ga, predecessor, candidate)) + + def test_catalog_app_and_limitation_negative_gates(self) -> None: + ga_catalog = _catalog() + ga_catalog.update({"edition": 7, "revision": 7}) + predecessor_catalog = copy.deepcopy(ga_catalog) + predecessor_catalog["catalogDigest"] = predecessor_catalog.pop("digest") + candidate_value = {"stableCatalog": _catalog()} + for field, value in ( + ("channel", "beta"), + ("revision", 6), + ("signatureStatus", "fail"), + ("mirrorStatus", "fail"), + ): + with self.subTest(catalog_field=field): + current = copy.deepcopy(candidate_value) + current["stableCatalog"][field] = value + self.assertTrue(_catalog_errors(ga_catalog, predecessor_catalog, current)) + rotated = copy.deepcopy(candidate_value) + rotated["stableCatalog"]["signingKeyId"] = "catalog-production-2027" + self.assertTrue(_catalog_errors(ga_catalog, predecessor_catalog, rotated)) + rotated["stableCatalog"]["keyRotationTrustTransitionStatus"] = "complete" + self.assertEqual(_catalog_errors(ga_catalog, predecessor_catalog, rotated), []) + unchanged_version = copy.deepcopy(candidate_value) + unchanged_version["stableCatalog"].update({"edition": 7, "revision": 7}) + self.assertEqual( + _catalog_errors(ga_catalog, predecessor_catalog, unchanged_version), [] + ) + for identity_field in ("digest", "signatureDigest", "signingKeyId"): + with self.subTest(catalog_identity_field=identity_field): + changed_identity = copy.deepcopy(unchanged_version) + changed_identity["stableCatalog"][identity_field] = ( + "catalog-production-2027" + if identity_field == "signingKeyId" + else _digest("9") + ) + if identity_field == "signingKeyId": + changed_identity["stableCatalog"][ + "keyRotationTrustTransitionStatus" + ] = "complete" + self.assertTrue( + _catalog_errors(ga_catalog, predecessor_catalog, changed_identity) + ) + changed_identity["stableCatalog"]["revision"] = 8 + self.assertEqual( + _catalog_errors(ga_catalog, predecessor_catalog, changed_identity), [] + ) + for mutate in ( + lambda rows: rows.pop(), + lambda rows: rows[0].__setitem__("channel", "beta"), + lambda rows: rows[0].__setitem__("supportLevel", "local-rc"), + lambda rows: rows[0].__setitem__("reviewedBundleDigest", _digest("f")), + lambda rows: rows[0].update({"permissionExpansion": True, "permissionConsentStatus": "fail"}), + ): + rows = _apps(candidate=True) + mutate(rows) + self.assertTrue( + _app_compatibility_errors( + _apps(candidate=False), _apps(candidate=False), rows + ) + ) + predecessor_apps = _apps(candidate=False) + predecessor_apps[0]["appDataSchemaVersion"] = 3 + downgraded_apps = _apps(candidate=True) + downgraded_apps[0]["appDataSchemaVersion"] = 2 + self.assertTrue( + _app_compatibility_errors( + _apps(candidate=False), predecessor_apps, downgraded_apps + ) + ) + downgraded_apps[0]["appDataSchemaVersion"] = "not-applicable" + self.assertTrue( + _app_compatibility_errors( + _apps(candidate=False), predecessor_apps, downgraded_apps + ) + ) + upgraded_apps = _apps(candidate=True) + upgraded_apps[0]["appDataSchemaVersion"] = 4 + self.assertEqual( + _app_compatibility_errors( + _apps(candidate=False), predecessor_apps, upgraded_apps + ), + [], + ) + ga_limitations = { + "allowedLimitations": [{"id": "stable-known-001"}] + } + predecessor_limitations = copy.deepcopy(ga_limitations) + limitations = _candidate_input()["limitations"] + assert isinstance(limitations, dict) + limitations["addedCount"] = 1 + self.assertTrue( + _limitation_errors(ga_limitations, predecessor_limitations, limitations) + ) + + def test_first_party_app_versions_track_update_selection_semantics(self) -> None: + ga_apps = _apps(candidate=False) + predecessor_apps = _apps(candidate=False) + candidate_apps = _apps(candidate=True) + for rows in (ga_apps, predecessor_apps, candidate_apps): + rows[0]["version"] = "1.9.0" + self.assertEqual( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps), [] + ) + + candidate_apps[0]["bundleDigest"] = _digest("9") + candidate_apps[0]["reviewedBundleDigest"] = _digest("9") + candidate_apps[0]["version"] = "1.10.0" + self.assertEqual( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps), [] + ) + + for version in ("1.9.0", "1.9", "1.8.99"): + with self.subTest(changed_bundle_version=version): + candidate_apps[0]["version"] = version + self.assertTrue( + _app_compatibility_errors( + ga_apps, predecessor_apps, candidate_apps + ) + ) + + candidate_apps[0]["bundleDigest"] = ga_apps[0]["bundleDigest"] + candidate_apps[0]["reviewedBundleDigest"] = ga_apps[0]["bundleDigest"] + candidate_apps[0]["version"] = "1.8.99" + self.assertTrue( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps) + ) + + candidate_apps[0]["version"] = "1.9.1-hotfix" + self.assertTrue( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps) + ) + + predecessor_apps[0]["version"] = "1.9.0-rc1" + candidate_apps[0]["version"] = "1.10.0" + self.assertTrue( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps) + ) + + ga_apps[0]["version"] = "2.0.0" + predecessor_apps[0]["version"] = "1.9.0" + candidate_apps[0]["version"] = "1.10.0" + self.assertTrue( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps) + ) + + candidate_apps[0]["version"] = "9" * 5_000 + self.assertTrue( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps) + ) + + def test_first_party_app_support_commitment_is_non_decreasing(self) -> None: + ga_apps = _apps(candidate=False) + predecessor_apps = _apps(candidate=False) + candidate_apps = _apps(candidate=True) + ga_apps[0]["supportLevel"] = "local-rc" + predecessor_apps[0]["supportLevel"] = "maintained" + candidate_apps[0]["supportLevel"] = "core" + + self.assertEqual( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps), [] + ) + + candidate_apps[0]["supportLevel"] = "maintained" + self.assertEqual( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps), [] + ) + + candidate_apps[0]["supportLevel"] = "local-rc" + self.assertTrue( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps) + ) + + ga_apps[0]["supportLevel"] = "core" + candidate_apps[0]["supportLevel"] = "maintained" + self.assertTrue( + _app_compatibility_errors(ga_apps, predecessor_apps, candidate_apps) + ) + + def test_first_party_app_support_commitment_rejects_unknown_levels(self) -> None: + for anchor in ("ga", "predecessor", "candidate"): + with self.subTest(anchor=anchor): + ga_apps = _apps(candidate=False) + predecessor_apps = _apps(candidate=False) + candidate_apps = _apps(candidate=True) + selected = { + "ga": ga_apps, + "predecessor": predecessor_apps, + "candidate": candidate_apps, + }[anchor] + selected[0]["supportLevel"] = "unknown-support" + + self.assertTrue( + _app_compatibility_errors( + ga_apps, predecessor_apps, candidate_apps + ) + ) + + malformed_policy = _policy() + malformed_policy["catalogAndApps"]["supportLevelOrder"] = [ + "local-rc", + "maintained", + ] + self.assertTrue( + _app_errors( + _apps(candidate=False), + _apps(candidate=False), + _apps(candidate=True), + malformed_policy, + ) + ) + + def test_limitation_delta_is_an_exact_authenticated_membership_transition(self) -> None: + predecessor_ids = {"stable-known-001", "stable-known-002"} + ga_limitations = { + "allowedLimitations": [ + {"id": limitation_id} for limitation_id in sorted(predecessor_ids) + ] + } + predecessor_limitations = copy.deepcopy(ga_limitations) + valid = _limitations( + predecessor_ids, + added_ids={"stable-known-003"}, + resolved_ids={"stable-known-001"}, + ) + self.assertEqual( + _limitation_errors(ga_limitations, predecessor_limitations, valid), [] + ) + unsorted_delta = _limitations( + predecessor_ids, + added_ids={"stable-known-003", "stable-known-004"}, + ) + unsorted_delta["addedIds"] = list(reversed(unsorted_delta["addedIds"])) + self.assertTrue( + _limitation_errors( + ga_limitations, predecessor_limitations, unsorted_delta + ) + ) + + mutations = { + "overlap": lambda value: value["addedIds"].append("stable-known-002"), + "invented-unchanged": lambda value: value["unchangedIds"].append( + "stable-known-999" + ), + "omitted-predecessor": lambda value: value["resolvedIds"].clear(), + "wrong-current-digest": lambda value: value.__setitem__( + "knownLimitationsDigest", _digest("8") + ), + "wrong-delta-digest": lambda value: value.__setitem__( + "deltaDigest", _digest("9") + ), + } + for label, mutate in mutations.items(): + with self.subTest(mutation=label): + invalid = copy.deepcopy(valid) + mutate(invalid) + if label in {"overlap", "invented-unchanged"}: + invalid["addedCount"] = len(invalid["addedIds"]) + invalid["unchangedCount"] = len(invalid["unchangedIds"]) + self.assertTrue( + _limitation_errors( + ga_limitations, predecessor_limitations, invalid + ) + ) + + successor_anchor = { + "currentDigest": _known_limitations_digest( + {"stable-known-002", "stable-known-003"} + ), + "currentIds": ["stable-known-002", "stable-known-003"], + "gaBaselineDigest": _digest("a"), + "predecessorDigest": _digest("b"), + } + next_delta = _limitations( + {"stable-known-002", "stable-known-003"}, + resolved_ids={"stable-known-002"}, + ) + self.assertEqual( + _limitation_errors(ga_limitations, successor_anchor, next_delta), [] + ) + successor_anchor["currentIds"] = list( + reversed(successor_anchor["currentIds"]) + ) + self.assertTrue( + _limitation_errors(ga_limitations, successor_anchor, next_delta) + ) + successor_anchor["currentIds"].sort() + successor_anchor["currentDigest"] = _digest("c") + self.assertTrue( + _limitation_errors(ga_limitations, successor_anchor, next_delta) + ) + + +class StableMaintenanceEvidenceTest(unittest.TestCase): + """Normal evidence and narrowly expedited hotfix follow-up tests.""" + + def test_normal_evidence_is_candidate_and_predecessor_bound(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + evidence_path = root / "evidence.json" + write_json(evidence_path, _evidence()) + context = _context(root, inputs={"maintenanceEvidence": "evidence.json"}) + ga, predecessor = _ga_and_predecessor() + state = ValidationState() + value, digest, follow_up = validate_production_evidence( + context, ga, predecessor, _candidate(root), _policy(), state + ) + self.assertEqual(state.blockers, []) + self.assertEqual(digest, file_digest(evidence_path)) + self.assertEqual(value["candidateProductDigest"], PRODUCT_DIGEST) + self.assertIsNone(follow_up) + + def test_later_maintenance_binds_direct_upgrade_to_authenticated_ga(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ga, predecessor = _ga_and_predecessor() + predecessor = dataclasses.replace( + predecessor, + release_id="stable-1-0-maintenance-301", + build_version="301", + product_digest=_digest("a"), + chain_depth=1, + ) + evidence = _evidence() + evidence.update( + { + "releaseId": "stable-1-0-maintenance-302", + "buildVersion": "302", + "predecessorBuild": predecessor.build_version, + "predecessorProductDigest": predecessor.product_digest, + } + ) + direct_ga_row = None + for row in evidence["evidenceRows"]: + row.update( + { + "candidateReleaseId": "stable-1-0-maintenance-302", + "candidateBuild": "302", + "predecessorBuild": predecessor.build_version, + "predecessorProductDigest": predecessor.product_digest, + } + ) + if row["evidenceId"] == "stable-maintenance.direct-ga-upgrade": + direct_ga_row = row + assert direct_ga_row is not None + context = _context(root, inputs={"maintenanceEvidence": "evidence.json"}) + context = dataclasses.replace( + context, + manifest=dataclasses.replace( + context.manifest, + release=ReleaseSpec( + "stable-1-0-maintenance-302", "302", "stable-review" + ), + ), + ) + write_json(root / "evidence.json", evidence) + + accepted = ValidationState() + validate_production_evidence( + context, ga, predecessor, _candidate(root), _policy(), accepted + ) + + self.assertEqual(accepted.blockers, []) + self.assertEqual(direct_ga_row["gaReleaseId"], ga.release_id) + self.assertEqual(direct_ga_row["gaBuild"], ga.build_version) + self.assertEqual(direct_ga_row["gaProductDigest"], ga.product_digest) + + direct_ga_row["gaProductDigest"] = predecessor.product_digest + write_json(root / "evidence.json", evidence) + rejected = ValidationState() + validate_production_evidence( + context, ga, predecessor, _candidate(root), _policy(), rejected + ) + + self.assertTrue(rejected.blockers) + + def test_evidence_rejects_fixture_stale_wrong_candidate_and_failed_rows(self) -> None: + mutations = ( + ("fixture", lambda value: value.__setitem__("fixtureOnly", True)), + ("wrong candidate", lambda value: value.__setitem__("candidateProductDigest", _digest("f"))), + ("wrong freeze", lambda value: value.__setitem__("candidateFreezeDigest", _digest("e"))), + ( + "wrong row freeze", + lambda value: value["evidenceRows"][0].__setitem__( + "candidateFreezeDigest", _digest("e") + ), + ), + ("stale row", lambda value: value["evidenceRows"][0].__setitem__("fresh", False)), + ("failed row", lambda value: value["evidenceRows"][0].__setitem__("status", "fail")), + ) + for label, mutate in mutations: + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + value = _evidence() + mutate(value) + path = root / "evidence.json" + write_json(path, value) + state = ValidationState() + validate_production_evidence( + _context(root, inputs={"maintenanceEvidence": "evidence.json"}), + _ga_and_predecessor()[0], + _ga_and_predecessor()[1], + _candidate(root), + _policy(), + state, + ) + self.assertTrue(state.blockers) + + def test_warned_evidence_requires_exact_frozen_warning_and_waiver(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + evidence = _evidence() + performance = next( + row + for row in evidence["evidenceRows"] + if row["evidenceId"] == "stable-maintenance.performance" + ) + performance["status"] = "warn" + candidate = _candidate(root) + warning = { + "warningId": "stable-maintenance.performance-comparable-runner-warning", + "status": "warn", + "evidenceDigest": performance["evidenceDigest"], + } + candidate.input_value["operationalWarnings"] = [warning] + write_json(root / "evidence.json", evidence) + context = _context(root, inputs={"maintenanceEvidence": "evidence.json"}) + ga, predecessor = _ga_and_predecessor() + state = ValidationState() + + validate_production_evidence( + context, ga, predecessor, candidate, _policy(), state + ) + expected = _authorization_expected( + context, + ga, + predecessor, + candidate, + _digest("2"), + _digest("3"), + _digest("4"), + _digest("5"), + _digest("6"), + _digest("7"), + _digest("8"), + None, + ) + + self.assertEqual(state.blockers, []) + self.assertEqual( + ["stable-maintenance.performance-comparable-runner-warning"], + expected["acceptedWarningIds"], + ) + self.assertEqual( + ["stable-maintenance.performance-comparable-runner-warning"], + [row["id"] for row in state.warnings], + ) + + def test_warned_evidence_rejects_missing_or_wrong_frozen_binding(self) -> None: + cases = ( + ("missing", None), + ("wrong digest", _digest("f")), + ) + for label, warning_digest in cases: + with self.subTest(label=label), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + evidence = _evidence() + performance = next( + row + for row in evidence["evidenceRows"] + if row["evidenceId"] == "stable-maintenance.performance" + ) + performance["status"] = "warn" + candidate = _candidate(root) + if warning_digest is not None: + candidate.input_value["operationalWarnings"] = [ + { + "warningId": "stable-maintenance.performance-comparable-runner-warning", + "status": "warn", + "evidenceDigest": warning_digest, + } + ] + write_json(root / "evidence.json", evidence) + state = ValidationState() + + validate_production_evidence( + _context(root, inputs={"maintenanceEvidence": "evidence.json"}), + _ga_and_predecessor()[0], + _ga_and_predecessor()[1], + candidate, + _policy(), + state, + ) + + self.assertTrue(state.blockers) + + def test_declared_warning_rejects_nonwarned_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + evidence = _evidence() + performance = next( + row + for row in evidence["evidenceRows"] + if row["evidenceId"] == "stable-maintenance.performance" + ) + candidate = _candidate(root) + candidate.input_value["operationalWarnings"] = [ + { + "warningId": "stable-maintenance.performance-comparable-runner-warning", + "status": "warn", + "evidenceDigest": performance["evidenceDigest"], + } + ] + write_json(root / "evidence.json", evidence) + state = ValidationState() + + validate_production_evidence( + _context(root, inputs={"maintenanceEvidence": "evidence.json"}), + _ga_and_predecessor()[0], + _ga_and_predecessor()[1], + candidate, + _policy(), + state, + ) + + self.assertTrue(state.blockers) + + def test_normal_evidence_rejects_short_individual_observation_window(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + evidence = _evidence() + rows = evidence["evidenceRows"] + assert isinstance(rows, list) + live_row = next( + row + for row in rows + if row["evidenceId"] + == "stable-maintenance.live-network-interoperability" + ) + live_row["startedAt"] = _timestamp(NOW - timedelta(hours=1)) + path = root / "evidence.json" + write_json(path, evidence) + state = ValidationState() + validate_production_evidence( + _context(root, inputs={"maintenanceEvidence": "evidence.json"}), + _ga_and_predecessor()[0], + _ga_and_predecessor()[1], + _candidate(root), + _policy(), + state, + ) + self.assertTrue(state.blockers) + + def test_evidence_rejects_pre_freeze_aggregate_and_rows(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + evidence = _evidence() + before_freeze = _timestamp(FROZEN - timedelta(hours=1)) + evidence["validationStartedAt"] = before_freeze + for row in evidence["evidenceRows"]: + row["startedAt"] = before_freeze + write_json(root / "evidence.json", evidence) + state = ValidationState() + + validate_production_evidence( + _context(root, inputs={"maintenanceEvidence": "evidence.json"}), + _ga_and_predecessor()[0], + _ga_and_predecessor()[1], + _candidate(root), + _policy(), + state, + ) + + self.assertTrue(state.blockers) + + def test_shortened_hotfix_window_creates_closed_obligation(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root, "security-hotfix") + evidence = _evidence("security-hotfix", window="shortened-security-hotfix") + state = ValidationState() + obligation = _hotfix_follow_up( + _context(root, release_class="security-hotfix"), + candidate, + evidence, + _policy(), + NOW, + state, + ) + self.assertEqual(state.blockers, []) + self.assertEqual(obligation["status"], "open") + self.assertTrue(obligation["blocksRoutineMaintenance"]) + self.assertEqual(obligation["predecessorBuild"], PREDECESSOR_BUILD) + self.assertEqual( + obligation["predecessorProductDigest"], PREDECESSOR_PRODUCT_DIGEST + ) + self.assertEqual(validate_schema(obligation, core.FOLLOW_UP_SCHEMA), []) + without_predecessor = copy.deepcopy(obligation) + without_predecessor.pop("predecessorBuild") + self.assertTrue(validate_schema(without_predecessor, core.FOLLOW_UP_SCHEMA)) + + def test_hotfix_follow_up_closes_only_with_full_production_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root, "security-hotfix") + evidence = _evidence("security-hotfix") + obligation = _hotfix_follow_up( + _context(root, release_class="security-hotfix"), + candidate, + evidence, + _policy(), + NOW, + ValidationState(), + ) + obligation_path = root / "obligation.json" + evidence_path = root / "follow-up.json" + policy_path = root / "maintenance-policy.json" + write_json(obligation_path, obligation) + write_json(evidence_path, evidence) + write_json(policy_path, _policy()) + context = _context( + root, + release_class="security-hotfix", + mode="close-hotfix-follow-up", + inputs={ + "hotfixFollowUpObligation": "obligation.json", + "hotfixFollowUpEvidence": "follow-up.json", + "maintenancePolicy": "maintenance-policy.json", + }, + ) + state = ValidationState() + closure = close_hotfix_follow_up( + context, _ga_and_predecessor()[0], candidate, state + ) + self.assertEqual(state.blockers, []) + self.assertEqual(closure["status"], "closed") + evidence["fixtureOnly"] = True + write_json(evidence_path, evidence) + rejected = close_hotfix_follow_up( + context, _ga_and_predecessor()[0], candidate, ValidationState() + ) + self.assertEqual(rejected["status"], "rejected") + + def test_carried_hotfix_follow_up_closes_original_obligated_release(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + latest_candidate = _candidate(root, "security-hotfix") + evidence = _evidence("security-hotfix") + obligation = _hotfix_follow_up( + _context(root, release_class="security-hotfix"), + latest_candidate, + evidence, + _policy(), + NOW, + ValidationState(), + ) + original_release = "stable-1-0-hotfix-300" + original_build = "300" + original_product = _digest("a") + original_identity = _digest("b") + original_freeze = _digest("c") + original_predecessor_build = "299" + original_predecessor_product = _digest("d") + obligation.update( + { + "releaseId": original_release, + "buildVersion": original_build, + "productDigest": original_product, + "candidateIdentityDigest": original_identity, + "candidateFreezeDigest": original_freeze, + "predecessorBuild": original_predecessor_build, + "predecessorProductDigest": original_predecessor_product, + } + ) + evidence.update( + { + "releaseId": original_release, + "buildVersion": original_build, + "candidateProductDigest": original_product, + "candidateFreezeDigest": original_freeze, + "predecessorBuild": original_predecessor_build, + "predecessorProductDigest": original_predecessor_product, + } + ) + for row in evidence["evidenceRows"]: + row.update( + { + "candidateReleaseId": original_release, + "candidateBuild": original_build, + "candidateProductDigest": original_product, + "candidateFreezeDigest": original_freeze, + "predecessorBuild": original_predecessor_build, + "predecessorProductDigest": original_predecessor_product, + } + ) + write_json(root / "obligation.json", obligation) + write_json(root / "follow-up.json", evidence) + write_json(root / "maintenance-policy.json", _policy()) + context = _context( + root, + release_class="security-hotfix", + mode="close-hotfix-follow-up", + inputs={ + "hotfixFollowUpObligation": "obligation.json", + "hotfixFollowUpEvidence": "follow-up.json", + "maintenancePolicy": "maintenance-policy.json", + }, + ) + state = ValidationState() + + closure = close_hotfix_follow_up( + context, _ga_and_predecessor()[0], latest_candidate, state + ) + + self.assertEqual(state.blockers, []) + self.assertEqual(closure["status"], "closed") + self.assertEqual(closure["releaseId"], original_release) + self.assertEqual(closure["buildVersion"], original_build) + self.assertEqual(closure["productDigest"], original_product) + self.assertEqual(closure["candidateIdentityDigest"], original_identity) + self.assertEqual(closure["predecessorBuild"], original_predecessor_build) + self.assertEqual( + closure["predecessorProductDigest"], original_predecessor_product + ) + + def test_hotfix_follow_up_rejects_substituted_original_predecessor(self) -> None: + mutations = ( + lambda evidence, _row: evidence.__setitem__("predecessorBuild", "299"), + lambda _evidence, row: row.__setitem__( + "predecessorProductDigest", _digest("e") + ), + ) + for mutate in mutations: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root, "security-hotfix") + evidence = _evidence("security-hotfix") + obligation = _hotfix_follow_up( + _context(root, release_class="security-hotfix"), + candidate, + evidence, + _policy(), + NOW, + ValidationState(), + ) + required_id = obligation["fullEvidenceRequired"][0] + required_row = next( + row + for row in evidence["evidenceRows"] + if row["evidenceId"] == required_id + ) + mutate(evidence, required_row) + write_json(root / "obligation.json", obligation) + write_json(root / "follow-up.json", evidence) + write_json(root / "maintenance-policy.json", _policy()) + context = _context( + root, + release_class="security-hotfix", + mode="close-hotfix-follow-up", + inputs={ + "hotfixFollowUpObligation": "obligation.json", + "hotfixFollowUpEvidence": "follow-up.json", + "maintenancePolicy": "maintenance-policy.json", + }, + ) + state = ValidationState() + + closure = close_hotfix_follow_up( + context, _ga_and_predecessor()[0], candidate, state + ) + + self.assertEqual(closure["status"], "rejected") + self.assertTrue(state.blockers) + + def test_hotfix_follow_up_rejects_substituted_ga_upgrade_source(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root, "security-hotfix") + evidence = _evidence("security-hotfix") + obligation = _hotfix_follow_up( + _context(root, release_class="security-hotfix"), + candidate, + evidence, + _policy(), + NOW, + ValidationState(), + ) + direct_ga_row = next( + row + for row in evidence["evidenceRows"] + if row["evidenceId"] == "stable-maintenance.direct-ga-upgrade" + ) + direct_ga_row["gaProductDigest"] = _digest("e") + write_json(root / "obligation.json", obligation) + write_json(root / "follow-up.json", evidence) + write_json(root / "maintenance-policy.json", _policy()) + context = _context( + root, + release_class="security-hotfix", + mode="close-hotfix-follow-up", + inputs={ + "hotfixFollowUpObligation": "obligation.json", + "hotfixFollowUpEvidence": "follow-up.json", + "maintenancePolicy": "maintenance-policy.json", + }, + ) + state = ValidationState() + + closure = close_hotfix_follow_up( + context, _ga_and_predecessor()[0], candidate, state + ) + + self.assertEqual(closure["status"], "rejected") + self.assertTrue(state.blockers) + + def test_hotfix_follow_up_rejects_short_obligated_row_in_long_aggregate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root, "security-hotfix") + evidence = _evidence("security-hotfix") + obligation = _hotfix_follow_up( + _context(root, release_class="security-hotfix"), + candidate, + evidence, + _policy(), + NOW, + ValidationState(), + ) + obligated_id = obligation["fullEvidenceRequired"][0] + row = next( + item + for item in evidence["evidenceRows"] + if item["evidenceId"] == obligated_id + ) + row["startedAt"] = _timestamp(NOW - timedelta(hours=1)) + write_json(root / "obligation.json", obligation) + write_json(root / "follow-up.json", evidence) + write_json(root / "maintenance-policy.json", _policy()) + context = _context( + root, + release_class="security-hotfix", + mode="close-hotfix-follow-up", + inputs={ + "hotfixFollowUpObligation": "obligation.json", + "hotfixFollowUpEvidence": "follow-up.json", + "maintenancePolicy": "maintenance-policy.json", + }, + ) + state = ValidationState() + + closure = close_hotfix_follow_up( + context, _ga_and_predecessor()[0], candidate, state + ) + + self.assertEqual(closure["status"], "rejected") + self.assertTrue(state.blockers) + + def test_hotfix_follow_up_rejects_future_dated_full_window(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root, "security-hotfix") + evidence = _evidence("security-hotfix") + obligation = _hotfix_follow_up( + _context(root, release_class="security-hotfix"), + candidate, + evidence, + _policy(), + NOW, + ValidationState(), + ) + future_start = NOW + timedelta(days=30) + future_end = future_start + timedelta(days=1) + evidence["validationStartedAt"] = _timestamp(future_start) + evidence["validationEndedAt"] = _timestamp(future_end) + for row in evidence["evidenceRows"]: + row["startedAt"] = _timestamp(future_start) + row["endedAt"] = _timestamp(future_end) + write_json(root / "obligation.json", obligation) + write_json(root / "follow-up.json", evidence) + write_json(root / "maintenance-policy.json", _policy()) + context = _context( + root, + release_class="security-hotfix", + mode="close-hotfix-follow-up", + inputs={ + "hotfixFollowUpObligation": "obligation.json", + "hotfixFollowUpEvidence": "follow-up.json", + "maintenancePolicy": "maintenance-policy.json", + }, + ) + + closure = close_hotfix_follow_up( + context, _ga_and_predecessor()[0], candidate, ValidationState() + ) + + self.assertEqual(closure["status"], "rejected") + + def test_hotfix_follow_up_rejects_closure_when_an_earlier_gate_failed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root, "security-hotfix") + evidence = _evidence("security-hotfix") + obligation = _hotfix_follow_up( + _context(root, release_class="security-hotfix"), + candidate, + evidence, + _policy(), + NOW, + ValidationState(), + ) + write_json(root / "obligation.json", obligation) + write_json(root / "follow-up.json", evidence) + write_json(root / "maintenance-policy.json", _policy()) + context = _context( + root, + release_class="security-hotfix", + mode="close-hotfix-follow-up", + inputs={ + "hotfixFollowUpObligation": "obligation.json", + "hotfixFollowUpEvidence": "follow-up.json", + "maintenancePolicy": "maintenance-policy.json", + }, + ) + state = ValidationState() + state.block( + "stable-maintenance.authorization", + "stable-maintenance.authorization", + "The original publication authorization did not match.", + "Use the exact published authorization.", + ) + + closure = close_hotfix_follow_up( + context, _ga_and_predecessor()[0], candidate, state + ) + + self.assertEqual(closure["status"], "rejected") + + +class StableMaintenanceAuthorizationAndPublicationTest(unittest.TestCase): + """Authorization, idempotency, conflict, and successor identity tests.""" + + def test_public_checksums_name_only_noncircular_public_payloads(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + candidate = _candidate(root) + generated = { + core.RELEASE_NOTES_FILE: b"maintenance notes\n", + core.KNOWN_LIMITATIONS_FILE: b"{}\n", + core.PROVENANCE_FILE: b"{}\n", + core.CORE_INFO_FILE: b"{}\n", + core.AUTHORIZATION_FILE: b"{}\n", + } + for name, payload in generated.items(): + (root / name).write_bytes(payload) + + payload_paths = _public_checksum_payload_paths(candidate, root) + _write_checksums(root / core.CHECKSUMS_FILE, payload_paths.values()) + planned_assets = _public_assets( + candidate, + root, + "https://downloads.crypta.network/stable/", + root / core.AUTHORIZATION_FILE, + ) + + planned_names = {row["fileName"] for row in planned_assets} + noncircular_names = planned_names - { + core.CHECKSUMS_FILE, + core.AUTHORIZATION_FILE, + } + checksum_names = { + line.split(" ", 1)[1] + for line in (root / core.CHECKSUMS_FILE) + .read_text(encoding="utf-8") + .splitlines() + } + internal_names = { + core.CANDIDATE_FREEZE_FILE, + core.CANDIDATE_FILE, + core.LINEAGE_FILE, + core.COMPARISON_FILE, + core.FOLLOW_UP_FILE, + core.PUBLICATION_PLAN_FILE, + core.AUDIT_CHECKSUMS_FILE, + } + + self.assertEqual(checksum_names, noncircular_names) + self.assertTrue(internal_names.isdisjoint(checksum_names)) + self.assertNotIn(core.AUDIT_CHECKSUMS_FILE, planned_names) + + def test_canonical_authorization_input_preserves_unescaped_unicode(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "authorization.json" + value = {"approverIdentity": "José stable-release-manager"} + path.write_bytes( + ( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + + "\n" + ).encode("utf-8") + ) + + self.assertTrue( + stable_1_0_maintenance._canonical_json_input(path, value) # noqa: SLF001 + ) + self.assertIn("José", path.read_text(encoding="utf-8")) + self.assertNotIn(r"\u00e9", path.read_text(encoding="utf-8")) + + path.write_bytes( + ( + json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + + "\n" + ).encode("utf-8") + ) + self.assertFalse( + stable_1_0_maintenance._canonical_json_input(path, value) # noqa: SLF001 + ) + + def test_publication_targets_require_nonempty_catalog_mirror(self) -> None: + with tempfile.TemporaryDirectory() as directory: + context = _context(Path(directory)) + context.manifest.policies["metadata"]["catalogMirrorUris"] = " " + state = ValidationState() + + stable_1_0_maintenance._targets(context, state) # noqa: SLF001 + + self.assertTrue( + any( + "require at least one catalog mirror URI" in row["summary"] + for row in state.blockers + ), + state.blockers, + ) + + def test_publication_targets_require_the_fixed_github_release_page(self) -> None: + invalid_pages = ( + "https://93.184.216.34/releases/tag/v301", + "https://github.com/crypta-network/another/releases/tag/v301", + "https://github.com/crypta-network/cryptad/releases/tag/v302", + ) + for page_uri in invalid_pages: + with self.subTest(page_uri=page_uri), tempfile.TemporaryDirectory() as directory: + context = _context(Path(directory)) + context.manifest.policies["metadata"]["githubReleasePageUri"] = page_uri + state = ValidationState() + + stable_1_0_maintenance._targets(context, state) # noqa: SLF001 + + self.assertTrue( + any( + "fixed repository and build tag" in row["summary"] + for row in state.blockers + ), + state.blockers, + ) + + def test_publication_targets_canonicalize_authorities_before_distinctness( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + context = _context(Path(directory)) + metadata = context.manifest.policies["metadata"] + metadata["catalogPrimaryUri"] = ( + "https://CATALOG.CRYPTA.NETWORK:443/stable/catalog.json" + ) + metadata["catalogMirrorUris"] = ( + "https://catalog.crypta.network/stable/catalog.json" + ) + state = ValidationState() + + targets, _digest_value = stable_1_0_maintenance._targets( # noqa: SLF001 + context, state + ) + + self.assertEqual( + targets["catalogPrimaryUri"], + "https://catalog.crypta.network/stable/catalog.json", + ) + self.assertTrue( + any( + "ambiguous duplicate URI" in row["summary"] + for row in state.blockers + ), + state.blockers, + ) + + def test_publication_targets_reject_ambiguous_or_whitespace_paths(self) -> None: + unsafe_uris = ( + "https://catalog.crypta.network/stable/../history/300.json", + "https://catalog.crypta.network/stable/catalog copy.json", + "https://catalog.crypta.network/stable/%63atalog.json", + ) + for unsafe_uri in unsafe_uris: + with self.subTest( + unsafe_uri=unsafe_uri + ), tempfile.TemporaryDirectory() as directory: + context = _context(Path(directory)) + context.manifest.policies["metadata"]["catalogRollbackUri"] = unsafe_uri + state = ValidationState() + + stable_1_0_maintenance._targets(context, state) # noqa: SLF001 + + self.assertTrue(state.blockers) + + def test_concrete_publication_destinations_are_distinct(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root) + state = ValidationState() + targets, _targets_digest = stable_1_0_maintenance._targets( # noqa: SLF001 + context, state + ) + + errors = stable_1_0_maintenance._concrete_publication_destination_errors( # noqa: SLF001 + targets, _candidate(root) + ) + + self.assertEqual(state.blockers, []) + self.assertEqual(errors, []) + + def test_concrete_publication_destinations_keep_fixed_github_target_offline( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory, mock.patch.object( + stable_1_0_ga_core.socket, + "getaddrinfo", + side_effect=OSError("DNS unavailable"), + ): + root = Path(directory) + state = ValidationState() + targets, _targets_digest = stable_1_0_maintenance._targets( # noqa: SLF001 + _context(root), state + ) + + errors = stable_1_0_maintenance._concrete_publication_destination_errors( # noqa: SLF001 + targets, _candidate(root) + ) + + self.assertEqual(state.blockers, []) + self.assertEqual(errors, []) + + def test_catalog_signature_cannot_alias_core_update_descriptor(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root) + context.manifest.policies["metadata"]["coreUpdatePublicUri"] = ( + "https://93.184.216.34:443/catalog/stable/stable-catalog.json.sig" + ) + state = ValidationState() + targets, _targets_digest = stable_1_0_maintenance._targets( # noqa: SLF001 + context, state + ) + + errors = stable_1_0_maintenance._concrete_publication_destination_errors( # noqa: SLF001 + targets, _candidate(root) + ) + + self.assertEqual(state.blockers, []) + self.assertTrue( + any( + "core-update-descriptor" in error + and "stable-catalog-signature" in error + for error in errors + ), + errors, + ) + + def test_catalog_signature_cannot_alias_core_info_artifact(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root) + candidate = _candidate(root) + catalog = candidate.input_value["stableCatalog"] + assert isinstance(catalog, dict) + catalog["signatureFileName"] = core.CORE_INFO_FILE + state = ValidationState() + targets, _targets_digest = stable_1_0_maintenance._targets( # noqa: SLF001 + context, state + ) + + errors = stable_1_0_maintenance._concrete_publication_destination_errors( # noqa: SLF001 + targets, candidate + ) + + self.assertEqual(state.blockers, []) + self.assertTrue( + any( + "artifact:core-info" in error + and "artifact:stable-catalog-signature" in error + for error in errors + ), + errors, + ) + + def test_catalog_and_mirror_objects_cannot_alias_artifact_objects(self) -> None: + cases = ( + ("catalogPrimaryUri", "stable-catalog.json"), + ("catalogMirrorUris", "cryptad-301.tar.gz"), + ("catalogRollbackUri", core.CORE_INFO_FILE), + ) + for field, artifact_name in cases: + with self.subTest(field=field), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root) + context.manifest.policies["metadata"][field] = ( + f"https://93.184.216.34/artifacts/stable/{artifact_name}" + ) + state = ValidationState() + targets, _targets_digest = stable_1_0_maintenance._targets( # noqa: SLF001 + context, state + ) + + errors = stable_1_0_maintenance._concrete_publication_destination_errors( # noqa: SLF001 + targets, _candidate(root) + ) + + self.assertEqual(state.blockers, []) + self.assertTrue(errors) + + def test_authorization_expected_identity_is_exact_and_deterministic(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ga, predecessor = _ga_and_predecessor() + candidate = _candidate(root) + args = ( + _context(root), ga, predecessor, candidate, + _digest("2"), _digest("3"), _digest("4"), _digest("5"), + _digest("6"), _digest("7"), _digest("8"), None, + ) + first = _authorization_expected(*args) + second = _authorization_expected(*args) + self.assertEqual(first, second) + self.assertEqual(first["role"], "stable-maintenance-release-manager") + self.assertEqual(first["candidateFreezeDigest"], candidate.freeze_digest) + self.assertEqual(first["allowedPublicationScopes"], list(AUTHORIZATION_SCOPE)) + changed = dict(first) + changed["productDigest"] = _digest("f") + self.assertNotEqual(semantic_digest(first), semantic_digest(changed)) + + def test_authorization_request_requires_waiver_decision_for_warnings(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root, mode="prepare-authorization") + ga, predecessor = _ga_and_predecessor() + candidate = _candidate(root) + expected = _authorization_expected( + context, + ga, + predecessor, + candidate, + _digest("2"), + _digest("3"), + _digest("4"), + _digest("5"), + _digest("6"), + _digest("7"), + _digest("8"), + None, + ) + state = ValidationState() + + request, _request_digest, authorized = _authorization( + context, expected, _policy(), state, prepare=True + ) + + self.assertEqual(request["decisionRequired"], "go") + self.assertFalse(authorized) + + expected["acceptedWarningIds"] = [ + "stable-maintenance.performance-comparable-runner-warning" + ] + warned_request, _warned_digest, warned_authorized = _authorization( + context, expected, _policy(), state, prepare=True + ) + + self.assertEqual( + warned_request["decisionRequired"], "go-with-waivers" + ) + self.assertFalse(warned_authorized) + + def test_authorization_scope_is_accepted_by_closed_schema(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ga, predecessor = _ga_and_predecessor() + expected = _authorization_expected( + _context(root), ga, predecessor, _candidate(root), + _digest("2"), _digest("3"), _digest("4"), _digest("5"), + _digest("6"), _digest("7"), _digest("8"), None, + ) + authorization = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-authorization", + "authorizationId": "maintenance-301-authorization", + **expected, + "approverIdentity": "stable-maintenance-approver", + "authorizedAt": _timestamp(NOW - timedelta(minutes=5)), + "expiresAt": _timestamp(NOW + timedelta(hours=1)), + "decision": "go", + "status": "approved", + "redaction": _redaction(), + } + self.assertEqual(validate_schema(authorization, AUTHORIZATION_SCHEMA), []) + + def test_follow_up_closure_requires_exact_published_eligible_authorization(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root, release_class="security-hotfix") + ga, predecessor = _ga_and_predecessor() + candidate = _candidate(root, "security-hotfix") + expected = _authorization_expected( + context, + ga, + predecessor, + candidate, + _digest("2"), + _digest("3"), + _digest("4"), + _digest("5"), + _digest("6"), + _digest("7"), + _digest("8"), + _digest("9"), + ) + authorization = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-authorization", + "authorizationId": "hotfix-301-authorization", + **expected, + "approverIdentity": "stable-security-approver", + "authorizedAt": _timestamp(NOW - timedelta(minutes=5)), + "expiresAt": _timestamp(NOW + timedelta(hours=1)), + "decision": "go", + "status": "approved", + "redaction": _redaction(), + } + authorization_path = root / "authorization.json" + write_json(authorization_path, authorization) + loaded = LoadedJson( + "stableMaintenanceAuthorization", + authorization_path, + authorization, + file_digest(authorization_path), + ) + obligation = SimpleNamespace( + value={ + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "productDigest": candidate.product_digest, + "candidateIdentityDigest": candidate.identity_digest, + "candidateFreezeDigest": candidate.freeze_digest, + } + ) + published_follow_up = {"authorizationDigest": loaded.digest} + self.assertEqual( + _close_authorization_errors(loaded, obligation, published_follow_up), [] + ) + + replacement = copy.deepcopy(authorization) + replacement["approverIdentity"] = "replacement-approver" + write_json(root / "replacement.json", replacement) + replacement_loaded = LoadedJson( + "stableMaintenanceAuthorization", + root / "replacement.json", + replacement, + file_digest(root / "replacement.json"), + ) + self.assertTrue( + _close_authorization_errors(replacement_loaded, obligation, published_follow_up) + ) + + no_go = copy.deepcopy(authorization) + no_go["decision"] = "no-go" + write_json(root / "no-go.json", no_go) + no_go_loaded = LoadedJson( + "stableMaintenanceAuthorization", + root / "no-go.json", + no_go, + file_digest(root / "no-go.json"), + ) + no_go_follow_up = {"authorizationDigest": no_go_loaded.digest} + self.assertTrue( + _close_authorization_errors(no_go_loaded, obligation, no_go_follow_up) + ) + + def _core_receipt_fixture( + self, root: Path, operation: str + ) -> tuple[ + RunContext, + LoadedJson, + Candidate, + Path, + dict[str, object], + Path, + dict[str, object], + ]: + context = _context(root) + candidate = _candidate(root) + descriptor = build_core_info(context, candidate, ValidationState())[0] + core_info_path = root / "core-info.json" + core_plan_path = root / "core-plan.json" + write_json(core_info_path, descriptor) + core_plan = { + "descriptorDigest": file_digest(core_info_path), + "descriptorSizeBytes": core_info_path.stat().st_size, + "packageMapDigest": semantic_digest(descriptor["packages"]), + "edition": int(BUILD), + "publicFetchUri": "https://updates.crypta.network/info/301/core-info.json", + } + write_json(core_plan_path, core_plan) + receipt = { + "schemaVersion": 1, + "kind": "cryptad-core-update-publication-receipt", + "generatedAt": _timestamp(), + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "candidateIdentityDigest": candidate.identity_digest, + "publicationPlanDigest": file_digest(core_plan_path), + "descriptorDigest": file_digest(core_info_path), + "descriptorSizeBytes": core_info_path.stat().st_size, + "packageMapDigest": semantic_digest(descriptor["packages"]), + "edition": int(BUILD), + "publicFetchUri": "https://updates.crypta.network/info/301/core-info.json", + "operation": operation, + "fetchedDescriptorDigest": file_digest(core_info_path), + "referencedPackages": [ + { + "packageKey": row["packageKey"], + "candidateAssetDigest": row["digest"], + "candidateAssetSizeBytes": row["sizeBytes"], + "publicReference": row["publicChk"], + "verificationStatus": "pass", + } + for row in candidate.assets + ], + "conflictStatus": "clear", + "verificationStatus": "pass", + "publicationState": "publication-complete", + "redaction": _redaction(), + } + path = root / "core-receipt.json" + write_json(path, receipt) + return ( + context, + LoadedJson("coreUpdatePublicationReceipt", path, receipt, file_digest(path)), + candidate, + core_plan_path, + core_plan, + core_info_path, + descriptor, + ) + + def test_core_receipt_created_and_existing_are_idempotent(self) -> None: + for operation in ("created", "verified-existing"): + with self.subTest(operation=operation), tempfile.TemporaryDirectory() as directory: + args = self._core_receipt_fixture(Path(directory), operation) + self.assertEqual(_core_receipt_errors(*args), []) + + def test_core_receipt_conflict_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as directory: + args = list(self._core_receipt_fixture(Path(directory), "verified-existing")) + loaded = args[1] + loaded.value["fetchedDescriptorDigest"] = _digest("f") + write_json(loaded.path, loaded.value) + self.assertTrue(_core_receipt_errors(*args)) + + def test_core_receipt_wrong_authorized_target_fails_closed(self) -> None: + mutations = ( + ("edition", 302), + ("publicFetchUri", "https://updates.crypta.network/info/302/core-info.json"), + ) + for field, replacement in mutations: + with self.subTest(field=field), tempfile.TemporaryDirectory() as directory: + args = list(self._core_receipt_fixture(Path(directory), "created")) + loaded = args[1] + assert isinstance(loaded, LoadedJson) + loaded.value[field] = replacement + write_json(loaded.path, loaded.value) + self.assertTrue(_core_receipt_errors(*args)) + + def _publication_fixture( + self, root: Path, operation: str + ) -> tuple[tuple[object, ...], dict[str, object]]: + context = _context(root) + candidate = _candidate(root) + asset = { + "role": "product", + "fileName": candidate.product_path.name, + "digest": candidate.product_digest, + "sizeBytes": 17, + "publicUri": "https://downloads.crypta.network/stable/cryptad-301.tar.gz", + } + plan = { + "githubReleasePageUri": "https://github.com/crypta-network/cryptad/releases/tag/v301", + "deploymentServicePublicUri": "https://93.184.216.34/deployment/observe", + "latestPointerPublicUri": "https://93.184.216.34/maintenance/latest.json", + "checksumsDigest": _digest("2"), + "provenanceDigest": _digest("3"), + "authorizationDigest": _digest("4"), + "releaseNotesDigest": _digest("5"), + "coreInfoDigest": _digest("6"), + "stableCatalogDigest": _digest("b"), + "assets": [asset], + "stableCatalogTarget": { + "catalogId": "crypta-first-party", + "channel": "stable", + "revision": 8, + "edition": 8, + "digest": _digest("b"), + "signatureDigest": _digest("c"), + "publicUri": "https://catalog.crypta.network/stable/catalog.json", + "signaturePublicUri": "https://catalog.crypta.network/stable/stable-catalog.json.sig", + "mirrorUris": ["https://mirror.crypta.network/stable/catalog.json"], + "rollbackUri": "https://catalog.crypta.network/stable/history/300/catalog.json", + "mirrorSetDigest": _digest("d"), + "rollbackStateDigest": _digest("e"), + }, + "coreUpdateTarget": { + "edition": 301, + "descriptorDigest": _digest("6"), + "publicUri": "https://updates.crypta.network/info/301/core-info.json", + "protectedInsertInputName": "CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT", + }, + } + plan_path = root / "publication-plan.json" + write_json(plan_path, plan) + core_receipt = { + "descriptorDigest": _digest("6"), + "packageMapDigest": _digest("7"), + "edition": 301, + "publicFetchUri": "https://updates.crypta.network/info/301/core-info.json", + } + core_receipt_digest = _digest("8") + successor_digest = _digest("9") + history_digest = _digest("a") + receipt = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-publication-receipt", + "generatedAt": _timestamp(), + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "sourceCommit": COMMIT, + "githubReleasePageUri": plan["githubReleasePageUri"], + "deploymentServicePublicUri": plan["deploymentServicePublicUri"], + "latestPointerPublicUri": plan["latestPointerPublicUri"], + "candidateIdentityDigest": candidate.identity_digest, + "productDigest": candidate.product_digest, + "checksumsDigest": plan["checksumsDigest"], + "provenanceDigest": plan["provenanceDigest"], + "authorizationDigest": plan["authorizationDigest"], + "coreUpdateReceiptDigest": core_receipt_digest, + "publicationPlanDigest": file_digest(plan_path), + "releaseNotesDigest": plan["releaseNotesDigest"], + "coreInfoDigest": plan["coreInfoDigest"], + "successorBaselineDigest": successor_digest, + "releaseHistoryDigest": history_digest, + "tag": { + "name": "v301", "objectType": "annotated", "targetCommit": COMMIT, + "tagObjectDigest": _digest("b"), "operation": operation, + "verificationStatus": "verified", + }, + "githubRelease": { + "releaseId": RELEASE_ID, "tag": "v301", + "pageUri": "https://github.com/crypta-network/cryptad/releases/tag/v301", + "notesDigest": plan["releaseNotesDigest"], "operation": operation, + "verificationStatus": "verified", + }, + "assets": [{**asset, "operation": operation, "verificationStatus": "verified"}], + "stableCatalog": { + "catalogId": "crypta-first-party", "revision": 8, "edition": 8, + "digest": plan["stableCatalogDigest"], "signatureDigest": _digest("c"), + "publicUri": "https://catalog.crypta.network/stable/catalog.json", + "signaturePublicUri": "https://catalog.crypta.network/stable/stable-catalog.json.sig", + "mirrorSetDigest": _digest("d"), "rollbackStateDigest": _digest("e"), + "operation": operation, "verificationStatus": "verified", + }, + "coreUpdate": { + "edition": 301, "descriptorDigest": core_receipt["descriptorDigest"], + "publicUri": "https://updates.crypta.network/info/301/core-info.json", + "packageMapDigest": core_receipt["packageMapDigest"], + "operation": operation, "verificationStatus": "verified", + }, + "workflow": { + "repository": "crypta-network/cryptad", "runId": 1, "runAttempt": 1, + "environment": "stable-1.0-maintenance-publication", "actor": "release-manager", + "attestationDigest": _digest("f"), + }, + "publicObservations": { + "tag": "verified", "githubRelease": "verified", "assets": "verified", + "artifactBase": "verified", "stableCatalog": "verified", "coreUpdate": "verified", + }, + "publicationState": "publication-complete", + "finalVerificationStatus": "pass", + "failureCategory": None, + "redaction": _redaction(), + } + path = root / "receipt.json" + write_json(path, receipt) + loaded = LoadedJson("stableMaintenancePublicationReceipt", path, receipt, file_digest(path)) + args = ( + context, loaded, candidate, plan_path, plan, core_receipt, + core_receipt_digest, successor_digest, history_digest, + ) + return args, receipt + + def test_publication_receipt_created_and_existing_are_idempotent(self) -> None: + for operation in ("created", "verified-existing"): + with self.subTest(operation=operation), tempfile.TemporaryDirectory() as directory: + args, _ = self._publication_fixture(Path(directory), operation) + self.assertEqual(_receipt_errors(*args), []) + + def test_publication_receipt_conflicts_fail_closed(self) -> None: + mutations = ( + lambda value: value["tag"].__setitem__("targetCommit", "f" * 40), + lambda value: value["assets"][0].__setitem__("digest", _digest("0")), + lambda value: value["publicObservations"].__setitem__("coreUpdate", "conflict"), + ) + for mutate in mutations: + with tempfile.TemporaryDirectory() as directory: + args, receipt = self._publication_fixture(Path(directory), "verified-existing") + mutate(receipt) + loaded = args[1] + assert isinstance(loaded, LoadedJson) + write_json(loaded.path, receipt) + self.assertTrue(_receipt_errors(*args)) + + def test_publication_receipt_github_release_identity_mismatch_fails_closed(self) -> None: + mutations = ( + ("releaseId", "stable-1-0-maintenance-302"), + ("tag", "v302"), + ("pageUri", "https://github.com/crypta-network/cryptad/releases/tag/v302"), + ) + for field, replacement in mutations: + with self.subTest(field=field), tempfile.TemporaryDirectory() as directory: + args, receipt = self._publication_fixture( + Path(directory), "verified-existing" + ) + receipt["githubRelease"][field] = replacement + loaded = args[1] + assert isinstance(loaded, LoadedJson) + write_json(loaded.path, receipt) + self.assertTrue(_receipt_errors(*args)) + + def test_publication_receipt_catalog_target_mismatch_fails_closed(self) -> None: + mutations = ( + ("catalogId", "another-catalog"), + ("revision", 9), + ("edition", 9), + ("digest", _digest("0")), + ("publicUri", "https://catalog.crypta.network/stable/other.json"), + ("mirrorSetDigest", _digest("0")), + ("rollbackStateDigest", _digest("0")), + ("signatureDigest", _digest("0")), + ("signaturePublicUri", "https://catalog.crypta.network/stable/other.sig"), + ) + for field, replacement in mutations: + with self.subTest(field=field), tempfile.TemporaryDirectory() as directory: + args, receipt = self._publication_fixture( + Path(directory), "verified-existing" + ) + receipt["stableCatalog"][field] = replacement + loaded = args[1] + assert isinstance(loaded, LoadedJson) + write_json(loaded.path, receipt) + self.assertTrue(_receipt_errors(*args)) + + def test_publication_receipt_core_update_target_mismatch_fails_closed(self) -> None: + mutations = ( + ("edition", 302), + ("descriptorDigest", _digest("0")), + ("publicUri", "https://updates.crypta.network/info/302/core-info.json"), + ("packageMapDigest", _digest("0")), + ) + for field, replacement in mutations: + with self.subTest(field=field), tempfile.TemporaryDirectory() as directory: + args, receipt = self._publication_fixture( + Path(directory), "verified-existing" + ) + receipt["coreUpdate"][field] = replacement + loaded = args[1] + assert isinstance(loaded, LoadedJson) + write_json(loaded.path, receipt) + self.assertTrue(_receipt_errors(*args)) + + def test_publication_plan_catalog_identity_must_match_frozen_candidate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + args, receipt = self._publication_fixture(Path(directory), "created") + plan = args[4] + assert isinstance(plan, dict) + plan["stableCatalogTarget"]["catalogId"] = "substituted-catalog" + receipt["stableCatalog"]["catalogId"] = "substituted-catalog" + plan_path = args[3] + assert isinstance(plan_path, Path) + write_json(plan_path, plan) + receipt["publicationPlanDigest"] = file_digest(plan_path) + loaded = args[1] + assert isinstance(loaded, LoadedJson) + write_json(loaded.path, receipt) + self.assertTrue(_receipt_errors(*args)) + + def test_receipt_identity_binds_public_catalog_and_observations(self) -> None: + with tempfile.TemporaryDirectory() as directory: + _, receipt = self._publication_fixture(Path(directory), "created") + changed_catalog = copy.deepcopy(receipt) + changed_catalog["stableCatalog"]["digest"] = _digest("0") + changed_observation = copy.deepcopy(receipt) + changed_observation["publicObservations"]["coreUpdate"] = "conflict" + self.assertNotEqual(receipt_identity(receipt), receipt_identity(changed_catalog)) + self.assertNotEqual(receipt_identity(receipt), receipt_identity(changed_observation)) + + def test_successor_uses_non_circular_identity_and_valid_schema(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ga, predecessor = _ga_and_predecessor() + candidate = _candidate(root) + receipt = self._publication_fixture(root, "created")[1] + evidence = _evidence() + successor = _successor( + _context(root), ga, predecessor, candidate, _digest("1"), evidence, + _digest("2"), receipt, _digest("3"), None, None, + ) + identity = successor_baseline_identity(successor) + self.assertEqual(successor["lineage"]["history"][-1]["baselineIdentityDigest"], identity) + self.assertEqual(successor["limitations"]["currentIds"], ["stable-known-001"]) + self.assertEqual( + successor["limitations"]["currentDigest"], + _known_limitations_digest({"stable-known-001"}), + ) + successor_with_external_digest = copy.deepcopy(successor) + successor_with_external_digest["externalPhysicalDigest"] = _digest("f") + self.assertEqual(identity, successor_baseline_identity(successor_with_external_digest)) + self.assertEqual(validate_schema(successor, core.SUCCESSOR_SCHEMA), []) + + def test_superseding_hotfix_carries_open_predecessor_follow_up(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ga, predecessor = _ga_and_predecessor() + inherited = { + "status": "open", + "generatedAt": _timestamp(NOW - timedelta(days=2)), + "obligationDigest": _digest("d"), + "deadline": _timestamp(NOW + timedelta(days=5)), + "closureEvidenceDigest": None, + "blocksRoutineMaintenance": True, + "obligatedReleaseId": "stable-1-0-hotfix-300", + "obligatedBuildVersion": "300", + "obligatedProductDigest": _digest("2"), + "obligatedCandidateIdentityDigest": _digest("3"), + "obligatedCandidateFreezeDigest": _digest("4"), + "obligatedCandidateFrozenAt": _timestamp(FROZEN - timedelta(days=1)), + "obligatedPredecessorBuild": "299", + "obligatedPredecessorProductDigest": _digest("5"), + "authorizationDigest": _digest("5"), + } + predecessor = Predecessor( + **{ + **predecessor.__dict__, + "outstanding_follow_up": inherited, + } + ) + candidate = _candidate(root, "security-hotfix") + receipt = self._publication_fixture(root, "created")[1] + + successor = _successor( + _context(root, release_class="security-hotfix"), + ga, + predecessor, + candidate, + _digest("1"), + _evidence("security-hotfix"), + _digest("2"), + receipt, + _digest("3"), + None, + None, + ) + + self.assertEqual(successor["hotfixFollowUp"], inherited) + self.assertEqual(validate_schema(successor, core.SUCCESSOR_SCHEMA), []) + self.assertTrue(_concurrent_follow_up_errors(predecessor, {"status": "open"})) + + def test_overdue_predecessor_follow_up_remains_active(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ga, predecessor = _ga_and_predecessor() + inherited = { + "status": "overdue", + "generatedAt": _timestamp(NOW - timedelta(days=10)), + "obligationDigest": _digest("d"), + "deadline": _timestamp(NOW - timedelta(days=3)), + "closureEvidenceDigest": None, + "blocksRoutineMaintenance": True, + "obligatedReleaseId": "stable-1-0-hotfix-300", + "obligatedBuildVersion": "300", + "obligatedProductDigest": _digest("2"), + "obligatedCandidateIdentityDigest": _digest("3"), + "obligatedCandidateFreezeDigest": _digest("4"), + "obligatedCandidateFrozenAt": _timestamp(FROZEN - timedelta(days=1)), + "obligatedPredecessorBuild": "299", + "obligatedPredecessorProductDigest": _digest("5"), + "authorizationDigest": _digest("5"), + } + predecessor = Predecessor( + **{ + **predecessor.__dict__, + "outstanding_follow_up": inherited, + } + ) + candidate = _candidate(root, "security-hotfix") + receipt = self._publication_fixture(root, "created")[1] + + successor = _successor( + _context(root, release_class="security-hotfix"), + ga, + predecessor, + candidate, + _digest("1"), + _evidence("security-hotfix"), + _digest("2"), + receipt, + _digest("3"), + None, + None, + ) + + self.assertEqual(successor["hotfixFollowUp"], inherited) + self.assertEqual(validate_schema(successor, core.SUCCESSOR_SCHEMA), []) + self.assertTrue(_concurrent_follow_up_errors(predecessor, {"status": "open"})) + overdue_without_flag = { + **inherited, + "deadline": _timestamp(NOW + timedelta(days=3)), + "blocksRoutineMaintenance": False, + } + self.assertTrue( + core._routine_follow_up_blocked( # noqa: SLF001 + overdue_without_flag, + "maintenance", + NOW, + ) + ) + self.assertFalse( + core._routine_follow_up_blocked( # noqa: SLF001 + overdue_without_flag, + "security-hotfix", + NOW, + ) + ) + + +class StableMaintenanceNoSideEffectsTest(unittest.TestCase): + """Ordinary execution is diagnostic-only and fail closed.""" + + def test_engine_contains_no_public_mutation_client(self) -> None: + source = inspect.getsource(stable_1_0_maintenance) + for forbidden in ( + "subprocess.run", + "urllib.request", + "requests.", + "fcpput", + "git tag", + "gh release create", + ): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, source) + + def test_fail_closed_run_writes_only_local_diagnostics(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + context = _context(root, mode="prepare-authorization", inputs={}) + (context.component_dir / "artifacts").mkdir(parents=True) + with mock.patch.object( + stable_1_0_maintenance, + "authenticate_ga_root", + side_effect=ValueError("malformed protected input"), + ), mock.patch.object(stable_1_0_maintenance, "_verify_receipts") as verify: + code, summary_path, report_path = stable_1_0_maintenance.run(context) + self.assertEqual(code, 1) + self.assertTrue(summary_path.is_file()) + self.assertTrue(report_path.is_file()) + verify.assert_not_called() + summary = read_json(summary_path) + self.assertFalse(summary["promotionReady"]) + names = {path.name for path in summary_path.parent.iterdir()} + self.assertEqual( + names, + { + core.SUMMARY_FILE, + core.REPORT_FILE, + core.REDACTION_REPORT_FILE, + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release-certification/cryptad_certification/tests/test_stable_maintenance_publication.py b/tools/release-certification/cryptad_certification/tests/test_stable_maintenance_publication.py new file mode 100644 index 00000000000..f957aef73a8 --- /dev/null +++ b/tools/release-certification/cryptad_certification/tests/test_stable_maintenance_publication.py @@ -0,0 +1,2794 @@ +"""Offline tests for the protected Stable 1.0 maintenance publication adapter.""" + +from __future__ import annotations + +import contextlib +import copy +import dataclasses +import datetime as dt +import hashlib +import importlib +import importlib.util +import io +import json +import os +from pathlib import Path +import sys +import tempfile +from typing import Mapping +import unittest +from unittest import mock + +from cryptad_certification.engines.stable_1_0_maintenance_core import ( + _receipt_identity as engine_receipt_identity, + stable_catalog_verification_identity, +) + + +SCRIPT = ( + Path(__file__).resolve().parents[2] + / "protected" + / "stable_maintenance_publication.py" +) +SPEC = importlib.util.spec_from_file_location( + "stable_maintenance_publication_under_test", SCRIPT +) +assert SPEC is not None and SPEC.loader is not None +publication = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = publication +SPEC.loader.exec_module(publication) + + +NOW = dt.datetime(2026, 7, 18, 12, 0, tzinfo=dt.timezone.utc) +RELEASE_ID = "stable-1-0-maintenance-301" +BUILD = "301" +COMMIT = "a" * 40 +POINTER = "sha256:" + "b" * 64 +PREVIOUS_BASELINE = "sha256:" + "c" * 64 +PREVIOUS_LINEAGE = "sha256:" + "d" * 64 +GA_BASELINE = "sha256:" + "e" * 64 +GA_RECEIPT = "sha256:" + "f" * 64 +CATALOG_SECRET = "USK@protected-catalog-insert-material" +CORE_UPDATE_SECRET = "USK@protected-core-update-insert-material" +MAINTENANCE_STATE_SECRET = "protected-maintenance-state-credential" +PUBLIC_PACKAGE_CHK = ( + "CHK@" + + "A" * 43 + + "," + + "B" * 43 + + "," + + "C" * 7 + + "/cryptad-301.exe" +) + + +def digest(character: str) -> str: + return "sha256:" + hashlib.sha256(character.encode("utf-8")).hexdigest() + + +def timestamp(value: dt.datetime) -> str: + return value.isoformat().replace("+00:00", "Z") + + +def canonical_bytes(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n" + + +def write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(canonical_bytes(value)) + + +def file_digest(path: Path) -> str: + return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + + +def semantic_digest(value: object) -> str: + encoded = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def redaction() -> dict[str, object]: + return {"status": "pass", "findingCount": 0, "findings": []} + + +def apps() -> list[dict[str, object]]: + result: list[dict[str, object]] = [] + for index, app_id in enumerate( + ( + "queue-manager", + "publisher", + "site-publisher", + "profile-publisher", + "social-inbox", + "feed-reader", + "trust-graph", + ), + start=1, + ): + result.append( + { + "appId": app_id, + "version": f"301.{index}", + "channel": "stable", + "supportLevel": "local-rc" if app_id == "trust-graph" else "maintained", + "bundleDigest": digest(str(index)), + "reviewReceiptDigest": digest(chr(96 + index)), + "appSigningKeyId": f"app-key-{index}", + "reviewerKeyId": f"reviewer-key-{index}", + "manifestDigest": digest(chr(103 + index)), + "permissionSetDigest": digest(chr(110 + index)), + "appDataSchemaVersion": 1, + "supportMetadataDigest": digest(chr(80 + index)), + } + ) + return result + + +def profiles() -> list[dict[str, object]]: + result: list[dict[str, object]] = [] + for index, profile_id in enumerate( + ( + "crypta.profile.v1", + "crypta.feed.snapshot.v1", + "crypta.trust.statement.v1", + "crypta.social.message.v1", + "crypta.social.outbox.v1", + ), + start=1, + ): + result.append( + { + "profileId": profile_id, + "version": 1, + "status": "stable", + "descriptorDigest": digest(str(index)), + "canonicalizationRulesDigest": digest(chr(102 + index)), + "maximumSizePolicyDigest": digest(chr(107 + index)), + "signaturePayloadRulesDigest": digest(chr(112 + index)), + } + ) + return result + + +class BundleFixture: + def __init__(self, root: Path) -> None: + self.root = root + self.legacy = root / "component" / "artifacts" / "legacy" + self.legacy.mkdir(parents=True) + self.product = self.legacy / "cryptad-stable-301.tar.gz" + self.package = self.legacy / "cryptad-301.exe" + self.catalog = self.legacy / "first-party-stable.properties" + self.catalog_signature = self.legacy / "first-party-stable.properties.sig" + self.product.write_bytes(b"exact product bytes\n") + self.package.write_bytes(b"exact windows package bytes\n") + self.catalog.write_bytes(b"catalog.version=301\n") + self.catalog_signature.write_bytes(b"detached catalog signature bytes\n") + + package_row = { + "packageKey": "amd64.exe", + "fileName": self.package.name, + "digest": file_digest(self.package), + "sizeBytes": self.package.stat().st_size, + "publicChk": PUBLIC_PACKAGE_CHK, + "storeUrl": None, + } + self.platform_api = { + "baselineName": "1.0", + "baselineDigest": digest("platform-baseline"), + "baselineContractVersion": 1, + "currentContractVersion": 2, + "currentContractDigest": digest("platform-contract"), + "stableSurfaceDigest": digest("platform-surface"), + "compatibilityWindowPolicyDigest": digest("platform-window"), + "deprecationHistoryDigest": semantic_digest([]), + "deprecationHistory": [], + } + self.stable_catalog = { + "fileName": self.catalog.name, + "sizeBytes": self.catalog.stat().st_size, + "signatureFileName": self.catalog_signature.name, + "signatureSizeBytes": self.catalog_signature.stat().st_size, + "catalogId": "crypta-first-party", + "channel": "stable", + "revision": 301, + "edition": 301, + "digest": file_digest(self.catalog), + "signatureDigest": file_digest(self.catalog_signature), + "signingKeyId": "catalog-key-2026", + } + self.first_party_apps = apps() + self.content_profiles = profiles() + self.limitations = { + "knownLimitationsDigest": semantic_digest({"limitationIds": []}), + "deltaDigest": semantic_digest( + { + "predecessorIds": [], + "addedIds": [], + "resolvedIds": [], + "unchangedIds": [], + "currentIds": [], + } + ), + "addedCount": 0, + "resolvedCount": 0, + "unchangedCount": 0, + "addedIds": [], + "resolvedIds": [], + "unchangedIds": [], + "changesReviewed": True, + "noHiddenLimitations": True, + } + self.security = { + "advisoryDigest": digest("security-advisory"), + "denylistDigest": digest("security-denylist"), + } + self.support = { + "supportLevelDigest": digest("support-level"), + "diagnosticsEvidenceDigest": digest("support-diagnostics"), + } + self.legacy_boundaries = { + "pluginRuntime": "removed", + "inCorePluginApi": "removed", + "legacyAdminMutationRoutes": "disabled", + "fproxyBrowse": "retained", + "contentFiltering": "retained", + "emergencyFallbackRoutes": "retained", + } + self.authenticated_inputs = self.root / "authenticated-inputs" + self.authenticated_inputs.mkdir() + self.source = { + "branch": f"release/{BUILD}", + "ref": f"commit:{COMMIT}", + "commit": COMMIT, + "baseBranch": "develop", + "baseCommit": "9" * 40, + "clean": True, + "treeState": "clean", + "branchHeadVerified": True, + "immutableRefVerified": True, + "currentPublishedMainBaseVerified": False, + "sourceTreeDigest": digest("source-tree"), + } + self.toolchain = { + "javaVersion": "25.0.3", + "javaMajorVersion": 25, + "gradleVersion": "9.0", + "gradleWrapperDigest": digest("gradle-wrapper"), + "dependencyVerificationDigest": digest("dependency-verification"), + "dependencyVerificationStatus": "pass", + "buildLogicDigest": digest("build-logic"), + "buildTasks": ["assembleCryptadDist", "jpackageInstallerCryptad"], + "productionSigning": True, + "testSigning": False, + } + self.candidate_input = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-candidate-input", + "generatedAt": timestamp(NOW), + "stableMilestone": "1.0", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "source": self.source, + "toolchain": self.toolchain, + "product": { + "fileName": self.product.name, + "digest": file_digest(self.product), + "sizeBytes": self.product.stat().st_size, + "frozenAt": timestamp(NOW), + }, + "packages": [package_row], + "platformApi": self.platform_api, + "stableCatalog": self.stable_catalog, + "firstPartyApps": self.first_party_apps, + "contentFormatProfiles": self.content_profiles, + "limitations": self.limitations, + "security": self.security, + "support": self.support, + "legacyBoundaries": self.legacy_boundaries, + "redaction": redaction(), + } + self.candidate_input_path = ( + self.authenticated_inputs / "maintenance-candidate-input.json" + ) + write_json(self.candidate_input_path, self.candidate_input) + self.ga_baseline = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-baseline", + "limitations": {"known": []}, + "securityBaseline": {"status": "stable"}, + "supportBaseline": {"status": "stable"}, + "redaction": redaction(), + } + self.ga_baseline_path = ( + self.authenticated_inputs / "stable-ga-maintenance-baseline.json" + ) + write_json(self.ga_baseline_path, self.ga_baseline) + self.predecessor_baseline_path = ( + self.authenticated_inputs / "predecessor-maintenance-baseline.json" + ) + write_json(self.predecessor_baseline_path, self.ga_baseline) + self.ga_baseline_digest = file_digest(self.ga_baseline_path) + catalog_verification = stable_catalog_verification_identity( + self.stable_catalog, digest("catalog-trust-registry") + ) + catalog_verification_digest = semantic_digest(catalog_verification) + freeze_assets = [ + { + "role": "product", + "fileName": self.product.name, + "digest": file_digest(self.product), + "sizeBytes": self.product.stat().st_size, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "signingReceiptDigest": digest("product-signing-receipt"), + "notarizationStatus": "not-applicable", + "notarizationReceiptDigest": None, + }, + { + "role": "package", + "fileName": self.package.name, + "digest": file_digest(self.package), + "sizeBytes": self.package.stat().st_size, + "packageKey": "amd64.exe", + "os": "windows", + "arch": "amd64", + "producerArchitecture": "amd64", + "packageType": "exe", + "publicAsset": True, + "signingStatus": "pass", + "signingReceiptDigest": digest("package-signing-receipt"), + "notarizationStatus": "not-applicable", + "notarizationReceiptDigest": None, + }, + { + "role": "stable-catalog", + "fileName": self.catalog.name, + "digest": file_digest(self.catalog), + "sizeBytes": self.catalog.stat().st_size, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "signingReceiptDigest": catalog_verification_digest, + "notarizationStatus": "not-applicable", + "notarizationReceiptDigest": None, + }, + { + "role": "stable-catalog-signature", + "fileName": self.catalog_signature.name, + "digest": file_digest(self.catalog_signature), + "sizeBytes": self.catalog_signature.stat().st_size, + "packageKey": None, + "os": None, + "arch": None, + "producerArchitecture": None, + "packageType": None, + "publicAsset": True, + "signingStatus": "pass", + "signingReceiptDigest": catalog_verification_digest, + "notarizationStatus": "not-applicable", + "notarizationReceiptDigest": None, + }, + ] + self.candidate_freeze = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-candidate-freeze", + "generatedAt": timestamp(NOW), + "frozenAt": timestamp(NOW), + "stableMilestone": "1.0", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "source": self.source, + "toolchain": self.toolchain, + "producer": { + "system": "github-actions", + "repository": "crypta-network/cryptad", + "workflowPath": ".github/workflows/stable-1.0-maintenance-release.yml", + "workflowCommit": COMMIT, + "runId": "30101", + "runAttempt": 1, + "runnerEnvironment": "github-hosted", + "producerIdentityReceiptDigest": digest("producer-identity"), + "sourceRefReceiptDigest": digest("source-ref-receipt"), + "buildReceiptDigest": digest("build-receipt"), + "authenticationStatus": "pass", + }, + "predecessorObservation": { + "releaseId": "stable-1-0-ga-300", + "buildVersion": "300", + "productDigest": digest("8"), + "baselineDigest": self.ga_baseline_digest, + "publicationReceiptDigest": GA_RECEIPT, + "latestPublishedPointerDigest": POINTER, + "observedAt": timestamp(NOW), + "status": "latest-published", + }, + "stableCatalogVerification": catalog_verification, + "buildCount": 1, + "rebuildPerformed": False, + "checksumsDigest": digest("candidate-checksums"), + "assets": freeze_assets, + "assetSetDigest": semantic_digest( + sorted(freeze_assets, key=lambda row: str(row["fileName"])) + ), + "redaction": redaction(), + } + self.candidate_freeze_path = ( + self.authenticated_inputs / "maintenance-candidate-freeze.json" + ) + write_json(self.candidate_freeze_path, self.candidate_freeze) + self.candidate_input["candidateFreezeDigest"] = file_digest( + self.candidate_freeze_path + ) + write_json(self.candidate_input_path, self.candidate_input) + self.evidence = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-evidence", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "candidateProductDigest": file_digest(self.product), + "candidateFreezeDigest": file_digest(self.candidate_freeze_path), + "windowClass": "normal", + "evidenceRows": [ + {"evidenceId": "stable-maintenance.installation-packaging"}, + {"evidenceId": "stable-maintenance.security"}, + ], + "redaction": redaction(), + } + self.evidence_path = self.authenticated_inputs / "maintenance-evidence.json" + write_json(self.evidence_path, self.evidence) + self.candidate = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-candidate", + "generatedAt": timestamp(NOW), + "stableMilestone": "1.0", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "source": self.source, + "toolchain": self.toolchain, + "product": { + "fileName": self.product.name, + "sizeBytes": self.product.stat().st_size, + "digest": file_digest(self.product), + }, + "packages": [package_row], + "candidateInputDigest": file_digest(self.candidate_input_path), + "candidateFreezeDigest": file_digest(self.candidate_freeze_path), + "frozenAt": timestamp(NOW), + "platformApiDigest": semantic_digest(self.platform_api), + "stableCatalogDigest": semantic_digest(self.stable_catalog), + "firstPartyAppsDigest": semantic_digest(self.first_party_apps), + "contentProfilesDigest": semantic_digest(self.content_profiles), + "knownLimitationsDigest": semantic_digest(self.limitations), + "securityDigest": semantic_digest(self.security), + "supportDigest": semantic_digest(self.support), + "legacyBoundariesDigest": semantic_digest(self.legacy_boundaries), + "builtOnce": True, + "rebuildPerformedAfterFreeze": False, + "redaction": redaction(), + } + self.candidate_path = self.legacy / "stable-1.0-maintenance-candidate.json" + write_json(self.candidate_path, self.candidate) + self.candidate_digest = semantic_digest(self.candidate) + + self.lineage = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-lineage", + "generatedAt": timestamp(NOW), + "stableMilestone": "1.0", + "gaRoot": { + "releaseId": "stable-1-0-ga-300", + "buildVersion": "300", + "tag": "v300", + "sourceCommit": "9" * 40, + "productDigest": digest("8"), + "maintenanceBaselineDigest": self.ga_baseline_digest, + "publicationReceiptDigest": GA_RECEIPT, + "publicationState": "publication-complete", + }, + "predecessor": { + "releaseId": "stable-1-0-ga-300", + "buildVersion": "300", + "tag": "v300", + "sourceCommit": "9" * 40, + "productDigest": digest("8"), + "releaseClass": "stable-ga", + "publicationReceiptDigest": GA_RECEIPT, + "successorBaselineDigest": None, + "hotfixFollowUpClosureDigest": None, + "publicationState": "publication-complete", + }, + "candidate": { + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "tag": f"v{BUILD}", + "sourceBranch": f"release/{BUILD}", + "sourceRef": f"commit:{COMMIT}", + "sourceCommit": COMMIT, + "releaseClass": "maintenance", + }, + "chainDepth": 1, + "previousLineageDigest": PREVIOUS_LINEAGE, + "latestPublishedPointerDigest": POINTER, + "noGap": True, + "noFork": True, + "status": "pass", + "redaction": redaction(), + } + self.lineage_path = self.legacy / "stable-1.0-maintenance-lineage.json" + write_json(self.lineage_path, self.lineage) + + self.core_info = { + "version": BUILD, + "release_page_url": f"https://github.com/crypta-network/cryptad/releases/tag/v{BUILD}", + "packages": { + "amd64.exe": { + "chk": package_row["publicChk"], + "size": package_row["sizeBytes"], + } + }, + } + self.core_info_path = self.legacy / "core-info.json" + write_json(self.core_info_path, self.core_info) + self.notes_path = self.legacy / "stable-1.0-maintenance-release-notes.md" + self.notes_path.write_text("# Stable 1.0 maintenance build 301\n", encoding="utf-8") + self.known_path = self.legacy / "stable-1.0-maintenance-known-limitations.json" + write_json( + self.known_path, + { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-known-limitations-delta", + "redaction": redaction(), + }, + ) + self.checksums_path = self.legacy / "stable-1.0-maintenance-checksums.txt" + self.checksums_path.write_text( + f"{file_digest(self.product).removeprefix('sha256:')} {self.product.name}\n", + encoding="utf-8", + ) + self.provenance = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-provenance", + "generatedAt": timestamp(NOW), + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "candidateIdentityDigest": self.candidate_digest, + "candidateFreezeDigest": file_digest(self.candidate_freeze_path), + "candidateProductDigest": file_digest(self.product), + "lineageDigest": file_digest(self.lineage_path), + "predecessorBaselineDigest": file_digest(self.predecessor_baseline_path), + "evidenceDigest": file_digest(self.evidence_path), + "policyDigest": digest("maintenance-policy"), + "redaction": redaction(), + } + self.provenance_path = self.legacy / "stable-1.0-maintenance-provenance.json" + write_json(self.provenance_path, self.provenance) + self.comparison_path = self.legacy / "stable-1.0-maintenance-comparison.json" + write_json( + self.comparison_path, + { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-comparison", + "releaseId": RELEASE_ID, + "redaction": redaction(), + }, + ) + + self.authorization = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-authorization", + "authorizationId": "maintenance-301-authorization", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "candidateIdentityDigest": self.candidate_digest, + "gaBaselineDigest": self.ga_baseline_digest, + "predecessorIdentityDigest": semantic_digest( + { + "releaseId": self.lineage["predecessor"]["releaseId"], + "buildVersion": self.lineage["predecessor"]["buildVersion"], + "sourceCommit": self.lineage["predecessor"]["sourceCommit"], + "productDigest": self.lineage["predecessor"]["productDigest"], + "baselineDigest": file_digest(self.predecessor_baseline_path), + } + ), + "predecessorProductDigest": digest("8"), + "predecessorPublicationReceiptDigest": GA_RECEIPT, + "candidateFreezeDigest": file_digest(self.candidate_freeze_path), + "productDigest": file_digest(self.product), + "checksumsDigest": file_digest(self.checksums_path), + "provenanceDigest": file_digest(self.provenance_path), + "comparisonDigest": file_digest(self.comparison_path), + "evidenceDigest": file_digest(self.evidence_path), + "coreInfoDigest": file_digest(self.core_info_path), + "stableCatalogDigest": file_digest(self.catalog), + "knownLimitationsDeltaDigest": digest("3"), + "releaseNotesDigest": file_digest(self.notes_path), + "publicationTargetsDigest": digest("2"), + "allowedPublicationScopes": list(publication.AUTHORIZATION_SCOPES), + "acceptedWarningIds": [], + "role": "stable-maintenance-release-manager", + "approverIdentity": "release-manager-1", + "authorizedAt": timestamp(NOW - dt.timedelta(days=365)), + "expiresAt": timestamp(NOW + dt.timedelta(days=365)), + "decision": "go", + "status": "approved", + "hotfixIncidentId": None, + "hotfixPolicyAuthorizationDigest": None, + "hotfixShortenedEvidenceIds": [], + "hotfixFollowUpObligationDigest": None, + "redaction": redaction(), + } + self.authorization_path = ( + self.legacy / "stable-1.0-maintenance-authorization-summary.json" + ) + write_json(self.authorization_path, self.authorization) + + self.core_plan = { + "schemaVersion": 1, + "kind": "cryptad-core-update-publication-plan", + "generatedAt": timestamp(NOW), + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "candidateIdentityDigest": self.candidate_digest, + "descriptorDigest": file_digest(self.core_info_path), + "descriptorSizeBytes": self.core_info_path.stat().st_size, + "packageMapDigest": semantic_digest(self.core_info["packages"]), + "edition": int(BUILD), + "publicFetchUri": f"https://updates.crypta.network/info/{BUILD}", + "protectedInsertInputName": publication.PRIVATE_INPUT_ENV, + "authorizationDigest": file_digest(self.authorization_path), + "publicationTargetDigest": digest("2"), + "preInsertionConflictStatus": "clear", + "sideEffectsPerformed": False, + "redaction": redaction(), + } + self.core_plan_path = self.legacy / "core-update-publication-plan.json" + write_json(self.core_plan_path, self.core_plan) + + asset_rows: list[dict[str, object]] = [] + role_paths = ( + ("product", self.product), + ("package", self.package), + ("release-notes", self.notes_path), + ("known-limitations", self.known_path), + ("checksums", self.checksums_path), + ("provenance", self.provenance_path), + ("authorization", self.authorization_path), + ("core-info", self.core_info_path), + ("stable-catalog", self.catalog), + ("stable-catalog-signature", self.catalog_signature), + ) + for role, path in role_paths: + asset_rows.append( + { + "role": role, + "fileName": path.name, + "digest": file_digest(path), + "sizeBytes": path.stat().st_size, + "publicUri": f"https://downloads.crypta.network/stable/{path.name}", + } + ) + self.plan = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-publication-plan", + "generatedAt": timestamp(NOW), + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "sourceBranch": f"release/{BUILD}", + "sourceCommit": COMMIT, + "expectedTag": f"v{BUILD}", + "githubReleasePageUri": f"https://github.com/crypta-network/cryptad/releases/tag/v{BUILD}", + "artifactBaseUri": "https://downloads.crypta.network/stable/", + "deploymentServicePublicUri": "https://deployment.crypta.network/stable/observe", + "latestPointerPublicUri": "https://state.crypta.network/stable/latest.json", + "candidateIdentityDigest": self.candidate_digest, + "productDigest": file_digest(self.product), + "checksumsDigest": file_digest(self.checksums_path), + "provenanceDigest": file_digest(self.provenance_path), + "authorizationDigest": file_digest(self.authorization_path), + "releaseNotesDigest": file_digest(self.notes_path), + "coreInfoDigest": file_digest(self.core_info_path), + "stableCatalogDigest": file_digest(self.catalog), + "knownLimitationsDeltaDigest": digest("3"), + "publicationTargetsDigest": digest("2"), + "assets": asset_rows, + "stableCatalogTarget": { + "catalogId": "crypta-first-party", + "channel": "stable", + "revision": 301, + "edition": 301, + "digest": file_digest(self.catalog), + "signatureDigest": file_digest(self.catalog_signature), + "publicUri": "https://catalog.crypta.network/stable/catalog.properties", + "signaturePublicUri": "https://catalog.crypta.network/stable/first-party-stable.properties.sig", + "mirrorUris": ["https://mirror.crypta.network/stable/catalog.properties"], + "rollbackUri": "https://catalog.crypta.network/stable/history/300/catalog.properties", + "mirrorSetDigest": digest("1"), + "rollbackStateDigest": digest("0"), + }, + "coreUpdateTarget": { + "edition": int(BUILD), + "descriptorDigest": file_digest(self.core_info_path), + "publicUri": self.core_plan["publicFetchUri"], + "protectedInsertInputName": publication.PRIVATE_INPUT_ENV, + }, + "sideEffectsPerformed": False, + "publicationState": "publication-authorized", + "redaction": redaction(), + } + self.plan_path = self.legacy / "stable-1.0-maintenance-publication-plan.json" + write_json(self.plan_path, self.plan) + + def load(self) -> publication.PublicationBundle: + return publication._load_bundle(self.root) + + def material(self, operation: str = "created") -> publication.VerificationMaterial: + bundle = self.load() + plan = bundle.plan + assets = [ + { + **dict(row), + "operation": operation, + "verificationStatus": "verified", + } + for row in plan["assets"] + ] + core_receipt = { + "schemaVersion": 1, + "kind": "cryptad-core-update-publication-receipt", + "generatedAt": timestamp(NOW), + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "candidateIdentityDigest": self.candidate_digest, + "publicationPlanDigest": file_digest(self.core_plan_path), + "descriptorDigest": file_digest(self.core_info_path), + "descriptorSizeBytes": self.core_info_path.stat().st_size, + "packageMapDigest": semantic_digest(self.core_info["packages"]), + "edition": int(BUILD), + "publicFetchUri": self.core_plan["publicFetchUri"], + "operation": operation, + "fetchedDescriptorDigest": file_digest(self.core_info_path), + "referencedPackages": [ + { + "packageKey": "amd64.exe", + "candidateAssetDigest": file_digest(self.package), + "candidateAssetSizeBytes": self.package.stat().st_size, + "publicReference": PUBLIC_PACKAGE_CHK, + "verificationStatus": "pass", + } + ], + "conflictStatus": "clear", + "verificationStatus": "pass", + "publicationState": "publication-complete", + "redaction": redaction(), + } + receipt = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-publication-receipt", + "generatedAt": timestamp(NOW), + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "sourceCommit": COMMIT, + "githubReleasePageUri": plan["githubReleasePageUri"], + "deploymentServicePublicUri": plan["deploymentServicePublicUri"], + "latestPointerPublicUri": plan["latestPointerPublicUri"], + "candidateIdentityDigest": self.candidate_digest, + "productDigest": file_digest(self.product), + "checksumsDigest": file_digest(self.checksums_path), + "provenanceDigest": file_digest(self.provenance_path), + "authorizationDigest": file_digest(self.authorization_path), + "publicationPlanDigest": file_digest(self.plan_path), + "releaseNotesDigest": file_digest(self.notes_path), + "coreInfoDigest": file_digest(self.core_info_path), + "coreUpdateReceiptDigest": ( + "sha256:" + hashlib.sha256(canonical_bytes(core_receipt)).hexdigest() + ), + "successorBaselineDigest": digest("0"), + "releaseHistoryDigest": digest("0"), + "tag": { + "name": f"v{BUILD}", + "objectType": "annotated", + "targetCommit": COMMIT, + "tagObjectDigest": digest("1"), + "operation": operation, + "verificationStatus": "verified", + }, + "githubRelease": { + "releaseId": "github-release-301", + "tag": f"v{BUILD}", + "pageUri": f"https://github.com/crypta-network/cryptad/releases/tag/v{BUILD}", + "notesDigest": file_digest(self.notes_path), + "operation": operation, + "verificationStatus": "verified", + }, + "assets": assets, + "stableCatalog": { + "catalogId": "crypta-first-party", + "revision": 301, + "edition": 301, + "digest": file_digest(self.catalog), + "signatureDigest": self.stable_catalog["signatureDigest"], + "publicUri": "https://catalog.crypta.network/stable/catalog.properties", + "signaturePublicUri": "https://catalog.crypta.network/stable/first-party-stable.properties.sig", + "mirrorSetDigest": digest("1"), + "rollbackStateDigest": digest("0"), + "operation": operation, + "verificationStatus": "verified", + }, + "coreUpdate": { + "edition": int(BUILD), + "descriptorDigest": file_digest(self.core_info_path), + "publicUri": self.core_plan["publicFetchUri"], + "packageMapDigest": semantic_digest(self.core_info["packages"]), + "operation": operation, + "verificationStatus": "verified", + }, + "workflow": { + "repository": "crypta-network/cryptad", + "runId": 1234, + "runAttempt": 1, + "environment": "stable-1.0-maintenance-publication", + "actor": "release-manager-1", + "attestationDigest": digest("3"), + }, + "publicObservations": {target: "verified" for target in publication.TARGETS}, + "publicationState": "publication-complete", + "finalVerificationStatus": "pass", + "failureCategory": None, + "redaction": redaction(), + } + receipt_identity = publication._receipt_identity(receipt) + history = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-history-entry", + "generatedAt": timestamp(NOW), + "stableMilestone": "1.0", + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "tag": f"v{BUILD}", + "sourceCommit": COMMIT, + "releaseClass": "maintenance", + "chainDepth": 1, + "gaBaselineDigest": self.ga_baseline_digest, + "previousBaselineDigest": file_digest(self.predecessor_baseline_path), + "previousLineageDigest": PREVIOUS_LINEAGE, + "candidateIdentityDigest": self.candidate_digest, + "productDigest": file_digest(self.product), + "publicationReceiptIdentityDigest": receipt_identity, + "coreInfoDigest": file_digest(self.core_info_path), + "evidenceDigest": file_digest(self.evidence_path), + "status": "published-and-verified", + "redaction": redaction(), + } + history_digest = "sha256:" + hashlib.sha256(canonical_bytes(history)).hexdigest() + history_rows = [ + { + "chainDepth": 0, + "releaseId": "stable-1-0-ga-300", + "buildVersion": "300", + "tag": "v300", + "sourceCommit": "9" * 40, + "releaseClass": "stable-ga", + "productDigest": digest("8"), + "baselineIdentityDigest": self.ga_baseline_digest, + "publicationReceiptIdentityDigest": GA_RECEIPT, + "previousLineageDigest": PREVIOUS_LINEAGE, + }, + { + "chainDepth": 1, + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "tag": f"v{BUILD}", + "sourceCommit": COMMIT, + "releaseClass": "maintenance", + "productDigest": file_digest(self.product), + "baselineIdentityDigest": digest("a"), + "publicationReceiptIdentityDigest": receipt_identity, + "previousLineageDigest": PREVIOUS_LINEAGE, + }, + ] + successor = { + "schemaVersion": 2, + "kind": "stable-1.0-maintenance-successor-baseline", + "generatedAt": timestamp(NOW), + "stableMilestone": "1.0", + "status": "published", + "gaRoot": { + "releaseId": "stable-1-0-ga-300", + "buildVersion": "300", + "tag": "v300", + "sourceCommit": "9" * 40, + "productDigest": digest("8"), + "maintenanceBaselineDigest": self.ga_baseline_digest, + "publicationReceiptDigest": GA_RECEIPT, + }, + "previousBaselineDigest": file_digest(self.predecessor_baseline_path), + "chainDepth": 1, + "previousLineageDigest": PREVIOUS_LINEAGE, + "lineage": { + "gaBaselineDigest": self.ga_baseline_digest, + "gaPublicationReceiptDigest": GA_RECEIPT, + "chainDepth": 1, + "lineageDigest": semantic_digest(history_rows), + "history": history_rows, + }, + "publication": { + "receiptIdentityDigest": receipt_identity, + "publicationState": "publication-complete", + "verificationStatus": "pass", + }, + "release": { + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "tag": f"v{BUILD}", + "sourceCommit": COMMIT, + "releaseClass": "maintenance", + "productDigest": file_digest(self.product), + "publicationReceiptIdentityDigest": receipt_identity, + "checksumsDigest": file_digest(self.checksums_path), + "provenanceDigest": file_digest(self.provenance_path), + "coreInfoDigest": file_digest(self.core_info_path), + }, + "platformApi": self.platform_api, + "stableCatalog": { + "catalogId": "crypta-first-party", + "channel": "stable", + "revision": 301, + "edition": 301, + "digest": file_digest(self.catalog), + "signatureFileName": self.catalog_signature.name, + "signatureSizeBytes": self.catalog_signature.stat().st_size, + "signatureDigest": self.stable_catalog["signatureDigest"], + "signingKeyId": "catalog-key-2026", + }, + "firstPartyApps": self.first_party_apps, + "contentFormatProfiles": self.content_profiles, + "limitations": { + "currentDigest": self.limitations["knownLimitationsDigest"], + "currentIds": [], + "gaBaselineDigest": semantic_digest(self.ga_baseline["limitations"]), + "predecessorDigest": semantic_digest(self.ga_baseline["limitations"]), + }, + "security": { + "currentDigest": semantic_digest(self.security), + "gaBaselineDigest": semantic_digest(self.ga_baseline["securityBaseline"]), + "predecessorDigest": semantic_digest(self.ga_baseline["securityBaseline"]), + }, + "support": { + "currentDigest": semantic_digest(self.support), + "gaBaselineDigest": semantic_digest(self.ga_baseline["supportBaseline"]), + "predecessorDigest": semantic_digest(self.ga_baseline["supportBaseline"]), + }, + "legacyBoundaries": self.legacy_boundaries, + "evidenceWindowPolicy": { + "windowClass": "normal", + "policyDigest": self.provenance["policyDigest"], + "requiredEvidenceDigest": semantic_digest( + sorted(row["evidenceId"] for row in self.evidence["evidenceRows"]) + ), + "completedEvidenceDigest": file_digest(self.evidence_path), + }, + "hotfixFollowUp": { + "status": "not-required", + "generatedAt": timestamp(NOW), + "obligationDigest": None, + "deadline": None, + "closureEvidenceDigest": None, + "blocksRoutineMaintenance": False, + }, + "releaseHistoryDigest": history_digest, + "redaction": redaction(), + } + history_rows[1]["baselineIdentityDigest"] = publication._successor_identity( + successor + ) + successor["lineage"]["lineageDigest"] = semantic_digest(history_rows) + receipt["successorBaselineDigest"] = ( + "sha256:" + hashlib.sha256(canonical_bytes(successor)).hexdigest() + ) + receipt["releaseHistoryDigest"] = history_digest + return publication.VerificationMaterial(receipt, core_receipt, successor, history) + + +class FakeOperations: + def __init__( + self, + material: publication.VerificationMaterial, + *, + target_status: str = "absent", + predecessor: str | None = POINTER, + fail_target: str | None = None, + ) -> None: + self.material = material + self.targets = {target: target_status for target in publication.TARGETS} + self.predecessor = predecessor + self.latest_candidate: str | None = None + self.fail_target = fail_target + self.observe_calls = 0 + self.publish_calls: list[str] = [] + self.publish_input_purposes: list[tuple[str, str | None]] = [] + self.publish_input_values: list[tuple[str, str | None]] = [] + self.activate_calls = 0 + self.activation_input_purpose: str | None = None + self.activation_input_value: str | None = None + self.activation_pointer_bytes: bytes | None = None + self.activation_pointer_digest_override: str | None = None + self.pointer = publication.PointerSnapshot( + "observed", POINTER, PREVIOUS_BASELINE, digest("0") + ) + + def observe_public_state( + self, request: publication.PublicationRequest + ) -> publication.PublicSnapshot: + self.observe_calls += 1 + return publication.PublicSnapshot( + self.predecessor, dict(self.targets), self.latest_candidate + ) + + def publish_target( + self, + target: str, + request: publication.PublicationRequest, + protected_input: publication.SecretMaterial | None, + ) -> None: + self.publish_calls.append(target) + self.publish_input_purposes.append( + (target, protected_input.purpose if protected_input else None) + ) + self.publish_input_values.append( + (target, protected_input.value if protected_input else None) + ) + if target == self.fail_target: + raise RuntimeError( + protected_input.value if protected_input else CORE_UPDATE_SECRET + ) + self.targets[target] = "matching" + if all(value == "matching" for value in self.targets.values()): + self.latest_candidate = request.candidate_identity_digest + + def verify_publication( + self, request: publication.PublicationRequest + ) -> publication.VerificationMaterial: + return self.material + + def observe_latest_pointer( + self, request: publication.ActivationRequest + ) -> publication.PointerSnapshot: + return self.pointer + + def activate_latest( + self, + request: publication.ActivationRequest, + protected_input: publication.SecretMaterial, + ) -> None: + self.activate_calls += 1 + self.activation_input_purpose = protected_input.purpose + self.activation_input_value = protected_input.value + self.activation_pointer_bytes = request.activated_pointer_bytes + self.pointer = publication.PointerSnapshot( + "observed", + self.activation_pointer_digest_override + or request.activated_pointer_digest, + request.successor_digest, + str(request.receipt["candidateIdentityDigest"]), + ) + + +class StableMaintenancePublicationTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory( + prefix="stable-maintenance-publication-test-" + ) + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.fixture = BundleFixture(self.root / "bundle") + + def operations( + self, + *, + status: str = "absent", + operation: str = "created", + fail_target: str | None = None, + ) -> FakeOperations: + operations = FakeOperations( + self.fixture.material(operation), + target_status=status, + fail_target=fail_target, + ) + if status == "absent": + operations.targets["artifactBase"] = "matching" + return operations + + @staticmethod + def protected_inputs() -> publication.PublicationProtectedInputs: + return publication.PublicationProtectedInputs( + publication.SecretMaterial("stable-catalog", CATALOG_SECRET), + publication.SecretMaterial("core-update", CORE_UPDATE_SECRET), + ) + + @staticmethod + def maintenance_state_input() -> publication.SecretMaterial: + return publication.SecretMaterial( + "maintenance-state", MAINTENANCE_STATE_SECRET + ) + + def activation_authorization( + self, + successor_path: Path, + history_path: Path, + receipt_path: Path, + *, + now: dt.datetime = NOW, + ) -> Path: + receipt = json.loads(receipt_path.read_text(encoding="utf-8")) + authorization = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-activation-authorization", + "generatedAt": timestamp(now), + "authorizationId": "activation-12345-1", + "authority": "github-protected-environment", + "protectedEnvironment": "stable-1.0-maintenance-publication", + "workflowRepository": publication.SOURCE_REPOSITORY, + "workflowRunId": "12345", + "workflowRunAttempt": 1, + "sourceCommit": receipt["sourceCommit"], + "releaseId": receipt["releaseId"], + "buildVersion": receipt["buildVersion"], + "releaseClass": receipt["releaseClass"], + "candidateIdentityDigest": receipt["candidateIdentityDigest"], + "publicationReceiptDigest": file_digest(receipt_path), + "successorBaselineDigest": file_digest(successor_path), + "historyDigest": file_digest(history_path), + "originalAuthorizationDigest": file_digest( + self.fixture.authorization_path + ), + "expectedCurrentPointerDigest": POINTER, + "allowedScope": "successor-baseline:activate", + "authorizedAt": timestamp(now), + "expiresAt": timestamp(now + dt.timedelta(minutes=30)), + "status": "approved", + "redaction": redaction(), + } + path = self.root / f"{receipt_path.stem}-activation-authorization.json" + write_json(path, authorization) + return path + + @staticmethod + def backend_source(lazy_dependency: str | None = None) -> str: + lazy_import = "" + lazy_result = "return 'authenticated-site'" + if lazy_dependency is not None: + lazy_import = f"import {lazy_dependency}\n " + lazy_result = f"return {lazy_dependency}.ORIGIN" + return ( + "class Backend:\n" + " def observe_public_state(self, request):\n" + f" {lazy_import}{lazy_result}\n" + " def publish_target(self, target, request, protected_input):\n" + " return None\n" + " def verify_publication(self, request):\n" + " return None\n" + " def observe_latest_pointer(self, request):\n" + " return None\n" + " def activate_latest(self, request, protected_input):\n" + " return None\n" + "def factory():\n" + " return Backend()\n" + ) + + def test_backend_loader_rejects_unscrubbed_target_credentials(self) -> None: + environment = { + publication.BACKEND_FACTORY_ENV: "reviewed_backend:factory", + publication.BACKEND_SITE_ENV: str(self.root.resolve()), + publication.CATALOG_INPUT_ENV: CATALOG_SECRET, + } + + with self.assertRaisesRegex( + publication.AdapterError, + "protected-input-environment-not-scrubbed", + ): + publication._load_backend(environment) + + def test_backend_loader_uses_authenticated_site_instead_of_checkout_shadow( + self, + ) -> None: + site = self.root / "authenticated-site" + checkout = self.root / "candidate-checkout" + site.mkdir() + checkout.mkdir() + module_name = "stable_maintenance_reviewed_backend_direct" + (site / f"{module_name}.py").write_text( + self.backend_source(), encoding="utf-8" + ) + (checkout / f"{module_name}.py").write_text( + "raise RuntimeError('candidate checkout backend executed')\n", + encoding="utf-8", + ) + original_path = list(sys.path) + sys.path.insert(0, str(checkout)) + try: + backend = publication._load_backend( + { + publication.BACKEND_FACTORY_ENV: f"{module_name}:factory", + publication.BACKEND_SITE_ENV: str(site.resolve()), + } + ) + self.assertEqual( + "authenticated-site", backend.observe_public_state(None) + ) + loaded = sys.modules[module_name] + self.assertTrue( + Path(loaded.__file__).resolve().is_relative_to(site.resolve()) + ) + finally: + sys.path[:] = original_path + sys.modules.pop(module_name, None) + + def test_backend_lazy_import_replaces_preloaded_checkout_shadow(self) -> None: + site = self.root / "authenticated-lazy-site" + checkout = self.root / "candidate-lazy-checkout" + site.mkdir() + checkout.mkdir() + module_name = "stable_maintenance_reviewed_backend_lazy" + dependency_name = "stable_maintenance_reviewed_backend_dependency" + (site / f"{module_name}.py").write_text( + self.backend_source(dependency_name), encoding="utf-8" + ) + (site / f"{dependency_name}.py").write_text( + "ORIGIN = 'authenticated-site'\n", encoding="utf-8" + ) + (checkout / f"{dependency_name}.py").write_text( + "ORIGIN = 'candidate-checkout'\n", encoding="utf-8" + ) + original_path = list(sys.path) + sys.path.insert(0, str(checkout)) + try: + shadow = importlib.import_module(dependency_name) + self.assertEqual("candidate-checkout", shadow.ORIGIN) + backend = publication._load_backend( + { + publication.BACKEND_FACTORY_ENV: f"{module_name}:factory", + publication.BACKEND_SITE_ENV: str(site.resolve()), + } + ) + self.assertEqual( + "authenticated-site", backend.observe_public_state(None) + ) + loaded = sys.modules[dependency_name] + self.assertTrue( + Path(loaded.__file__).resolve().is_relative_to(site.resolve()) + ) + finally: + sys.path[:] = original_path + sys.modules.pop(module_name, None) + sys.modules.pop(dependency_name, None) + + def test_backend_loader_requires_explicit_authenticated_site(self) -> None: + with self.assertRaisesRegex( + publication.AdapterError, + "protected-publication-backend-site-not-authenticated", + ): + publication._load_backend( + {publication.BACKEND_FACTORY_ENV: "reviewed_backend:factory"} + ) + + def test_preflight_requires_prestaged_artifact_base(self) -> None: + absent = self.operations() + absent.targets["artifactBase"] = "absent" + outcome = publication.preflight( + self.fixture.root, POINTER, absent, now=NOW + ) + self.assertFalse(outcome.passed) + self.assertEqual("absent", outcome.artifact["publicState"]) + self.assertEqual( + "artifact-base-not-prestaged", outcome.artifact["failureCategory"] + ) + self.assertEqual([], absent.publish_calls) + + matching = self.operations(status="matching", operation="verified-existing") + matching.latest_candidate = self.fixture.candidate_digest + outcome = publication.preflight( + self.fixture.root, POINTER, matching, now=NOW + ) + self.assertTrue(outcome.passed) + self.assertEqual("matching-existing", outcome.artifact["publicState"]) + + def test_preflight_records_conflict_partial_and_unavailable_state(self) -> None: + for target_status, expected in ( + ("conflict", "conflict"), + ("unavailable", "unavailable"), + ): + with self.subTest(target_status=target_status): + operations = self.operations(status=target_status) + outcome = publication.preflight( + self.fixture.root, POINTER, operations, now=NOW + ) + self.assertFalse(outcome.passed) + self.assertEqual(expected, outcome.artifact["publicState"]) + self.assertFalse(outcome.artifact["sideEffectsPerformed"]) + + resumable = self.operations() + outcome = publication.preflight(self.fixture.root, POINTER, resumable, now=NOW) + self.assertTrue(outcome.passed) + self.assertEqual("resumable-prefix", outcome.artifact["publicState"]) + + operations = self.operations() + operations.targets["githubRelease"] = "matching" + outcome = publication.preflight(self.fixture.root, POINTER, operations, now=NOW) + self.assertFalse(outcome.passed) + self.assertEqual("partial", outcome.artifact["publicState"]) + + def test_duplicate_json_and_mutated_frozen_asset_fail_closed(self) -> None: + self.fixture.plan_path.write_text( + '{"schemaVersion":1,"schemaVersion":1}\n', encoding="utf-8" + ) + with self.assertRaisesRegex(publication.AdapterError, "duplicate-json-field"): + self.fixture.load() + + replacement = BundleFixture(self.root / "mutated-bundle") + replacement.product.write_bytes(b"changed after authorization\n") + with self.assertRaisesRegex( + publication.AdapterError, "publication-plan-asset-byte-mismatch" + ): + replacement.load() + + replaced_signature = BundleFixture(self.root / "mutated-catalog-signature") + replaced_signature.catalog_signature.write_bytes( + b"changed detached catalog signature after authorization\n" + ) + with self.assertRaisesRegex( + publication.AdapterError, "publication-plan-asset-byte-mismatch" + ): + replaced_signature.load() + + def test_substituted_candidate_freeze_record_fails_closed(self) -> None: + replacement = BundleFixture(self.root / "substituted-freeze-bundle") + replacement.candidate_freeze["producer"]["runAttempt"] = 2 + write_json(replacement.candidate_freeze_path, replacement.candidate_freeze) + + with self.assertRaisesRegex( + publication.AdapterError, "authenticated-candidate-input-binding-mismatch" + ): + replacement.load() + + def test_publish_revalidates_before_every_target_and_writes_exact_receipts(self) -> None: + operations = self.operations() + source_ref_checks: list[str] = [] + receipt = self.root / "publication-receipt.json" + core_receipt = self.root / "core-receipt.json" + outcome = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + lambda record: source_ref_checks.append( + str(record.plan["sourceCommit"]) + ), + receipt, + core_receipt, + now=lambda: NOW, + ) + self.assertTrue(outcome.passed) + self.assertEqual(list(publication.TARGETS[1:]), operations.publish_calls) + self.assertEqual([COMMIT] * len(publication.TARGETS[1:]), source_ref_checks) + self.assertEqual( + [ + ("tag", None), + ("githubRelease", None), + ("assets", None), + ("stableCatalog", "stable-catalog"), + ("coreUpdate", "core-update"), + ], + operations.publish_input_purposes, + ) + self.assertEqual( + [ + ("tag", None), + ("githubRelease", None), + ("assets", None), + ("stableCatalog", CATALOG_SECRET), + ("coreUpdate", CORE_UPDATE_SECRET), + ], + operations.publish_input_values, + ) + self.assertEqual(len(publication.TARGETS) + 1, operations.observe_calls) + for path, value in outcome.artifacts.items(): + publication._write_canonical( + path, + value, + ( + self.protected_inputs().stable_catalog, + self.protected_inputs().core_update, + ), + ) + encoded = path.read_text(encoding="utf-8") + self.assertNotIn(CATALOG_SECRET, encoded) + self.assertNotIn(CORE_UPDATE_SECRET, encoded) + self.assertEqual( + "publication-complete", json.loads(receipt.read_text())["publicationState"] + ) + + def test_publish_resumes_only_an_exact_matching_target_prefix(self) -> None: + operations = self.operations(fail_target="assets") + receipt = self.root / "resumed-publication-receipt.json" + core_receipt = self.root / "resumed-core-receipt.json" + + failed = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + lambda _record: None, + receipt, + core_receipt, + now=lambda: NOW, + ) + + self.assertFalse(failed.passed) + self.assertEqual( + ["tag", "githubRelease", "assets"], + operations.publish_calls, + ) + self.assertEqual( + "resumable-prefix", + publication._public_state_class( + publication.PublicSnapshot(POINTER, dict(operations.targets)) + ), + ) + + operations.fail_target = None + operations.publish_calls.clear() + resumed = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + lambda _record: None, + receipt, + core_receipt, + now=lambda: NOW, + ) + + self.assertTrue(resumed.passed) + self.assertEqual( + ["assets", "stableCatalog", "coreUpdate"], + operations.publish_calls, + ) + + def test_exact_matching_publication_is_idempotent_and_has_no_mutation(self) -> None: + operations = self.operations(status="matching", operation="verified-existing") + operations.latest_candidate = self.fixture.candidate_digest + outcome = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + lambda _record: None, + self.root / "receipt.json", + self.root / "core.json", + now=lambda: NOW, + ) + self.assertTrue(outcome.passed) + self.assertEqual([], operations.publish_calls) + self.assertEqual( + "verified-existing", + outcome.artifacts[self.root / "receipt.json"]["tag"]["operation"], + ) + + def test_mid_publication_failure_records_truthful_partial_state_without_secret(self) -> None: + operations = self.operations(fail_target="stableCatalog") + receipt_path = self.root / "partial.json" + outcome = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + lambda _record: None, + receipt_path, + self.root / "core-partial.json", + now=lambda: NOW, + ) + self.assertFalse(outcome.passed) + audit_path = publication._failure_audit_path(receipt_path) + self.assertNotIn(receipt_path, outcome.artifacts) + self.assertNotIn(self.root / "core-partial.json", outcome.artifacts) + audit = outcome.artifacts[audit_path] + self.assertEqual( + "stable-1.0-maintenance-publication-failure-audit", audit["kind"] + ) + self.assertEqual("publication-partial", audit["publicationState"]) + self.assertEqual("matching", audit["observedPublicState"]["tag"]) + self.assertEqual("absent", audit["observedPublicState"]["stableCatalog"]) + self.assertTrue(audit["sideEffectsMayHaveOccurred"]) + self.assertEqual( + ["tag", "githubRelease", "assets", "stableCatalog"], + audit["attemptedTargets"], + ) + self.assertEqual( + ["artifactBase", "tag", "githubRelease", "assets"], + audit["completedTargets"], + ) + self.assertEqual( + ["tag", "githubRelease", "assets", "stableCatalog"], + operations.publish_calls, + ) + encoded = canonical_bytes(audit).decode("utf-8") + self.assertNotIn(CATALOG_SECRET, encoded) + self.assertNotIn(CORE_UPDATE_SECRET, encoded) + + def test_failed_observation_retains_attempted_and_completed_mutations(self) -> None: + class ObservationUnavailableAfterFailure(FakeOperations): + def observe_public_state( + self, request: publication.PublicationRequest + ) -> publication.PublicSnapshot: + if self.fail_target in self.publish_calls: + raise RuntimeError("public observation unavailable") + return super().observe_public_state(request) + + operations = ObservationUnavailableAfterFailure( + self.fixture.material(), fail_target="githubRelease" + ) + operations.targets["artifactBase"] = "matching" + receipt_path = self.root / "unavailable-after-mutation.json" + + outcome = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + lambda _record: None, + receipt_path, + self.root / "unavailable-after-mutation-core.json", + now=lambda: NOW, + ) + + self.assertFalse(outcome.passed) + audit = outcome.artifacts[publication._failure_audit_path(receipt_path)] + self.assertEqual("publication-partial", audit["publicationState"]) + self.assertEqual( + {target: "unavailable" for target in publication.TARGETS}, + audit["observedPublicState"], + ) + self.assertEqual( + ["tag", "githubRelease"], audit["attemptedTargets"] + ) + self.assertEqual(["artifactBase", "tag"], audit["completedTargets"]) + self.assertTrue(audit["sideEffectsMayHaveOccurred"]) + + def test_authorization_expiry_blocks_first_mutation(self) -> None: + operations = self.operations() + outcome = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + lambda _record: None, + self.root / "expired.json", + self.root / "expired-core.json", + now=lambda: NOW + dt.timedelta(days=730), + ) + self.assertFalse(outcome.passed) + self.assertEqual([], operations.publish_calls) + + def test_authorization_expiring_during_ref_check_blocks_first_mutation(self) -> None: + operations = self.operations() + expired = NOW + dt.timedelta(days=730) + times = iter((NOW, NOW, expired, expired)) + + outcome = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + lambda _record: None, + self.root / "expired-after-ref.json", + self.root / "expired-after-ref-core.json", + now=lambda: next(times), + ) + + self.assertFalse(outcome.passed) + self.assertEqual([], operations.publish_calls) + audit = outcome.artifacts[ + publication._failure_audit_path(self.root / "expired-after-ref.json") + ] + self.assertEqual( + "authorization-expired-before-mutation", audit["failureCategory"] + ) + + def test_remote_source_ref_verifier_authenticates_exact_head_without_leaking_token( + self, + ) -> None: + token = "github-protected-source-ref-token" + requests: list[object] = [] + + class Response: + status = 200 + + def __enter__(self) -> "Response": + return self + + def __exit__(self, *_arguments: object) -> None: + return None + + @staticmethod + def read(_size: int) -> bytes: + return json.dumps( + { + "ref": f"refs/heads/release/{BUILD}", + "object": {"type": "commit", "sha": COMMIT}, + } + ).encode("utf-8") + + def open_source_ref(request: object, *, timeout: int) -> Response: + self.assertEqual(30, timeout) + requests.append(request) + return Response() + + revalidator = publication._make_remote_source_ref_revalidator( + publication.SOURCE_REPOSITORY, + f"release/{BUILD}", + COMMIT, + publication.GITHUB_TOKEN_ENV, + {publication.GITHUB_TOKEN_ENV: token}, + opener=open_source_ref, + ) + + revalidator(self.fixture.load()) + + self.assertEqual(1, len(requests)) + self.assertEqual( + f"Bearer {token}", requests[0].get_header("Authorization") + ) + + def test_workflow_publish_and_activation_use_literal_source_ref_token_contract( + self, + ) -> None: + workflow = ( + SCRIPT.parents[3] + / ".github/workflows/stable-1.0-maintenance-release.yml" + ).read_text(encoding="utf-8") + + def step(name: str) -> str: + marker = f" - name: {name}" + start = workflow.index(marker) + end = workflow.find("\n - name:", start + len(marker)) + return workflow[start:] if end == -1 else workflow[start:end] + + publish_call_site = step("Publish or idempotently verify exact bytes") + self.assertIn( + "GITHUB_TOKEN: ${{ secrets.LEUMOR_GITHUB_TOKEN }}", publish_call_site + ) + self.assertNotIn("GITHUB_TOKEN: ${{ github.token }}", publish_call_site) + self.assertIn("--github-token-env GITHUB_TOKEN", publish_call_site) + self.assertNotIn("--github-token-env GH_TOKEN", publish_call_site) + + activation_call_site = step("Compare-and-swap latest-published pointer") + self.assertIn("GITHUB_TOKEN: ${{ github.token }}", activation_call_site) + self.assertIn("--github-token-env GITHUB_TOKEN", activation_call_site) + self.assertNotIn("--github-token-env GH_TOKEN", activation_call_site) + self.assertEqual(2, workflow.count("--github-token-env GITHUB_TOKEN")) + self.assertNotIn("--github-token-env GH_TOKEN", workflow) + + def test_remote_source_ref_movement_blocks_first_publication_target(self) -> None: + operations = self.operations() + + def moved(_record: object) -> None: + raise publication.AdapterError("remote-source-ref-moved") + + outcome = publication.publish_or_verify_exact( + self.fixture.root, + POINTER, + operations, + self.protected_inputs(), + moved, + self.root / "moved-ref-receipt.json", + self.root / "moved-ref-core.json", + now=lambda: NOW, + ) + + self.assertFalse(outcome.passed) + self.assertEqual([], operations.publish_calls) + audit = outcome.artifacts[ + publication._failure_audit_path(self.root / "moved-ref-receipt.json") + ] + self.assertEqual("remote-source-ref-moved", audit["failureCategory"]) + + def test_independent_verification_is_read_only_and_emits_successor_records(self) -> None: + operations = self.operations(status="matching") + operations.latest_candidate = self.fixture.candidate_digest + paths = [ + self.root / name + for name in ("receipt", "core", "successor", "history") + ] + outcome = publication.verify_public_state( + self.fixture.root, operations, *paths + ) + self.assertTrue(outcome.passed) + self.assertEqual([], operations.publish_calls) + self.assertEqual(set(paths), set(outcome.artifacts)) + self.assertEqual( + "stable-1.0-maintenance-successor-baseline", + outcome.artifacts[paths[2]]["kind"], + ) + + def test_publication_receipt_identity_matches_the_engine_contract(self) -> None: + receipt = dict(self.fixture.material().maintenance_receipt) + + self.assertEqual( + engine_receipt_identity(receipt), publication._receipt_identity(receipt) + ) + + def test_verified_receipt_cannot_substitute_authorized_public_topology(self) -> None: + for field in ( + "githubReleasePageUri", + "deploymentServicePublicUri", + "latestPointerPublicUri", + ): + with self.subTest(field=field): + material = self.fixture.material() + receipt = copy.deepcopy(material.maintenance_receipt) + receipt[field] = f"https://substituted.example.com/{field}" + replaced = publication.VerificationMaterial( + receipt, + material.core_update_receipt, + material.successor_baseline, + material.history_entry, + ) + with self.assertRaisesRegex( + publication.AdapterError, + "maintenance-publication-receipt-mismatch", + ): + publication._validate_verification_material( + replaced, self.fixture.load() + ) + + def test_independent_verification_rejects_provider_successor_surface_drift(self) -> None: + material = self.fixture.material() + successor = copy.deepcopy(material.successor_baseline) + successor["platformApi"]["currentContractDigest"] = digest( + "provider-substituted-contract" + ) + operations = FakeOperations( + publication.VerificationMaterial( + material.maintenance_receipt, + material.core_update_receipt, + successor, + material.history_entry, + ), + target_status="matching", + ) + operations.latest_candidate = self.fixture.candidate_digest + paths = [self.root / name for name in ("receipt", "core", "successor", "history")] + + outcome = publication.verify_public_state( + self.fixture.root, operations, *paths + ) + + self.assertFalse(outcome.passed) + self.assertEqual([], operations.publish_calls) + audit_path = publication._failure_audit_path(paths[0]) + self.assertEqual( + "successor-baseline-binding-mismatch", + outcome.artifacts[audit_path]["failureCategory"], + ) + self.assertNotIn(paths[0], outcome.artifacts) + self.assertNotIn(paths[2], outcome.artifacts) + + def test_independent_verification_rejects_unverified_github_release(self) -> None: + for status in ("conflict", "unavailable"): + with self.subTest(status=status): + material = self.fixture.material() + receipt = copy.deepcopy(material.maintenance_receipt) + receipt["githubRelease"]["verificationStatus"] = status + operations = FakeOperations( + publication.VerificationMaterial( + receipt, + material.core_update_receipt, + material.successor_baseline, + material.history_entry, + ), + target_status="matching", + ) + operations.latest_candidate = self.fixture.candidate_digest + paths = [ + self.root / f"{status}-{name}" + for name in ("receipt", "core", "successor", "history") + ] + + outcome = publication.verify_public_state( + self.fixture.root, operations, *paths + ) + + self.assertFalse(outcome.passed) + self.assertEqual([], operations.publish_calls) + audit_path = publication._failure_audit_path(paths[0]) + self.assertEqual( + "maintenance-publication-receipt-mismatch", + outcome.artifacts[audit_path]["failureCategory"], + ) + self.assertNotIn(paths[0], outcome.artifacts) + self.assertNotIn(paths[2], outcome.artifacts) + + def test_successor_verification_carries_inherited_hotfix_obligation(self) -> None: + material = self.fixture.material() + inherited = { + "status": "open", + "generatedAt": timestamp(NOW - dt.timedelta(days=1)), + "obligationDigest": digest("inherited-hotfix-obligation"), + "deadline": timestamp(NOW + dt.timedelta(days=6)), + "closureEvidenceDigest": None, + "blocksRoutineMaintenance": True, + "obligatedReleaseId": "stable-1-0-hotfix-300", + "obligatedBuildVersion": "300", + "obligatedProductDigest": digest("obligated-product"), + "obligatedCandidateIdentityDigest": digest("obligated-candidate"), + "obligatedCandidateFreezeDigest": digest("obligated-freeze"), + "obligatedCandidateFrozenAt": timestamp(NOW - dt.timedelta(days=3)), + "obligatedPredecessorBuild": "299", + "obligatedPredecessorProductDigest": digest("obligated-predecessor"), + "authorizationDigest": digest("obligated-authorization"), + } + bundle = self.fixture.load() + bundle = dataclasses.replace( + bundle, + predecessor_baseline={ + **bundle.predecessor_baseline, + "hotfixFollowUp": inherited, + }, + ) + successor = copy.deepcopy(material.successor_baseline) + successor["hotfixFollowUp"] = dict(inherited) + successor["lineage"]["history"][-1]["baselineIdentityDigest"] = ( + publication._successor_identity(successor) + ) + successor["lineage"]["lineageDigest"] = semantic_digest( + successor["lineage"]["history"] + ) + history_digest = "sha256:" + hashlib.sha256( + canonical_bytes(material.history_entry) + ).hexdigest() + + publication._validate_successor( + successor, + material.history_entry, + history_digest, + bundle, + publication._receipt_identity(material.maintenance_receipt), + ) + successor["hotfixFollowUp"] = { + "status": "not-required", + "generatedAt": timestamp(NOW), + "obligationDigest": None, + "deadline": None, + "closureEvidenceDigest": None, + "blocksRoutineMaintenance": False, + } + + with self.assertRaises(publication.AdapterError) as raised: + publication._validate_successor( + successor, + material.history_entry, + history_digest, + bundle, + publication._receipt_identity(material.maintenance_receipt), + ) + + self.assertEqual("successor-baseline-binding-mismatch", raised.exception.code) + + def test_successor_verification_carries_overdue_hotfix_obligation(self) -> None: + inherited = { + "status": "overdue", + "generatedAt": timestamp(NOW - dt.timedelta(days=10)), + "obligationDigest": digest("overdue-hotfix-obligation"), + "deadline": timestamp(NOW - dt.timedelta(days=3)), + "closureEvidenceDigest": None, + "blocksRoutineMaintenance": True, + "obligatedReleaseId": "stable-1-0-hotfix-300", + "obligatedBuildVersion": "300", + "obligatedProductDigest": digest("obligated-product"), + "obligatedCandidateIdentityDigest": digest("obligated-candidate"), + "obligatedCandidateFreezeDigest": digest("obligated-freeze"), + "obligatedCandidateFrozenAt": timestamp(NOW - dt.timedelta(days=10)), + "obligatedPredecessorBuild": "299", + "obligatedPredecessorProductDigest": digest("obligated-predecessor"), + "authorizationDigest": digest("obligated-authorization"), + } + bundle = self.fixture.load() + bundle = dataclasses.replace( + bundle, + predecessor_baseline={ + **bundle.predecessor_baseline, + "hotfixFollowUp": inherited, + }, + ) + + self.assertEqual( + inherited, + publication._expected_successor_follow_up(bundle), + ) + + def test_successor_verification_applies_authenticated_closure_overlay(self) -> None: + base_bundle = self.fixture.load() + inherited = { + "status": "open", + "generatedAt": timestamp(NOW - dt.timedelta(days=2)), + "obligationDigest": digest("closed-hotfix-obligation"), + "deadline": timestamp(NOW + dt.timedelta(days=5)), + "closureEvidenceDigest": None, + "blocksRoutineMaintenance": True, + "obligatedReleaseId": "stable-1-0-hotfix-300", + "obligatedBuildVersion": "300", + "obligatedProductDigest": digest("closed-product"), + "obligatedCandidateIdentityDigest": digest("closed-candidate"), + "obligatedCandidateFreezeDigest": digest("closed-freeze"), + "obligatedCandidateFrozenAt": timestamp(NOW - dt.timedelta(days=3)), + "obligatedPredecessorBuild": "299", + "obligatedPredecessorProductDigest": digest("closed-predecessor"), + "authorizationDigest": digest("closed-authorization"), + } + receipt_file_digest = digest("closed-publication-receipt") + receipt_identity_digest = digest("closed-publication-receipt-identity") + predecessor_baseline = { + **self.fixture.ga_baseline, + "publication": {"receiptIdentityDigest": receipt_identity_digest}, + "hotfixFollowUp": inherited, + } + predecessor_path = self.root / "closed-predecessor-baseline.json" + write_json(predecessor_path, predecessor_baseline) + closure = { + "schemaVersion": 1, + "kind": "stable-1.0-hotfix-follow-up-closure", + "generatedAt": timestamp(NOW), + "closedAt": timestamp(NOW), + "status": "closed", + "releaseId": inherited["obligatedReleaseId"], + "buildVersion": inherited["obligatedBuildVersion"], + "releaseClass": "security-hotfix", + "productDigest": inherited["obligatedProductDigest"], + "candidateIdentityDigest": inherited["obligatedCandidateIdentityDigest"], + "predecessorBuild": inherited["obligatedPredecessorBuild"], + "predecessorProductDigest": inherited[ + "obligatedPredecessorProductDigest" + ], + "successorBaselineDigest": file_digest(predecessor_path), + "publicationReceiptDigest": receipt_file_digest, + "publicationReceiptIdentityDigest": receipt_identity_digest, + "authorizationDigest": inherited["authorizationDigest"], + "latestPublishedPointerDigest": POINTER, + "obligationDigest": inherited["obligationDigest"], + "fullEvidenceDigest": digest("closed-full-evidence"), + "owner": "stable-security-team", + "approver": "stable-security-release-manager", + "redaction": redaction(), + } + closure_path = ( + self.fixture.authenticated_inputs / publication.FOLLOW_UP_CLOSURE_FILE + ) + write_json(closure_path, closure) + lineage = copy.deepcopy(self.fixture.lineage) + lineage["predecessor"].update( + { + "publicationReceiptDigest": receipt_file_digest, + "successorBaselineDigest": file_digest(predecessor_path), + "hotfixFollowUpClosureDigest": file_digest(closure_path), + } + ) + lineage["latestPublishedPointerDigest"] = POINTER + + loaded_path, loaded_closure = publication._load_follow_up_closure( + self.fixture.authenticated_inputs, + lineage, + predecessor_baseline, + predecessor_path, + ) + bundle = dataclasses.replace( + base_bundle, + lineage=lineage, + predecessor_baseline_path=predecessor_path, + predecessor_baseline=predecessor_baseline, + follow_up_closure_path=loaded_path, + follow_up_closure=loaded_closure, + ) + + expected = publication._expected_successor_follow_up(bundle) + + self.assertEqual("not-required", expected["status"]) + self.assertFalse(expected["blocksRoutineMaintenance"]) + self.assertIsNone(expected["obligationDigest"]) + + substituted_closure = copy.deepcopy(closure) + substituted_closure["predecessorBuild"] = "298" + write_json(closure_path, substituted_closure) + substituted_lineage = copy.deepcopy(lineage) + substituted_lineage["predecessor"]["hotfixFollowUpClosureDigest"] = ( + file_digest(closure_path) + ) + with self.assertRaises(publication.AdapterError) as raised: + publication._load_follow_up_closure( + self.fixture.authenticated_inputs, + substituted_lineage, + predecessor_baseline, + predecessor_path, + ) + self.assertEqual( + "hotfix-follow-up-closure-binding-mismatch", raised.exception.code + ) + + write_json(closure_path, closure) + closure["fullEvidenceDigest"] = digest("tampered-full-evidence") + write_json(closure_path, closure) + with self.assertRaises(publication.AdapterError) as raised: + publication._load_follow_up_closure( + self.fixture.authenticated_inputs, + lineage, + predecessor_baseline, + predecessor_path, + ) + self.assertEqual( + "hotfix-follow-up-closure-digest-mismatch", raised.exception.code + ) + + def test_verification_rejects_duplicate_failed_asset_observation(self) -> None: + material = self.fixture.material() + receipt = copy.deepcopy(material.maintenance_receipt) + duplicate = dict(receipt["assets"][0]) + duplicate["operation"] = "partial" + duplicate["verificationStatus"] = "conflict" + receipt["assets"].append(duplicate) + + with self.assertRaises(publication.AdapterError) as raised: + publication._validate_verification_material( + publication.VerificationMaterial( + receipt, + material.core_update_receipt, + material.successor_baseline, + material.history_entry, + ), + self.fixture.load(), + ) + + self.assertEqual( + "maintenance-publication-receipt-mismatch", raised.exception.code + ) + + def test_verification_rejects_duplicate_failed_core_package_observation(self) -> None: + material = self.fixture.material() + core_receipt = copy.deepcopy(material.core_update_receipt) + duplicate = dict(core_receipt["referencedPackages"][0]) + duplicate["verificationStatus"] = "fail" + core_receipt["referencedPackages"].append(duplicate) + receipt = copy.deepcopy(material.maintenance_receipt) + receipt["coreUpdateReceiptDigest"] = ( + "sha256:" + hashlib.sha256(canonical_bytes(core_receipt)).hexdigest() + ) + + with self.assertRaises(publication.AdapterError) as raised: + publication._validate_verification_material( + publication.VerificationMaterial( + receipt, + core_receipt, + material.successor_baseline, + material.history_entry, + ), + self.fixture.load(), + ) + + self.assertEqual( + "core-update-publication-receipt-mismatch", raised.exception.code + ) + + def test_activation_is_compare_and_swap_and_idempotent(self) -> None: + material = self.fixture.material() + successor_path = self.root / "successor.json" + history_path = self.root / "history.json" + receipt_path = self.root / "receipt.json" + write_json(successor_path, material.successor_baseline) + write_json(history_path, material.history_entry) + write_json(receipt_path, material.maintenance_receipt) + operations = self.operations() + activation_path = self.root / "activation.json" + outcome = publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization(successor_path, history_path, receipt_path), + POINTER, + operations, + self.maintenance_state_input(), + lambda _record: None, + activation_path, + now=NOW, + ) + self.assertTrue(outcome.passed) + self.assertEqual(1, operations.activate_calls) + self.assertEqual("maintenance-state", operations.activation_input_purpose) + self.assertEqual(MAINTENANCE_STATE_SECRET, operations.activation_input_value) + self.assertEqual("activated", outcome.artifacts[activation_path]["pointerUpdate"]) + expected_pointer = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-latest-published", + "generatedAt": material.successor_baseline["generatedAt"], + "releaseId": RELEASE_ID, + "buildVersion": BUILD, + "releaseClass": "maintenance", + "baselineDigest": file_digest(successor_path), + "baselineIdentityDigest": publication._successor_identity( + material.successor_baseline + ), + "publicationReceiptDigest": file_digest(receipt_path), + "publicationReceiptIdentityDigest": publication._receipt_identity( + material.maintenance_receipt + ), + "lineageDigest": material.successor_baseline["lineage"]["lineageDigest"], + "historyDigest": file_digest(history_path), + "compareAndSwapPredecessorBaselineDigest": material.successor_baseline[ + "previousBaselineDigest" + ], + "status": "active", + "redaction": redaction(), + } + expected_pointer_bytes = canonical_bytes(expected_pointer) + expected_pointer_digest = ( + "sha256:" + hashlib.sha256(expected_pointer_bytes).hexdigest() + ) + self.assertEqual(expected_pointer_bytes, operations.activation_pointer_bytes) + self.assertEqual(expected_pointer_digest, operations.pointer.pointer_digest) + self.assertEqual( + expected_pointer_digest, + outcome.artifacts[activation_path]["expectedActivatedPointerDigest"], + ) + self.assertEqual( + expected_pointer_digest, + outcome.artifacts[activation_path]["observedPointerDigest"], + ) + + idempotent_path = self.root / "activation-idempotent.json" + outcome = publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization(successor_path, history_path, receipt_path), + POINTER, + operations, + self.maintenance_state_input(), + lambda _record: None, + idempotent_path, + now=NOW, + ) + self.assertTrue(outcome.passed) + self.assertEqual(1, operations.activate_calls) + self.assertEqual( + "verified-existing", outcome.artifacts[idempotent_path]["operation"] + ) + self.assertEqual( + expected_pointer_digest, + outcome.artifacts[idempotent_path]["observedPointerDigest"], + ) + + def test_activation_rejects_inexact_pointer_bytes_after_mutation(self) -> None: + material = self.fixture.material() + successor_path = self.root / "successor-inexact-pointer.json" + history_path = self.root / "history-inexact-pointer.json" + receipt_path = self.root / "receipt-inexact-pointer.json" + write_json(successor_path, material.successor_baseline) + write_json(history_path, material.history_entry) + write_json(receipt_path, material.maintenance_receipt) + operations = self.operations() + operations.activation_pointer_digest_override = digest("inexact-pointer") + activation_path = self.root / "activation-inexact-pointer.json" + + outcome = publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization(successor_path, history_path, receipt_path), + POINTER, + operations, + self.maintenance_state_input(), + lambda _record: None, + activation_path, + now=NOW, + ) + + self.assertFalse(outcome.passed) + self.assertEqual(1, operations.activate_calls) + self.assertEqual( + "latest-pointer-post-activation-mismatch", + outcome.artifacts[activation_path]["failureCategory"], + ) + self.assertEqual("partial", outcome.artifacts[activation_path]["operation"]) + self.assertEqual( + digest("inexact-pointer"), + outcome.artifacts[activation_path]["observedPointerDigest"], + ) + self.assertNotEqual( + outcome.artifacts[activation_path]["expectedActivatedPointerDigest"], + outcome.artifacts[activation_path]["observedPointerDigest"], + ) + + def test_activation_rejects_inexact_preexisting_active_pointer(self) -> None: + material = self.fixture.material() + successor_path = self.root / "successor-inexact-existing.json" + history_path = self.root / "history-inexact-existing.json" + receipt_path = self.root / "receipt-inexact-existing.json" + write_json(successor_path, material.successor_baseline) + write_json(history_path, material.history_entry) + write_json(receipt_path, material.maintenance_receipt) + operations = self.operations() + operations.pointer = publication.PointerSnapshot( + "observed", + digest("inexact-existing-pointer"), + file_digest(successor_path), + self.fixture.candidate_digest, + ) + activation_path = self.root / "activation-inexact-existing.json" + + outcome = publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization(successor_path, history_path, receipt_path), + POINTER, + operations, + self.maintenance_state_input(), + lambda _record: None, + activation_path, + now=NOW, + ) + + self.assertFalse(outcome.passed) + self.assertEqual(0, operations.activate_calls) + self.assertEqual( + "latest-pointer-compare-and-swap-conflict", + outcome.artifacts[activation_path]["failureCategory"], + ) + self.assertEqual( + digest("inexact-existing-pointer"), + outcome.artifacts[activation_path]["observedPointerDigest"], + ) + + def test_activation_pointer_conflict_never_mutates(self) -> None: + material = self.fixture.material() + successor_path = self.root / "successor.json" + history_path = self.root / "history.json" + receipt_path = self.root / "receipt.json" + write_json(successor_path, material.successor_baseline) + write_json(history_path, material.history_entry) + write_json(receipt_path, material.maintenance_receipt) + operations = self.operations() + operations.pointer = publication.PointerSnapshot( + "observed", digest("1"), PREVIOUS_BASELINE, digest("2") + ) + activation_path = self.root / "activation-conflict.json" + outcome = publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization(successor_path, history_path, receipt_path), + POINTER, + operations, + self.maintenance_state_input(), + lambda _record: None, + activation_path, + now=NOW, + ) + self.assertFalse(outcome.passed) + self.assertEqual(0, operations.activate_calls) + self.assertEqual( + "latest-pointer-compare-and-swap-conflict", + outcome.artifacts[activation_path]["failureCategory"], + ) + + def test_activation_rejects_authorization_substituted_after_publication(self) -> None: + material = self.fixture.material() + successor_path = self.root / "successor-auth-substitution.json" + history_path = self.root / "history-auth-substitution.json" + receipt_path = self.root / "receipt-auth-substitution.json" + authorization_path = self.root / "authorization-substitution.json" + write_json(successor_path, material.successor_baseline) + write_json(history_path, material.history_entry) + write_json(receipt_path, material.maintenance_receipt) + substituted = copy.deepcopy(self.fixture.authorization) + substituted["approverIdentity"] = "replacement-release-manager" + write_json(authorization_path, substituted) + + with self.assertRaises(publication.AdapterError) as raised: + publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + authorization_path, + self.activation_authorization( + successor_path, history_path, receipt_path + ), + POINTER, + self.operations(), + self.maintenance_state_input(), + lambda _record: None, + self.root / "activation-auth-substitution.json", + now=NOW, + ) + + self.assertEqual( + "baseline-activation-input-binding-mismatch", raised.exception.code + ) + + def test_activation_authorization_expiry_blocks_pointer_mutation(self) -> None: + material = self.fixture.material() + successor_path = self.root / "successor-expiring-auth.json" + history_path = self.root / "history-expiring-auth.json" + receipt_path = self.root / "receipt-expiring-auth.json" + write_json(successor_path, material.successor_baseline) + write_json(history_path, material.history_entry) + write_json(receipt_path, material.maintenance_receipt) + operations = self.operations() + times = iter((NOW, NOW + dt.timedelta(days=730))) + activation_path = self.root / "activation-expiring-auth.json" + + outcome = publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization(successor_path, history_path, receipt_path), + POINTER, + operations, + self.maintenance_state_input(), + lambda _record: None, + activation_path, + now=lambda: next(times), + ) + + self.assertFalse(outcome.passed) + self.assertEqual(0, operations.activate_calls) + self.assertEqual( + "activation-authorization-expired", + outcome.artifacts[activation_path]["failureCategory"], + ) + + def test_expired_activation_authorization_cannot_accept_idempotent_state(self) -> None: + material = self.fixture.material() + successor_path = self.root / "successor-idempotent-expired.json" + history_path = self.root / "history-idempotent-expired.json" + receipt_path = self.root / "receipt-idempotent-expired.json" + write_json(successor_path, material.successor_baseline) + write_json(history_path, material.history_entry) + write_json(receipt_path, material.maintenance_receipt) + operations = self.operations() + request = publication._load_activation_request( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization(successor_path, history_path, receipt_path), + POINTER, + ) + operations.pointer = publication.PointerSnapshot( + "observed", + request.activated_pointer_digest, + file_digest(successor_path), + self.fixture.candidate_digest, + ) + activation_path = self.root / "activation-idempotent-expired.json" + + outcome = publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization(successor_path, history_path, receipt_path), + POINTER, + operations, + self.maintenance_state_input(), + lambda _record: None, + activation_path, + now=NOW + dt.timedelta(days=730), + ) + + self.assertFalse(outcome.passed) + self.assertEqual(0, operations.activate_calls) + self.assertEqual( + "activation-authorization-expired", + outcome.artifacts[activation_path]["failureCategory"], + ) + + def test_renewed_activation_authorization_can_finalize_published_release(self) -> None: + material = self.fixture.material() + successor_path = self.root / "successor-renewed-activation.json" + history_path = self.root / "history-renewed-activation.json" + receipt_path = self.root / "receipt-renewed-activation.json" + write_json(successor_path, material.successor_baseline) + write_json(history_path, material.history_entry) + write_json(receipt_path, material.maintenance_receipt) + activation_time = NOW + dt.timedelta(days=730) + activation_path = self.root / "activation-renewed.json" + operations = self.operations() + + outcome = publication.activate_latest_baseline( + successor_path, + history_path, + receipt_path, + self.fixture.authorization_path, + self.activation_authorization( + successor_path, + history_path, + receipt_path, + now=activation_time, + ), + POINTER, + operations, + self.maintenance_state_input(), + lambda _record: None, + activation_path, + now=activation_time, + ) + + self.assertTrue(outcome.passed) + self.assertEqual(1, operations.activate_calls) + self.assertEqual("activated", outcome.artifacts[activation_path]["pointerUpdate"]) + self.assertEqual( + file_digest(self.fixture.authorization_path), + outcome.artifacts[activation_path]["authorizationDigest"], + ) + self.assertEqual( + file_digest( + self.activation_authorization( + successor_path, + history_path, + receipt_path, + now=activation_time, + ) + ), + outcome.artifacts[activation_path]["activationAuthorizationDigest"], + ) + + def test_cli_rejects_local_pr_and_self_test_contexts(self) -> None: + arguments = self.preflight_arguments(self.root / "preflight.json") + operations = self.operations() + for environment in ( + {}, + self.workflow_environment(GITHUB_EVENT_NAME="pull_request"), + self.workflow_environment(CRYPTAD_SELF_TEST="1"), + ): + with self.subTest(environment=environment): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = publication.main( + arguments, operations=operations, environ=environment + ) + self.assertEqual(1, code) + self.assertIn("protected-workflow-context-required", stderr.getvalue()) + self.assertEqual([], operations.publish_calls) + + def test_cli_accepts_exact_preflight_contract_and_never_serializes_secret(self) -> None: + output = self.root / "preflight.json" + operations = self.operations() + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = publication.main( + self.preflight_arguments(output), + operations=operations, + environ=self.workflow_environment(), + ) + self.assertEqual(0, code, stderr.getvalue()) + encoded = output.read_text(encoding="utf-8") + self.assertNotIn(CATALOG_SECRET, encoded) + self.assertNotIn(CORE_UPDATE_SECRET, encoded) + self.assertNotIn(MAINTENANCE_STATE_SECRET, encoded) + self.assertEqual("pass", json.loads(output.read_text())["status"]) + + def test_cli_provider_exception_cannot_disclose_secret(self) -> None: + operations = self.operations(fail_target="tag") + receipt = self.root / "receipt.json" + core = self.root / "core.json" + arguments = self.publication_arguments(receipt, core) + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = publication.main( + arguments, + operations=operations, + source_ref_revalidator=lambda _record: None, + environ=self.workflow_environment(), + ) + self.assertEqual(1, code) + audit = receipt.with_name(publication.FAILURE_AUDIT_FILE) + self.assertFalse(receipt.exists()) + self.assertFalse(core.exists()) + self.assertTrue(audit.is_file()) + for protected_value in (CATALOG_SECRET, CORE_UPDATE_SECRET): + self.assertNotIn(protected_value, stderr.getvalue()) + self.assertNotIn(protected_value, audit.read_text(encoding="utf-8")) + + def test_cli_isolates_target_credentials_from_backend_and_ambient_environment( + self, + ) -> None: + protected_names = ( + publication.CATALOG_INPUT_ENV, + publication.CORE_UPDATE_INPUT_ENV, + ) + + class EnvironmentAuditingOperations(FakeOperations): + def __init__(self, fixture: BundleFixture) -> None: + super().__init__(fixture.material(), target_status="absent") + self.targets["artifactBase"] = "matching" + self.ambient_observations: list[ + tuple[str, tuple[str | None, str | None]] + ] = [] + + def capture(self, operation: str) -> None: + self.ambient_observations.append( + (operation, tuple(os.environ.get(name) for name in protected_names)) + ) + + def observe_public_state( + self, request: publication.PublicationRequest + ) -> publication.PublicSnapshot: + self.capture("observe") + return super().observe_public_state(request) + + def publish_target( + self, + target: str, + request: publication.PublicationRequest, + protected_input: publication.SecretMaterial | None, + ) -> None: + self.capture(f"publish:{target}") + super().publish_target(target, request, protected_input) + + def verify_publication( + self, request: publication.PublicationRequest + ) -> publication.VerificationMaterial: + self.capture("verify") + return super().verify_publication(request) + + operations = EnvironmentAuditingOperations(self.fixture) + loader_environments: list[dict[str, str]] = [] + loader_ambient: list[tuple[str | None, str | None]] = [] + + def load_backend(environment: Mapping[str, str]) -> FakeOperations: + loader_environments.append(dict(environment)) + loader_ambient.append( + tuple(os.environ.get(name) for name in protected_names) + ) + return operations + + receipt = self.root / "isolated-receipt.json" + core_receipt = self.root / "isolated-core-receipt.json" + ambient = { + publication.CATALOG_INPUT_ENV: CATALOG_SECRET, + publication.CORE_UPDATE_INPUT_ENV: CORE_UPDATE_SECRET, + } + with mock.patch.dict(os.environ, ambient, clear=False), mock.patch.object( + publication, "_load_backend", side_effect=load_backend + ): + code = publication.main( + self.publication_arguments(receipt, core_receipt), + source_ref_revalidator=lambda _record: None, + environ=self.workflow_environment(), + ) + + self.assertEqual(0, code) + self.assertTrue(all(name not in os.environ for name in protected_names)) + + self.assertEqual([(None, None)], loader_ambient) + self.assertTrue( + all(name not in loader_environments[0] for name in protected_names) + ) + self.assertTrue(operations.ambient_observations) + self.assertTrue( + all( + values == (None, None) + for _operation, values in operations.ambient_observations + ) + ) + self.assertEqual( + [ + ("tag", None), + ("githubRelease", None), + ("assets", None), + ("stableCatalog", "stable-catalog"), + ("coreUpdate", "core-update"), + ], + operations.publish_input_purposes, + ) + self.assertEqual( + [ + ("tag", None), + ("githubRelease", None), + ("assets", None), + ("stableCatalog", CATALOG_SECRET), + ("coreUpdate", CORE_UPDATE_SECRET), + ], + operations.publish_input_values, + ) + + def test_cli_requires_each_target_specific_protected_input(self) -> None: + for missing_name in ( + publication.CATALOG_INPUT_ENV, + publication.CORE_UPDATE_INPUT_ENV, + ): + with self.subTest(missing_name=missing_name): + operations = self.operations() + environment = self.workflow_environment() + del environment[missing_name] + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = publication.main( + self.publication_arguments( + self.root / f"{missing_name}-receipt.json", + self.root / f"{missing_name}-core.json", + ), + operations=operations, + environ=environment, + ) + self.assertEqual(1, code) + self.assertEqual([], operations.publish_calls) + self.assertIn("protected-input-not-materialized", stderr.getvalue()) + + def test_cli_requires_distinct_maintenance_state_activation_input(self) -> None: + material = self.fixture.material() + successor = self.root / "activation-successor.json" + history = self.root / "activation-history.json" + receipt = self.root / "activation-publication-receipt.json" + write_json(successor, material.successor_baseline) + write_json(history, material.history_entry) + write_json(receipt, material.maintenance_receipt) + operations = self.operations() + environment = self.workflow_environment(GITHUB_JOB="activate-latest-baseline") + del environment[publication.MAINTENANCE_STATE_INPUT_ENV] + stderr = io.StringIO() + + with contextlib.redirect_stderr(stderr): + code = publication.main( + self.activation_arguments(successor, history, receipt), + operations=operations, + environ=environment, + ) + + self.assertEqual(1, code) + self.assertEqual(0, operations.activate_calls) + self.assertIn("protected-input-not-materialized", stderr.getvalue()) + + def workflow_environment(self, **overrides: str) -> dict[str, str]: + environment = { + "GITHUB_ACTIONS": "true", + "GITHUB_EVENT_NAME": "workflow_dispatch", + "GITHUB_REPOSITORY": "crypta-network/cryptad", + "GITHUB_WORKFLOW_REF": ( + "crypta-network/cryptad/.github/workflows/" + "stable-1.0-maintenance-release.yml@refs/heads/release/301" + ), + "GITHUB_JOB": "protected-publication", + "GITHUB_REF": "refs/heads/release/301", + publication.CATALOG_INPUT_ENV: CATALOG_SECRET, + publication.CORE_UPDATE_INPUT_ENV: CORE_UPDATE_SECRET, + publication.MAINTENANCE_STATE_INPUT_ENV: MAINTENANCE_STATE_SECRET, + publication.GITHUB_TOKEN_ENV: "github-protected-token-material", + } + environment.update(overrides) + return environment + + def preflight_arguments(self, output: Path) -> list[str]: + return [ + "--mode", + "preflight-only", + "--bundle", + str(self.fixture.root), + "--no-protected-inputs", + "--expected-predecessor-pointer-digest", + POINTER, + "--check-latest-predecessor", + "--check-authorization-expiry", + "--check-exact-freeze-bytes", + "--check-tag-release-artifact-catalog-and-updater-conflicts", + "--idempotency", + "exact-match-only", + "--conflict-action", + "fail", + "--no-side-effects", + "--out", + str(output), + ] + + def publication_arguments(self, receipt: Path, core: Path) -> list[str]: + return [ + "--mode", + "publish-or-verify-exact", + "--bundle", + str(self.fixture.root), + "--catalog-input-env", + publication.CATALOG_INPUT_ENV, + "--core-update-input-env", + publication.CORE_UPDATE_INPUT_ENV, + "--expected-predecessor-pointer-digest", + POINTER, + "--expected-source-repository", + publication.SOURCE_REPOSITORY, + "--expected-source-branch", + f"release/{BUILD}", + "--expected-source-commit", + COMMIT, + "--github-token-env", + publication.GITHUB_TOKEN_ENV, + "--recheck-remote-source-ref", + "--revalidate-before-each-mutation", + "--idempotency", + "exact-match-only", + "--conflict-action", + "fail", + "--partial-state-action", + "record-only", + "--forbid-overwrite", + "--forbid-delete-recovery", + "--receipt", + str(receipt), + "--core-update-receipt", + str(core), + ] + + def activation_arguments( + self, successor: Path, history: Path, receipt: Path + ) -> list[str]: + return [ + "--mode", + "activate-latest-baseline", + "--maintenance-state-input-env", + publication.MAINTENANCE_STATE_INPUT_ENV, + "--expected-current-pointer-digest", + POINTER, + "--expected-source-repository", + publication.SOURCE_REPOSITORY, + "--expected-source-branch", + f"release/{BUILD}", + "--expected-source-commit", + COMMIT, + "--github-token-env", + publication.GITHUB_TOKEN_ENV, + "--recheck-remote-source-ref", + "--successor-baseline", + str(successor), + "--history-entry", + str(history), + "--publication-receipt", + str(receipt), + "--authorization", + str(self.fixture.authorization_path), + "--activation-authorization", + str(self.activation_authorization(successor, history, receipt)), + "--check-authorization-expiry", + "--compare-and-swap", + "--forbid-overwrite-on-conflict", + "--verify-after-activation", + "--activation-receipt", + str(self.root / "activation-receipt.json"), + ] + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release-certification/cryptad_certification/tests/test_stable_maintenance_workflows.py b/tools/release-certification/cryptad_certification/tests/test_stable_maintenance_workflows.py new file mode 100644 index 00000000000..2b1ef664fea --- /dev/null +++ b/tools/release-certification/cryptad_certification/tests/test_stable_maintenance_workflows.py @@ -0,0 +1,1463 @@ +"""Offline structural checks for protected Stable maintenance producer workflows.""" + +from __future__ import annotations + +import hashlib +import importlib +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import textwrap +import types +import unittest +import zipfile +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[4] +WINDOWS = ROOT / ".github/workflows/stable-1.0-maintenance-windows-package-producer.yml" +INPUTS = ROOT / ".github/workflows/stable-1.0-maintenance-input-producer.yml" +RELEASE = ROOT / ".github/workflows/stable-1.0-maintenance-release.yml" +EXAMPLE = ( + ROOT + / "tools/release-certification/manifests/stable-1.0-maintenance.example.json" +) +POLICY = ROOT / "tools/release-certification/stable-1.0-maintenance-policy.json" +BACKEND_ACTION = ( + ROOT / ".github/actions/setup-stable-maintenance-publication-backend/action.yml" +) +BACKEND_PRODUCER = ( + ROOT + / ".github/workflows/stable-1.0-maintenance-publication-backend-producer.yml" +) +BACKEND_ROOT = ROOT / "tools/release-certification/publication-backend" +MAINTENANCE_DOC = ROOT / "docs/stable-1.0-maintenance-release-and-hotfix-path.md" + + +def _input_producer_validation_script() -> str: + workflow = INPUTS.read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Extract safely and validate the exact phase layout" + ) + command = " PYTHONPATH=tools/release-certification python3 - <<'PY'\n" + script_start = workflow.index(command, step_start) + len(command) + script_end = workflow.index("\n PY\n", script_start) + return textwrap.dedent(workflow[script_start:script_end]) + + +def _input_producer_fetch_script() -> str: + workflow = INPUTS.read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Fetch exact reviewed bundle without exposing its protected locator" + ) + command = " python3 - <<'PY'\n" + script_start = workflow.index(command, step_start) + len(command) + script_end = workflow.index("\n PY\n", script_start) + return textwrap.dedent(workflow[script_start:script_end]) + + +class StableMaintenanceProducerWorkflowTests(unittest.TestCase): + def test_publication_backend_wheel_is_deterministic_and_loadable_in_isolation( + self, + ) -> None: + with tempfile.TemporaryDirectory() as directory: + first = Path(directory) / "first" + second = Path(directory) / "second" + builder = BACKEND_ROOT / "build_wheel.py" + subprocess.run( + [sys.executable, str(builder), "--out", str(first)], + check=True, + capture_output=True, + text=True, + ) + subprocess.run( + [sys.executable, str(builder), "--out", str(second)], + check=True, + capture_output=True, + text=True, + ) + first_wheels = list(first.glob("*.whl")) + second_wheels = list(second.glob("*.whl")) + self.assertEqual(1, len(first_wheels)) + self.assertEqual(1, len(second_wheels)) + self.assertEqual(first_wheels[0].read_bytes(), second_wheels[0].read_bytes()) + script = textwrap.dedent( + """ + import importlib + import os + import sys + sys.path.insert(0, sys.argv[1]) + module = importlib.import_module('cryptad_stable_maintenance_backend') + os.environ['GITHUB_REPOSITORY'] = 'crypta-network/cryptad' + os.environ['GITHUB_TOKEN'] = 'offline-test-token' + backend = module.factory() + names = ('observe_public_state', 'publish_target', 'verify_publication', + 'observe_latest_pointer', 'activate_latest') + if any(not callable(getattr(backend, name, None)) for name in names): + raise SystemExit(1) + """ + ) + subprocess.run( + [sys.executable, "-I", "-S", "-c", script, str(first_wheels[0])], + check=True, + capture_output=True, + text=True, + ) + + def test_publication_backend_accepts_certified_service_endpoint_paths(self) -> None: + from cryptad_certification.engines.stable_1_0_ga_core import ( + _has_unambiguous_publication_path, + canonical_public_https_uri, + ) + + source = str(BACKEND_ROOT / "src") + sys.path.insert(0, source) + self.addCleanup(lambda: sys.path.remove(source)) + module = importlib.import_module( + "cryptad_stable_maintenance_backend.provider" + ) + + for uri in ( + "https://deployment.example.com", + "https://deployment.example.com/", + "https://deployment.example.com/stable/", + "https://deployment.example.com/stable/observe", + ): + with self.subTest(uri=uri): + self.assertEqual(uri, canonical_public_https_uri(uri)) + self.assertTrue(_has_unambiguous_publication_path(uri)) + self.assertEqual(uri, module._canonical_https_uri(uri)) + + for uri in ( + "https://deployment.example.com//", + "https://deployment.example.com/stable//observe", + "https://deployment.example.com/stable/./observe", + "https://deployment.example.com/stable/../observe", + ): + with self.subTest(uri=uri): + with self.assertRaisesRegex(module.ProviderError, "public-uri-invalid"): + module._canonical_https_uri(uri) + + def test_publication_backend_protocol_uses_schema_bound_public_topology(self) -> None: + source = str(BACKEND_ROOT / "src") + sys.path.insert(0, source) + self.addCleanup(lambda: sys.path.remove(source)) + module = importlib.import_module( + "cryptad_stable_maintenance_backend.provider" + ) + + class Transport: + def __init__(self) -> None: + self.calls: list[tuple[str, str, dict[str, object]]] = [] + + def request(self, method, uri, *, headers=None, body=None): + request = json.loads(body) + operation = request["operation"] + self.calls.append((method, uri, request)) + if operation.startswith("publish-"): + target = ( + "stableCatalog" + if operation == "publish-stable-catalog" + else "coreUpdate" + ) + response = { + "schemaVersion": 1, + "kind": "cryptad-stable-maintenance-deployment-mutation", + "target": target, + "candidateIdentityDigest": "sha256:" + "1" * 64, + "status": "created", + } + elif operation == "observe-publication": + response = { + "schemaVersion": 1, + "kind": "cryptad-stable-maintenance-deployment-observation", + "predecessorPointerDigest": "sha256:" + "2" * 64, + "latestCandidateIdentityDigest": None, + "targets": {"stableCatalog": "absent", "coreUpdate": "absent"}, + } + elif operation == "verify-publication": + response = { + "schemaVersion": 1, + "kind": "cryptad-stable-maintenance-deployment-verification", + "maintenanceReceipt": {}, + "coreUpdateReceipt": {}, + "successorBaseline": {}, + "historyEntry": {}, + } + elif operation == "observe-latest-pointer": + response = { + "schemaVersion": 1, + "kind": "cryptad-stable-maintenance-pointer-observation", + "status": "observed", + "pointerDigest": "sha256:" + "2" * 64, + "activeBaselineDigest": "sha256:" + "3" * 64, + "candidateIdentityDigest": "sha256:" + "1" * 64, + } + else: + response = { + "schemaVersion": 1, + "kind": "cryptad-stable-maintenance-pointer-activation", + "status": "activated", + "activatedPointerDigest": "sha256:" + "4" * 64, + } + encoded = json.dumps(response, indent=2, sort_keys=True).encode() + b"\n" + return 200, {}, encoded + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + def record(name, value): + path = root / name + path.write_bytes( + json.dumps( + value, ensure_ascii=False, indent=2, sort_keys=True + ).encode() + + b"\n" + ) + return path + + (root / "catalog.json").write_bytes(b"catalog") + (root / "catalog.sig").write_bytes(b"signature") + core_info_value = {} + core_info = record("core-info.json", core_info_value) + candidate_input = { + "stableCatalog": { + "fileName": "catalog.json", + "signatureFileName": "catalog.sig", + } + } + candidate_input_path = record("candidate-input.json", candidate_input) + candidate = { + "candidateInputDigest": module._digest( + candidate_input_path.read_bytes() + ) + } + candidate_path = record("candidate.json", candidate) + ga_baseline = {"stableMilestone": "1.0"} + ga_baseline_path = record("ga-baseline.json", ga_baseline) + predecessor_baseline = {"stableMilestone": "1.0"} + predecessor_baseline_path = record( + "predecessor-baseline.json", predecessor_baseline + ) + evidence = {"evidenceRows": []} + evidence_path = record("evidence.json", evidence) + lineage = { + "gaRoot": { + "maintenanceBaselineDigest": module._digest( + ga_baseline_path.read_bytes() + ) + } + } + lineage_path = record("lineage.json", lineage) + provenance = { + "lineageDigest": module._digest(lineage_path.read_bytes()), + "predecessorBaselineDigest": module._digest( + predecessor_baseline_path.read_bytes() + ), + "evidenceDigest": module._digest(evidence_path.read_bytes()), + } + provenance_path = record( + "stable-1.0-maintenance-provenance.json", provenance + ) + core_plan = {"edition": 301} + core_plan_path = record("core-publication-plan.json", core_plan) + plan = { + "githubReleasePageUri": "https://github.com/crypta-network/cryptad/releases/tag/v301", + "deploymentServicePublicUri": "https://deployment.example.com/stable/observe", + "latestPointerPublicUri": "https://state.example.com/stable/latest.json", + "coreInfoDigest": module._digest(core_info.read_bytes()), + "provenanceDigest": module._digest(provenance_path.read_bytes()), + "stableCatalogTarget": { + "mirrorUris": ["https://mirror.example.com/stable/catalog.json"], + "rollbackUri": "https://catalog.example.com/stable/history/300.json", + }, + } + plan_path = record("publication-plan.json", plan) + bundle = types.SimpleNamespace( + plan_path=plan_path, + plan=plan, + candidate_path=candidate_path, + candidate=candidate, + candidate_input_path=candidate_input_path, + candidate_input=candidate_input, + lineage_path=lineage_path, + lineage=lineage, + core_plan_path=core_plan_path, + core_plan=core_plan, + core_info=core_info_value, + legacy=root, + core_info_path=core_info, + ga_baseline_path=ga_baseline_path, + ga_baseline=ga_baseline, + predecessor_baseline_path=predecessor_baseline_path, + predecessor_baseline=predecessor_baseline, + evidence_path=evidence_path, + evidence=evidence, + authorization={"hotfixFollowUpObligationDigest": None}, + follow_up_obligation_path=None, + follow_up_obligation=None, + follow_up_closure_path=None, + follow_up_closure=None, + ) + request = types.SimpleNamespace( + release_id="stable-1-0-maintenance-301", + build_version="301", + release_class="maintenance", + candidate_identity_digest="sha256:" + "1" * 64, + bundle=bundle, + ) + transport = Transport() + backend = module.StableMaintenanceBackend("offline-test-token", transport) + backend._deployment_observation(request) + backend.publish_target( + "stableCatalog", + request, + types.SimpleNamespace( + purpose="stable-catalog", + value="https://capability.example.com/catalog", + ), + ) + backend.publish_target( + "coreUpdate", + request, + types.SimpleNamespace( + purpose="core-update", + value="https://capability.example.com/core", + ), + ) + backend.verify_publication(request) + receipt = { + "releaseId": request.release_id, + "candidateIdentityDigest": request.candidate_identity_digest, + "deploymentServicePublicUri": plan["deploymentServicePublicUri"], + "latestPointerPublicUri": plan["latestPointerPublicUri"], + } + activation = types.SimpleNamespace( + receipt=receipt, + expected_pointer_digest="sha256:" + "2" * 64, + activated_pointer_digest="sha256:" + "4" * 64, + activated_pointer_bytes=b"{}\n", + ) + backend.observe_latest_pointer(activation) + backend.activate_latest( + activation, + types.SimpleNamespace( + purpose="maintenance-state", + value="https://capability.example.com/state", + ), + ) + + operations = [row[2]["operation"] for row in transport.calls] + self.assertEqual( + [ + "observe-publication", + "publish-stable-catalog", + "publish-core-update", + "verify-publication", + "observe-latest-pointer", + "activate-latest-pointer", + ], + operations, + ) + self.assertEqual( + plan["latestPointerPublicUri"], + transport.calls[-1][2]["subject"]["latestPointerPublicUri"], + ) + verification_call = next( + call + for call in transport.calls + if call[2]["operation"] == "verify-publication" + ) + verification_subject = verification_call[2]["subject"] + self.assertEqual( + { + "releaseId", + "buildVersion", + "releaseClass", + "candidateIdentityDigest", + "publicationPlan", + "coreInfo", + "lineage", + "verificationInputs", + }, + set(verification_subject), + ) + bindings = verification_subject["verificationInputs"] + self.assertEqual( + { + "publicationPlan", + "candidate", + "candidateInput", + "lineage", + "corePublicationPlan", + "coreInfo", + "gaBaseline", + "predecessorBaseline", + "evidence", + "provenance", + "hotfixFollowUpObligation", + "hotfixFollowUpClosure", + }, + set(bindings), + ) + expected_paths = { + "publicationPlan": plan_path, + "candidate": candidate_path, + "candidateInput": candidate_input_path, + "lineage": lineage_path, + "corePublicationPlan": core_plan_path, + "coreInfo": core_info, + "gaBaseline": ga_baseline_path, + "predecessorBaseline": predecessor_baseline_path, + "evidence": evidence_path, + "provenance": provenance_path, + } + for name, path in expected_paths.items(): + self.assertEqual( + module._digest(path.read_bytes()), bindings[name]["digest"] + ) + self.assertEqual( + json.loads(path.read_text(encoding="utf-8")), + bindings[name]["record"], + ) + self.assertIsNone(bindings["hotfixFollowUpObligation"]) + self.assertIsNone(bindings["hotfixFollowUpClosure"]) + for _method, _uri, service_request in transport.calls[:3]: + self.assertNotIn( + "verificationInputs", service_request.get("subject", {}) + ) + + provenance_path.write_bytes( + json.dumps( + {**provenance, "evidenceDigest": "sha256:" + "f" * 64}, + ensure_ascii=False, + indent=2, + sort_keys=True, + ).encode() + + b"\n" + ) + with self.assertRaisesRegex( + module.ProviderError, "verification-input-digest-mismatch" + ): + backend._verification_subject(request) + + def test_publication_backend_streams_large_assets_and_rejects_duplicate_names( + self, + ) -> None: + source = str(BACKEND_ROOT / "src") + sys.path.insert(0, source) + self.addCleanup(lambda: sys.path.remove(source)) + module = importlib.import_module( + "cryptad_stable_maintenance_backend.provider" + ) + + class Transport: + def __init__(self) -> None: + self.calls: list[tuple[str, int]] = [] + + def digest(self, uri, expected_size, *, headers=None): + self.calls.append((uri, expected_size)) + return 200, expected_size, "sha256:" + "a" * 64 + + size = 512 * 1024 * 1024 + plan = { + "assets": [ + { + "fileName": "cryptad-301.exe", + "sizeBytes": size, + "digest": "sha256:" + "a" * 64, + "publicUri": "https://downloads.example.com/cryptad-301.exe", + } + ] + } + request = types.SimpleNamespace(bundle=types.SimpleNamespace(plan=plan)) + transport = Transport() + backend = module.StableMaintenanceBackend("offline-test-token", transport) + release = {"assets": [{"id": 7, "name": "cryptad-301.exe", "size": size}]} + self.assertEqual("matching", backend._assets_status(request, release)) + self.assertEqual("matching", backend._artifact_status(request)) + self.assertEqual( + [ + ( + "https://api.github.com/repos/crypta-network/cryptad/releases/assets/7", + size, + ), + ("https://downloads.example.com/cryptad-301.exe", size), + ], + transport.calls, + ) + duplicate = { + "assets": [ + {"id": 7, "name": "cryptad-301.exe", "size": size}, + {"id": 8, "name": "cryptad-301.exe", "size": size}, + ] + } + self.assertEqual("conflict", backend._assets_status(request, duplicate)) + + def test_publication_backend_requires_the_deterministic_release_title(self) -> None: + source = str(BACKEND_ROOT / "src") + sys.path.insert(0, source) + self.addCleanup(lambda: sys.path.remove(source)) + module = importlib.import_module( + "cryptad_stable_maintenance_backend.provider" + ) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + notes = "Stable maintenance notes\n" + (root / "stable-1.0-maintenance-release-notes.md").write_text( + notes, encoding="utf-8" + ) + plan = { + "expectedTag": "v301", + "sourceCommit": "a" * 40, + "githubReleasePageUri": ( + "https://github.com/crypta-network/cryptad/releases/tag/v301" + ), + } + request = types.SimpleNamespace( + bundle=types.SimpleNamespace(plan=plan, legacy=root) + ) + release = { + "tag_name": "v301", + "target_commitish": "a" * 40, + "name": "Cryptad v301", + "html_url": plan["githubReleasePageUri"], + "body": notes, + "draft": False, + "prerelease": False, + "assets": [], + } + backend = module.StableMaintenanceBackend("offline-test-token") + with mock.patch.object( + backend, "_github_json", return_value=(200, release) + ): + self.assertEqual("matching", backend._release(request)[0]) + release["name"] = "Modified release title" + self.assertEqual("conflict", backend._release(request)[0]) + + def test_publication_backend_resumes_exact_partial_asset_uploads(self) -> None: + source = str(BACKEND_ROOT / "src") + sys.path.insert(0, source) + self.addCleanup(lambda: sys.path.remove(source)) + module = importlib.import_module( + "cryptad_stable_maintenance_backend.provider" + ) + + class Transport: + def __init__(self) -> None: + self.downloads: list[str] = [] + self.uploads: list[str] = [] + self.digest_value = module._digest(b"a") + + def digest(self, uri, expected_size, *, headers=None): + self.downloads.append(uri) + return 200, expected_size, self.digest_value + + def request(self, method, uri, *, headers=None, body=None): + self.uploads.append(uri) + return 201, {}, b"{}" + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + names = ("cryptad-301.tar.gz", "cryptad-301.dmg", "cryptad-301.exe") + plan = { + "assets": [ + { + "fileName": name, + "sizeBytes": 1, + "digest": module._digest(b"a"), + } + for name in names + ] + } + for name in names: + (root / name).write_bytes(b"a") + request = types.SimpleNamespace( + bundle=types.SimpleNamespace(plan=plan, legacy=root) + ) + partial_release = { + "id": 301, + "assets": [{"id": 7, "name": names[1], "size": 1}], + } + transport = Transport() + backend = module.StableMaintenanceBackend("offline-test-token", transport) + + self.assertEqual("absent", backend._assets_status(request, {"assets": []})) + self.assertEqual([], transport.downloads) + self.assertEqual("absent", backend._assets_status(request, partial_release)) + self.assertEqual( + [ + "https://api.github.com/repos/crypta-network/cryptad/" + "releases/assets/7" + ], + transport.downloads, + ) + with mock.patch.object( + backend, "_release", return_value=("matching", partial_release) + ): + backend._upload_assets(request) + + self.assertEqual( + [ + "https://uploads.github.com/repos/crypta-network/cryptad/" + "releases/301/assets?name=cryptad-301.tar.gz", + "https://uploads.github.com/repos/crypta-network/cryptad/" + "releases/301/assets?name=cryptad-301.exe", + ], + transport.uploads, + ) + self.assertEqual(2, len(transport.downloads)) + + unexpected = { + "assets": [{"id": 8, "name": "unexpected.bin", "size": 1}] + } + self.assertEqual("conflict", backend._assets_status(request, unexpected)) + transport.digest_value = "sha256:" + "b" * 64 + self.assertEqual("conflict", backend._assets_status(request, partial_release)) + + def test_publication_backend_producer_and_setup_are_canonically_pinned(self) -> None: + producer = BACKEND_PRODUCER.read_text(encoding="utf-8") + action = BACKEND_ACTION.read_text(encoding="utf-8") + for value in ( + "stable-1.0-maintenance-publication-backend", + "cryptad_stable_maintenance_backend:factory", + "actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26", + "artifact-metadata: write", + '"${#first_rows[@]}" -ne 1', + '"${#second_rows[@]}" -ne 1', + ): + self.assertIn(value, producer) + self.assertIn( + "stable-1.0-maintenance-publication-backend-producer.yml", action + ) + self.assertIn("publication-provider wheel members are ambiguous", action) + + def test_windows_producer_builds_signs_rechecks_and_attests_consumer_contract(self) -> None: + text = WINDOWS.read_text(encoding="utf-8") + + required = ( + "workflow_dispatch:", + "runs-on: windows-latest", + "environment: stable-1.0-maintenance-evidence", + "jpackageInstallerWindowsExeCryptad", + "signtool.FullName sign", + "signtool.FullName verify", + "Get-AuthenticodeSignature", + "TimeStamperCertificate", + "refs/heads/$expectedBranch", + "git diff-index --quiet --cached HEAD --", + "git diff-files --quiet --", + '"kind": "stable-1.0-maintenance-external-package-producer"', + '"authenticode": {', + '"timestampStatus": "pass"', + "stable-1.0-maintenance-windows-package-producer.json", + "actions/attest@", + "actions/upload-artifact@v6", + ) + for value in required: + self.assertIn(value, text) + self.assertGreaterEqual(text.count("git diff-files --quiet --"), 2) + self.assertIn("productionSigning\": True", text) + self.assertNotIn("pull_request:", text) + self.assertNotIn("contents: write", text) + + def test_input_producer_uses_protected_exact_digest_intake_and_closed_phases(self) -> None: + text = INPUTS.read_text(encoding="utf-8") + + required = ( + "workflow_dispatch:", + "freeze-candidate", + "prepare-authorization", + "validate-authorization", + "environment: stable-1.0-maintenance-evidence", + "CRYPTAD_STABLE_MAINTENANCE_INPUT_BUNDLE_URL", + "INPUT_BUNDLE_SHA256", + "NoRedirect", + "ProxyHandler({})", + "PinnedHTTPSConnection", + "PinnedHTTPSHandler", + "connect_to_pinned", + "server_hostname=self._tls_hostname", + "is_global", + 'refs/heads/$expected_branch', + "duplicate JSON key", + 'raw.decode("utf-8")', + "ensure_ascii=False, indent=2, sort_keys=True", + "if raw != canonical:", + "noncanonical JSON bytes", + "_maintenance_public_redaction_findings", + "placeholder_findings", + "phase bundle contains an unreferenced input", + "stable-1.0-maintenance.json", + "stable-1.0-maintenance-authorization.json", + "actions/attest@", + "actions/upload-artifact@v6", + ) + for value in required: + self.assertIn(value, text) + self.assertNotIn("pull_request:", text) + self.assertNotIn("contents: write", text) + self.assertNotIn("PROTECTED_BUNDLE_URL: ${{ inputs.", text) + + def test_protected_fetch_pins_validated_address_and_tls_hostname(self) -> None: + namespace: dict[str, object] = {"__name__": "workflow_test"} + exec(_input_producer_fetch_script(), namespace) + socket_module = namespace["socket"] + connection_class = namespace["PinnedHTTPSConnection"] + + class FakeSocket: + def __init__(self, peer: str = "8.8.8.8") -> None: + self.peer = peer + self.connected_to: tuple[str, int] | None = None + self.timeout: object = None + self.closed = False + + def settimeout(self, timeout: object) -> None: + self.timeout = timeout + + def bind(self, source_address: object) -> None: + raise AssertionError(f"unexpected source bind: {source_address}") + + def connect(self, sockaddr: tuple[str, int]) -> None: + self.connected_to = sockaddr + + def getpeername(self) -> tuple[str, int]: + return (self.peer, 443) + + def close(self) -> None: + self.closed = True + + class FakeTlsContext: + def __init__(self) -> None: + self.server_hostname: str | None = None + self.raw_socket: FakeSocket | None = None + + def wrap_socket( + self, raw_socket: FakeSocket, *, server_hostname: str + ) -> object: + self.raw_socket = raw_socket + self.server_hostname = server_hostname + return object() + + endpoint = ( + socket_module.AF_INET, + socket_module.SOCK_STREAM, + socket_module.IPPROTO_TCP, + ("8.8.8.8", 443), + ) + raw_socket = FakeSocket() + tls_context = FakeTlsContext() + with ( + mock.patch.object(socket_module, "socket", return_value=raw_socket), + mock.patch.object( + socket_module, + "getaddrinfo", + side_effect=AssertionError("the pinned connection must not resolve DNS again"), + ), + ): + connection = connection_class( + "bundle.example:443", + pinned_endpoints=(endpoint,), + tls_hostname="bundle.example", + tls_port=443, + context=tls_context, + timeout=60, + ) + connection.connect() + + self.assertEqual(("8.8.8.8", 443), raw_socket.connected_to) + self.assertEqual(60, raw_socket.timeout) + self.assertIs(raw_socket, tls_context.raw_socket) + self.assertEqual("bundle.example", tls_context.server_hostname) + + rebound_socket = FakeSocket(peer="127.0.0.1") + rebound_context = FakeTlsContext() + with mock.patch.object(socket_module, "socket", return_value=rebound_socket): + connection = connection_class( + "bundle.example:443", + pinned_endpoints=(endpoint,), + tls_hostname="bundle.example", + tls_port=443, + context=rebound_context, + timeout=60, + ) + with self.assertRaises(OSError): + connection.connect() + self.assertTrue(rebound_socket.closed) + self.assertIsNone(rebound_context.raw_socket) + + def test_input_producer_rejects_noncanonical_json_before_phase_acceptance(self) -> None: + script = _input_producer_validation_script() + + value = {"label": "caf\N{LATIN SMALL LETTER E WITH ACUTE}", "status": "pass"} + canonical = ( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + noncanonical_documents = ( + json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8") + b"\n", + canonical.removesuffix(b"\n"), + (json.dumps(value, ensure_ascii=True, indent=2, sort_keys=True) + "\n").encode( + "utf-8" + ), + ( + json.dumps( + {"status": "pass", "label": "caf\N{LATIN SMALL LETTER E WITH ACUTE}"}, + ensure_ascii=False, + indent=2, + ) + + "\n" + ).encode("utf-8"), + ) + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + runner_temp = root / "runner-temp" + archive = runner_temp / "stable-maintenance-input" / "bundle.zip" + archive.parent.mkdir(parents=True) + environment = { + **os.environ, + "INPUT_PHASE": "freeze-candidate", + "PYTHONPATH": str(ROOT / "tools/release-certification"), + "RUNNER_TEMP": str(runner_temp), + } + + def write_bundle(encoded: bytes) -> None: + member = zipfile.ZipInfo("input.json") + member.create_system = 3 + member.external_attr = (stat.S_IFREG | 0o644) << 16 + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr(member, encoded) + + for index, encoded in enumerate(noncanonical_documents): + with self.subTest(index=index): + write_bundle(encoded) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, completed.returncode) + self.assertIn("noncanonical JSON bytes", completed.stderr) + + write_bundle(canonical) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, completed.returncode) + self.assertNotIn("noncanonical JSON bytes", completed.stderr) + self.assertIn( + "phase bundle lacks the canonical manifest/input layout", completed.stderr + ) + + def test_input_producer_accepts_only_the_canonical_checked_in_policy_path(self) -> None: + script = _input_producer_validation_script() + canonical_policy = "tools/release-certification/stable-1.0-maintenance-policy.json" + example = json.loads(EXAMPLE.read_text(encoding="utf-8")) + example["release"].update( + { + "id": "stable-1-0-maintenance-301", + "version": "301", + } + ) + example["policies"].update( + { + "artifactBaseUri": "https://artifacts.example.com/stable/maintenance/301/", + "candidateSourceBranch": "release/301", + "candidateSourceCommit": "a" * 40, + "candidateBaseCommit": "b" * 40, + "candidateSourceRef": "commit:" + "a" * 40, + "expectedPredecessorBuild": "300", + "expectedPredecessorProductDigest": "sha256:" + "c" * 64, + "expectedPredecessorReleaseId": "stable-1-0-ga-300", + } + ) + example["policies"]["metadata"] = { + "catalogPrimaryUri": "https://catalog.example.com/stable/catalog.json", + "catalogMirrorUris": "https://mirror.example.com/stable/catalog.json", + "catalogRollbackUri": "https://catalog.example.com/stable/history/7/catalog.json", + "coreUpdatePublicUri": "https://updates.example.com/301/core-info.json", + "deploymentServicePublicUri": "https://deployment.example.com/stable/observe", + "githubReleasePageUri": "https://github.com/crypta-network/cryptad/releases/tag/v301", + "latestPointerPublicUri": "https://state.example.com/stable/latest.json", + "hotfixAffectedPlatforms": "not-applicable", + "hotfixIncidentId": "not-applicable", + "hotfixSeverity": "not-applicable", + } + self.assertEqual(canonical_policy, example["inputs"]["maintenancePolicy"]) + example["inputs"] = { + "maintenanceCandidateAssets": "build/protected-inputs/candidate/assets", + "maintenanceCandidate": "build/protected-inputs/candidate/candidate.json", + "maintenancePolicy": canonical_policy, + } + + with tempfile.TemporaryDirectory() as directory: + checkout = Path(directory) + policy = checkout / canonical_policy + policy.parent.mkdir(parents=True) + shutil.copyfile(POLICY, policy) + runner_temp = checkout / "runner-temp" + archive = runner_temp / "stable-maintenance-input" / "bundle.zip" + archive.parent.mkdir(parents=True) + environment = { + **os.environ, + "INPUT_RELEASE_ID": "stable-1-0-maintenance-301", + "INPUT_BUILD_VERSION": "301", + "INPUT_RELEASE_CLASS": "maintenance", + "INPUT_CANDIDATE_COMMIT": "a" * 40, + "PYTHONPATH": str(ROOT / "tools/release-certification"), + "RUNNER_TEMP": str(runner_temp), + } + + def write_bundle( + manifest: dict[str, object], + phase: str, + *, + signature_payload: bytes = b"detached-signature\n", + extra_asset_directory: bool = False, + extra_file: str | None = None, + ) -> None: + manifest_member = zipfile.ZipInfo("stable-1.0-maintenance.json") + manifest_member.create_system = 3 + manifest_member.external_attr = (stat.S_IFREG | 0o644) << 16 + protected_member = zipfile.ZipInfo("protected-inputs/") + protected_member.create_system = 3 + protected_member.external_attr = (stat.S_IFDIR | 0o755) << 16 + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr( + manifest_member, + ( + json.dumps( + manifest, ensure_ascii=False, indent=2, sort_keys=True + ) + + "\n" + ).encode("utf-8"), + ) + bundle.writestr(protected_member, b"") + if phase == "freeze-candidate": + catalog_bytes = b"catalog.version=1\n" + signature_bytes = b"detached-signature\n" + candidate = { + "stableCatalog": { + "fileName": "catalog.properties", + "sizeBytes": len(catalog_bytes), + "digest": "sha256:" + + hashlib.sha256(catalog_bytes).hexdigest(), + "signatureFileName": "catalog.properties.sig", + "signatureSizeBytes": len(signature_bytes), + "signatureDigest": "sha256:" + + hashlib.sha256(signature_bytes).hexdigest(), + } + } + candidate_member = zipfile.ZipInfo( + "protected-inputs/candidate/candidate.json" + ) + candidate_member.create_system = 3 + candidate_member.external_attr = (stat.S_IFREG | 0o644) << 16 + bundle.writestr( + candidate_member, + ( + json.dumps( + candidate, + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + + "\n" + ).encode("utf-8"), + ) + for name, contents in ( + ("catalog.properties", catalog_bytes), + ("catalog.properties.sig", signature_payload), + ): + asset = zipfile.ZipInfo( + f"protected-inputs/candidate/assets/{name}" + ) + asset.create_system = 3 + asset.external_attr = (stat.S_IFREG | 0o644) << 16 + bundle.writestr(asset, contents) + if extra_asset_directory: + extra = zipfile.ZipInfo( + "protected-inputs/candidate/assets/unexpected/" + ) + extra.create_system = 3 + extra.external_attr = (stat.S_IFDIR | 0o755) << 16 + bundle.writestr(extra, b"") + if extra_file is not None: + extra = zipfile.ZipInfo(extra_file) + extra.create_system = 3 + extra.external_attr = (stat.S_IFREG | 0o644) << 16 + bundle.writestr(extra, b"unreferenced public-safe data\n") + + for phase, mode in ( + ("freeze-candidate", "validate-only"), + ("prepare-authorization", "prepare-authorization"), + ): + with self.subTest(phase=phase): + manifest = json.loads(json.dumps(example)) + manifest["commands"]["stable-maintenance"]["mode"] = mode + if phase == "prepare-authorization": + manifest["inputs"].pop("stableMaintenanceAuthorization", None) + write_bundle(manifest, phase) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=checkout, + env={**environment, "INPUT_PHASE": phase}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(0, completed.returncode, completed.stderr) + + mutated_signature = json.loads(json.dumps(example)) + write_bundle( + mutated_signature, + "freeze-candidate", + signature_payload=b"substituted-signature\n", + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=checkout, + env={**environment, "INPUT_PHASE": "freeze-candidate"}, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, completed.returncode) + self.assertIn("catalog/signature bytes do not match", completed.stderr) + + extra_directory = json.loads(json.dumps(example)) + write_bundle( + extra_directory, + "freeze-candidate", + extra_asset_directory=True, + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=checkout, + env={**environment, "INPUT_PHASE": "freeze-candidate"}, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, completed.returncode) + self.assertIn("exact catalog/signature pair", completed.stderr) + + for phase, mode in ( + ("freeze-candidate", "validate-only"), + ("prepare-authorization", "prepare-authorization"), + ): + for extra_file in ("unreferenced.txt", "sibling/unreferenced.txt"): + with self.subTest(phase=phase, extra_file=extra_file): + unreferenced = json.loads(json.dumps(example)) + unreferenced["commands"]["stable-maintenance"]["mode"] = mode + write_bundle( + unreferenced, + phase, + extra_file=extra_file, + ) + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=checkout, + env={**environment, "INPUT_PHASE": phase}, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, completed.returncode) + self.assertIn("unreferenced input", completed.stderr) + + wrong_policy = json.loads(json.dumps(example)) + wrong_policy["inputs"]["maintenancePolicy"] = ( + "tools/release-certification/stable-1.0-known-limitations.json" + ) + write_bundle(wrong_policy, "freeze-candidate") + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=checkout, + env={**environment, "INPUT_PHASE": "freeze-candidate"}, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, completed.returncode) + self.assertIn("not the exact checked-in policy path", completed.stderr) + + escaped_input = json.loads(json.dumps(example)) + escaped_input["inputs"]["maintenanceCandidate"] = canonical_policy + write_bundle(escaped_input, "freeze-candidate") + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=checkout, + env={**environment, "INPUT_PHASE": "freeze-candidate"}, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, completed.returncode) + self.assertIn( + "maintenanceCandidate does not use the canonical protected-input root", + completed.stderr, + ) + + def test_release_consumer_rebinds_exact_windows_bytes_and_signer(self) -> None: + text = RELEASE.read_text(encoding="utf-8") + + required = ( + "CRYPTAD_STABLE_MAINTENANCE_WINDOWS_SIGNER_WORKFLOW", + "CRYPTAD_WINDOWS_CODE_SIGNING_CERTIFICATE_SHA1", + 'find "$download" -type f | wc -l', + '.authenticode.status == "pass"', + '.authenticode.timestampStatus == "pass"', + ".authenticode.signerCertificateIdentityDigest == $signer_digest", + ".assetDigest == $exe_digest", + ".assetSizeBytes == $exe_size", + 'gh attestation verify "$exe"', + 'gh attestation verify "$producer"', + ) + for value in required: + self.assertIn(value, text) + + def test_linux_package_producer_installs_rpm_tools_before_gradle(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + install_start = workflow.index(" - name: Install Linux RPM packaging tools") + install_end = workflow.index("\n - name:", install_start + 8) + install_step = workflow[install_start:install_end] + build_start = workflow.index(" - name: Build package set once") + + self.assertIn("if: matrix.package_class == 'linux-and-portable'", install_step) + self.assertIn("sudo apt-get install --yes rpm", install_step) + self.assertIn("command -v rpm >/dev/null", install_step) + self.assertIn("command -v rpmbuild >/dev/null", install_step) + self.assertLess(install_start, build_start) + + def test_publication_preflight_accepts_only_safe_initial_states(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Preflight predecessor, authorization, freeze, and public conflicts" + ) + step_end = workflow.index("\n - name:", step_start + 8) + step = workflow[step_start:step_end] + + for state in ("matching-existing", "resumable-prefix"): + with self.subTest(state=state): + self.assertIn(f'.publicState == "{state}"', step) + self.assertNotIn('.publicState == "absent"', step) + self.assertNotIn('.publicState == "partial"', step) + + def test_protected_publication_uses_only_verified_leumor_token_for_github_state( + self, + ) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + job_start = workflow.index(" protected-publication:") + job_end = workflow.index("\n independent-verification:", job_start) + job = workflow[job_start:job_end] + + self.assertIn("contents: read", job) + self.assertNotIn("contents: write", job) + self.assertIn("github-token: ${{ github.token }}", job) + self.assertIn("GH_TOKEN: ${{ github.token }}", job) + + identity_start = job.index( + " - name: Verify protected leumor GitHub publication identity" + ) + identity_end = job.index("\n - name:", identity_start + 8) + identity_step = job[identity_start:identity_end] + self.assertIn( + "LEUMOR_GITHUB_TOKEN: ${{ secrets.LEUMOR_GITHUB_TOKEN }}", + identity_step, + ) + self.assertIn('[[ -z "$LEUMOR_GITHUB_TOKEN" ]]', identity_step) + self.assertIn( + 'GH_TOKEN="$LEUMOR_GITHUB_TOKEN" gh api user --jq .login', + identity_step, + ) + self.assertIn('!= "leumor"', identity_step) + + for step_name in ( + "Preflight predecessor, authorization, freeze, and public conflicts", + "Publish or idempotently verify exact bytes", + ): + with self.subTest(step=step_name): + step_start = job.index(f" - name: {step_name}") + step_end = job.index("\n - name:", step_start + 8) + step = job[step_start:step_end] + self.assertIn( + "GITHUB_TOKEN: ${{ secrets.LEUMOR_GITHUB_TOKEN }}", step + ) + self.assertNotIn("GITHUB_TOKEN: ${{ github.token }}", step) + + mutation_start = job.index( + " - name: Publish or idempotently verify exact bytes" + ) + self.assertLess(identity_start, mutation_start) + + def test_freeze_binds_the_sole_notarization_receipt_only_to_the_dmg(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Generate the canonical exact-byte freeze record" + ) + step_end = workflow.index("\n - name:", step_start + 8) + step = workflow[step_start:step_end] + + required = ( + "select_candidate_dmg_for_freeze(", + 'os.environ["INPUT_RELEASE_CLASS"]', + 'candidate.get("changeScope", {})', + 'mac_assets[0].suffix.lower() != ".dmg"', + 'mac_receipt_value.get("fileName") != mac_assets[0].name', + 'mac_receipt_value.get("subjectDigest") != file_digest(mac_assets[0])', + 'mac_receipt_value.get("subjectSizeBytes") != mac_assets[0].stat().st_size', + "if dmg_package is not None:", + 'mac_assets[0].name != dmg_path.name', + 'file_digest(mac_assets[0]) != file_digest(dmg_path)', + '"pass" if package.get("packageType") == "dmg" else "not-applicable"', + '"notarizationStatus": "pass" if is_dmg else "not-applicable"', + '"notarizationReceiptDigest": mac_receipt_digest if is_dmg else None', + ) + for value in required: + self.assertIn(value, step) + self.assertLess( + step.index("for package in packages:\n expected_notarization"), + step.index("for package in packages:\n path = selected"), + ) + self.assertLess( + step.index("cryptographic signing attestation failed for"), + step.index("select_candidate_dmg_for_freeze("), + ) + + def test_macos_producer_signs_exact_dmg_before_notarization(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Build, Developer ID sign, notarize, staple, and verify macOS package once" + ) + step_end = workflow.index("\n - name:", step_start + 8) + step = workflow[step_start:step_end] + + sign = ( + 'codesign --force --timestamp --sign "$MACOS_DEVELOPER_ID_APPLICATION" ' + '\\\n --keychain "$keychain" "$dmg"' + ) + verify = 'codesign --verify --strict --verbose=2 "$dmg"' + submit = 'xcrun notarytool submit "$dmg"' + staple = 'xcrun stapler staple "$dmg"' + validate = 'xcrun stapler validate "$dmg"' + digest = 'shasum -a 256 "$dmg"' + + sign_index = step.index(sign) + pre_notary_verify = step.index(verify, sign_index) + submit_index = step.index(submit) + staple_index = step.index(staple) + validate_index = step.index(validate) + final_verify = step.index(verify, staple_index) + digest_index = step.index(digest) + + self.assertLess(sign_index, pre_notary_verify) + self.assertLess(pre_notary_verify, submit_index) + self.assertLess(submit_index, staple_index) + self.assertLess(staple_index, validate_index) + self.assertLess(validate_index, final_verify) + self.assertLess(final_verify, digest_index) + + def test_freeze_cryptographically_verifies_exact_catalog_and_declared_key(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + verifier_step = workflow.index( + " - name: Build the trusted Stable catalog verifier" + ) + freeze_step = workflow.index( + " - name: Generate the canonical exact-byte freeze record" + ) + freeze_end = workflow.index("\n - name:", freeze_step + 8) + step = workflow[freeze_step:freeze_end] + + required = ( + "./gradlew :platform-devtools:installDist", + "STABLE_CATALOG_TRUSTED_KEYS_BASE64: ${{ secrets.STABLE_CATALOG_TRUSTED_KEYS_BASE64 }}", + 'base64 --decode > "$trusted_keys"', + 'chmod 600 "$trusted_keys"', + "unset STABLE_CATALOG_TRUSTED_KEYS_BASE64", + "trap 'rm -rf \"$trusted_keys_dir\"' EXIT", + '"catalog",', + '"verify",', + '"--catalog-file",', + '"--catalog-signature-file",', + '"--expected-key-id",', + 'catalog["signingKeyId"]', + '"--trusted-keys-file",', + "stdout=subprocess.DEVNULL", + "stderr=subprocess.DEVNULL", + "stable_catalog_verification_identity(", + "catalog, file_digest(trusted_catalog_keys)", + '"signingReceiptDigest": catalog_verification_digest', + '"stableCatalogVerification": catalog_verification', + ) + for value in required: + self.assertIn(value, workflow if value.startswith("./gradlew") else step) + self.assertLess(verifier_step, freeze_step) + self.assertNotIn("public.key.base64", step) + self.assertNotIn("signature.value.base64", step) + + def test_release_consumer_stages_optional_authenticated_follow_up_closure(self) -> None: + text = RELEASE.read_text(encoding="utf-8") + + self.assertIn( + ".inputs.hotfixFollowUpClosure?", + text, + ) + self.assertIn( + "hotfixFollowUpClosure stable-1.0-hotfix-follow-up-closure.json", + text, + ) + + def test_prepare_recreates_only_the_confined_empty_asset_directory(self) -> None: + text = RELEASE.read_text(encoding="utf-8") + step_start = text.index( + " - name: Materialize and verify prior frozen bytes for authorization preparation" + ) + step_end = text.index("\n - name:", step_start + 8) + step = text[step_start:step_end] + + self.assertIn("if index in {1, 4} and not path.exists():", step) + self.assertIn("resolved.relative_to(root)", step) + self.assertIn("if index == 1 and not path.exists():", step) + self.assertIn("path.mkdir(mode=0o755)", step) + self.assertLess(step.index("resolved.relative_to(root)"), step.index("path.mkdir")) + self.assertLess(step.index("path.mkdir"), step.index('! -d "$asset_root"')) + self.assertLess( + step.index('! -d "$asset_root"'), + step.index('find "$asset_root" -mindepth 1 -print -quit'), + ) + + def test_independent_verifier_uses_shared_nonsecret_backend_identity(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + job_start = workflow.index(" independent-verification:") + job_end = workflow.index("\n activate-latest-baseline:", job_start) + job = workflow[job_start:job_end] + documentation = MAINTENANCE_DOC.read_text(encoding="utf-8") + + self.assertIn("environment: stable-1.0-maintenance-evidence", job) + for variable in ( + "CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SOURCE_COMMIT", + "CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_WHEEL_SHA256", + "CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SIGNER_WORKFLOW", + "CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND", + ): + with self.subTest(variable=variable): + self.assertIn(f"${{{{ vars.{variable} }}}}", job) + self.assertIn(variable, documentation) + self.assertIn("repository-level Actions variables", documentation) + self.assertIn("do not configure them only on a publication environment", documentation) + + def test_publication_backend_site_is_canonical_and_enforced_by_every_adapter_call( + self, + ) -> None: + action = BACKEND_ACTION.read_text(encoding="utf-8") + workflow = RELEASE.read_text(encoding="utf-8") + + self.assertIn('site="$(cd "$site" && pwd -P)"', action) + self.assertIn('echo "site-directory=$site" >> "$GITHUB_OUTPUT"', action) + self.assertIn("python3 -I -S - \"$site\" \"$INPUT_ENTRYPOINT\"", action) + self.assertLess( + action.index("origin = Path(module.__file__).resolve()"), + action.index("getattr(module, factory_name, None)"), + ) + binding = ( + "CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SITE: " + "${{ steps.publication-backend.outputs.site-directory }}" + ) + self.assertEqual(4, workflow.count(binding)) + + def test_independent_verifier_checks_core_receipt_verification_status(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Fetch and verify public tag, assets, catalog, updater, and package identities" + ) + step_end = workflow.index("\n - name:", step_start + 8) + step = workflow[step_start:step_end] + receipt_marker = "build/core-update-publication-receipt.json >/dev/null" + marker_index = step.index(receipt_marker) + check_start = step.rfind("jq -e '", 0, marker_index) + core_receipt_check = step[check_start:marker_index] + + self.assertIn('.verificationStatus == "pass"', core_receipt_check) + self.assertNotIn("finalVerificationStatus", core_receipt_check) + + def test_activation_uses_a_renewable_environment_scoped_authorization(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + job_start = workflow.index(" activate-latest-baseline:") + job = workflow[job_start:] + + self.assertIn('"kind": "stable-1.0-maintenance-activation-authorization"', job) + self.assertIn('"authority": "github-protected-environment"', job) + self.assertIn('"allowedScope": "successor-baseline:activate"', job) + self.assertIn("expires = now + dt.timedelta(minutes=30)", job) + self.assertIn( + '"originalAuthorizationDigest": digest(original_authorization_path)', + job, + ) + self.assertIn( + '"expectedCurrentPointerDigest": expected_pointer_digest', job + ) + self.assertIn('--activation-authorization "$activation_authorization"', job) + self.assertNotIn("authorization expired before baseline activation", job) + + def test_activation_failure_audit_preserves_possible_pointer_mutation(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + marker = "build/stable-maintenance-baseline-activation-started" + activation_start = workflow.index(" - name: Compare-and-swap latest-published pointer") + activation_end = workflow.index("\n - name:", activation_start + 8) + activation_step = workflow[activation_start:activation_end] + audit_start = workflow.index(" - name: Stage baseline activation outcome") + audit_end = workflow.index("\n - name:", audit_start + 8) + audit_step = workflow[audit_start:audit_end] + + self.assertIn(f'activation_marker="{marker}"', activation_step) + self.assertLess( + activation_step.index('(umask 022; : > "$activation_marker")'), + activation_step.index('python3 "$adapter"'), + ) + for value in ( + "activationBoundaryMarkerStatus", + "activationBoundaryEntered", + "sideEffectsMayHaveOccurred", + "observedPointerDigest", + ".observedPointerDigest // empty", + ): + self.assertIn(value, audit_step) + self.assertIn('cp "$marker" "$root/"', audit_step) + self.assertIn('sideEffectsMayHaveOccurred: $activation_boundary_entered', audit_step) + + def test_publication_fallback_audit_records_attempted_targets(self) -> None: + workflow = RELEASE.read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Record truthful partial state after a failed protected operation" + ) + step_end = workflow.index("\n - name:", step_start + 8) + step = workflow[step_start:step_end] + + self.assertIn("attemptedTargets:", step) + self.assertIn( + '["artifactBase", "tag", "githubRelease", "assets", "stableCatalog", "coreUpdate"]', + step, + ) + self.assertIn( + '$failure_category == "side-effecting-publication-failure"', + step, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release-certification/cryptad_certification/workspace.py b/tools/release-certification/cryptad_certification/workspace.py index 281b1c88775..f24e42d6bec 100644 --- a/tools/release-certification/cryptad_certification/workspace.py +++ b/tools/release-certification/cryptad_certification/workspace.py @@ -76,17 +76,24 @@ def prepare_context(workspace_root: Path, manifest: RunManifest, component: str) def _require_confined_directory(path: Path, run_root: Path, description: str) -> None: """Reject symlinks, non-directories, and paths resolving outside the release root.""" + resolved_root = run_root.resolve() current = run_root try: relative = path.relative_to(run_root) except ValueError as exc: - raise WorkspaceError(f"{description} path is outside release workspace: {path}") from exc + try: + relative = path.resolve().relative_to(resolved_root) + except ValueError: + raise WorkspaceError( + f"{description} path is outside release workspace: {path}" + ) from exc + current = resolved_root for part in relative.parts: current /= part if current.is_symlink(): raise WorkspaceError(f"{description} path contains a symlink: {current}") try: - path.resolve().relative_to(run_root) + path.resolve().relative_to(resolved_root) except ValueError as exc: raise WorkspaceError(f"{description} path escapes release workspace: {path}") from exc if path.exists() and not path.is_dir(): @@ -96,16 +103,15 @@ def _require_confined_directory(path: Path, run_root: Path, description: str) -> def reset_confined_directory(path: Path, run_root: Path, description: str) -> Path: """Remove and recreate one confined directory without following replacement symlinks.""" - resolved_root = run_root.resolve() - _require_confined_directory(path.parent, resolved_root, f"{description} parent") + _require_confined_directory(path.parent, run_root, f"{description} parent") if path.is_symlink() or (path.exists() and not path.is_dir()): path.unlink() elif path.exists(): - _require_confined_directory(path, resolved_root, description) + _require_confined_directory(path, run_root, description) shutil.rmtree(path) - _require_confined_directory(path.parent, resolved_root, f"{description} parent") + _require_confined_directory(path.parent, run_root, f"{description} parent") path.mkdir() - _require_confined_directory(path, resolved_root, description) + _require_confined_directory(path, run_root, description) return path.resolve() diff --git a/tools/release-certification/manifests/stable-1.0-maintenance.example.json b/tools/release-certification/manifests/stable-1.0-maintenance.example.json new file mode 100644 index 00000000000..f57158c14f0 --- /dev/null +++ b/tools/release-certification/manifests/stable-1.0-maintenance.example.json @@ -0,0 +1,87 @@ +{ + "schemaVersion": 1, + "release": { + "id": "cryptad-stable-1-0-maintenance-REPLACE_ME", + "version": "REPLACE_WITH_INTEGER_BUILD", + "profile": "stable-review" + }, + "output": { + "root": "build/release-certification", + "reset": true + }, + "requirements": { + "history": true, + "liveNetwork": true, + "multiNodeSoak": true, + "sandboxProviderTests": true, + "stableReadiness": true, + "thirdPartyIntake": true + }, + "inputs": { + "stableGaPromotionSummary": "build/protected-inputs/stable-ga/stable-1.0-ga-promotion-summary.json", + "stableGaValidation": "build/protected-inputs/stable-ga/stable-1.0-ga-validation.json", + "stableGaAuthorizationSummary": "build/protected-inputs/stable-ga/stable-1.0-ga-authorization-summary.json", + "stableGaPublicationPlan": "build/protected-inputs/stable-ga/stable-1.0-ga-publication-plan.json", + "stableGaPublicationReceipt": "build/protected-inputs/stable-ga/stable-1.0-ga-publication-receipt.json", + "stableGaChecksums": "build/protected-inputs/stable-ga/stable-1.0-ga-checksums.txt", + "stableGaProvenance": "build/protected-inputs/stable-ga/stable-1.0-ga-provenance.json", + "stableGaMaintenanceBaseline": "build/protected-inputs/stable-ga/stable-1.0-maintenance-baseline.json", + "predecessorPublicationReceipt": "build/protected-inputs/predecessor/publication-receipt.json", + "predecessorBaseline": "build/protected-inputs/predecessor/maintenance-baseline.json", + "maintenanceCandidate": "build/protected-inputs/candidate/stable-1.0-maintenance-candidate-input.json", + "maintenanceCandidateFreeze": "build/protected-inputs/candidate/stable-1.0-maintenance-candidate-freeze.json", + "maintenanceCandidateAssets": "build/protected-inputs/candidate/assets", + "maintenanceCandidateChecksums": "build/protected-inputs/candidate/checksums.txt", + "maintenanceCandidateProvenance": "build/protected-inputs/candidate/provenance.json", + "maintenanceEvidence": "build/protected-inputs/evidence/stable-1.0-maintenance-evidence.json", + "maintenancePolicy": "tools/release-certification/stable-1.0-maintenance-policy.json", + "stableMaintenanceAuthorization": "build/protected-inputs/authorization/stable-1.0-maintenance-authorization.json" + }, + "policies": { + "artifactBaseUri": "https://REPLACE_ME.invalid/stable-1.0/maintenance/", + "candidateSourceBranch": "release/REPLACE_WITH_INTEGER_BUILD", + "candidateSourceCommit": "REPLACE_WITH_EXACT_LOWERCASE_SOURCE_COMMIT", + "candidateBaseCommit": "REPLACE_WITH_EXACT_LOWERCASE_BASE_COMMIT", + "candidateSourceRef": "commit:REPLACE_WITH_EXACT_LOWERCASE_SOURCE_COMMIT", + "catalogChannel": "stable", + "expectedPredecessorBuild": "REPLACE_WITH_PREDECESSOR_INTEGER_BUILD", + "expectedPredecessorProductDigest": "sha256:REPLACE_WITH_PREDECESSOR_PRODUCT_SHA256", + "expectedPredecessorReleaseId": "REPLACE_WITH_PREDECESSOR_RELEASE_ID", + "metadata": { + "catalogPrimaryUri": "https://REPLACE_ME.invalid/catalog/first-party-catalog.properties", + "catalogMirrorUris": "https://REPLACE_ME_MIRROR.invalid/catalog/first-party-catalog.properties", + "catalogRollbackUri": "https://REPLACE_ME.invalid/catalog/history/REPLACE_WITH_ROLLBACK_REVISION/first-party-catalog.properties", + "coreUpdatePublicUri": "https://REPLACE_ME.invalid/core-update/info/REPLACE_WITH_INTEGER_BUILD/core-info.json", + "deploymentServicePublicUri": "https://REPLACE_ME.invalid/stable-1.0/deployment/observe", + "githubReleasePageUri": "https://github.com/crypta-network/cryptad/releases/tag/vREPLACE_WITH_INTEGER_BUILD", + "latestPointerPublicUri": "https://REPLACE_ME.invalid/stable-1.0/maintenance/latest.json", + "hotfixAffectedPlatforms": "not-applicable", + "hotfixIncidentId": "not-applicable", + "hotfixSeverity": "not-applicable" + }, + "publicationIntent": "prepare-explicit-protected-publication", + "releaseClass": "maintenance" + }, + "execution": { + "allowDirtyWorkspace": false, + "allowTestSigningInProduction": false, + "collectEvidence": false, + "collectLiveNetwork": false, + "emergencySkipBuild": false, + "emergencySkipLiveNetwork": false, + "fixtureEvidence": false, + "generateStableReadiness": false, + "runMultiNodeSoak": false, + "runThirdPartyIntakeSampleFlow": false, + "skipFullBuild": true, + "skipGitMetadata": false, + "skipGradle": true, + "writeHistory": false + }, + "commands": { + "stable-maintenance": { + "args": [], + "mode": "validate-only" + } + } +} diff --git a/tools/release-certification/normalize_stable_archive.py b/tools/release-certification/normalize_stable_archive.py new file mode 100644 index 00000000000..c9c92b2bd44 --- /dev/null +++ b/tools/release-certification/normalize_stable_archive.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Normalize a Gradle portable archive at the Stable release packaging boundary.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from cryptad_certification.engines.stable_1_0_maintenance_core import ( + archive_hygiene_errors, +) +from cryptad_certification.engines.stable_1_0_rc_artifacts import ( + normalize_portable_distribution_archive, +) + + +def main(arguments: list[str]) -> int: + """Normalize each named archive and fail unless the strict maintenance gate accepts it.""" + + if not arguments: + raise SystemExit("usage: normalize_stable_archive.py ARCHIVE [ARCHIVE ...]") + for argument in arguments: + archive = Path(argument) + normalize_portable_distribution_archive(archive) + errors = archive_hygiene_errors(archive) + if errors: + raise SystemExit( + f"normalized archive {archive.name} failed hygiene: " + "; ".join(errors) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/release-certification/protected/stable_maintenance_publication.py b/tools/release-certification/protected/stable_maintenance_publication.py new file mode 100644 index 00000000000..0d31621c4e3 --- /dev/null +++ b/tools/release-certification/protected/stable_maintenance_publication.py @@ -0,0 +1,2976 @@ +#!/usr/bin/env python3 +"""Fail-closed protected publication boundary for Stable 1.0 maintenance releases. + +The certification engine deliberately has no public side effects. This adapter is the only +bridge used by the protected workflow. Provider-specific operations are supplied through the +``ExternalOperations`` protocol so that all policy and exact-byte behavior is testable offline. + +The command-line entry point will not load a provider by default. A protected runner must expose +``CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND`` as ``module.path:factory`` and +``CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SITE`` as the canonical authenticated wheel site +root. The adapter excludes checkout and global package paths while importing the provider and +while invoking every provider method. The factory returns an ``ExternalOperations`` +implementation. Catalog insertion, CoreUpdater insertion, and maintenance-state activation use +distinct opaque ``SecretMaterial`` values. The entry point captures them once and removes their +environment variables before importing or invoking provider code. Public GitHub and artifact-base +targets receive no protected input. Protected values are never rendered, logged, or copied into an +artifact. +""" + +from __future__ import annotations + +import argparse +import contextlib +import dataclasses +import datetime as dt +import hashlib +import importlib +import inspect +import json +import os +from pathlib import Path +import re +import stat +import sys +import sysconfig +import tempfile +from types import ModuleType +from typing import Any, Callable, Iterator, Mapping, Protocol, Sequence +from urllib import parse as urllib_parse +from urllib import request as urllib_request + + +_RELEASE_CERTIFICATION_ROOT = Path(__file__).resolve().parents[1] +if str(_RELEASE_CERTIFICATION_ROOT) not in sys.path: + sys.path.insert(0, str(_RELEASE_CERTIFICATION_ROOT)) + +from cryptad_certification.schema_validation import validate_schema # noqa: E402 + + +TARGETS = ( + "artifactBase", + "tag", + "githubRelease", + "assets", + "stableCatalog", + "coreUpdate", +) +MUTATION_TARGETS = TARGETS +TARGET_STATUSES = frozenset({"absent", "matching", "conflict", "unavailable"}) +AUTHORIZATION_SCOPES = ( + "tag:create-or-verify", + "github-release:create-or-verify", + "artifact-base:publish-or-verify", + "stable-catalog:publish-or-verify", + "core-update:insert-or-verify", + "successor-baseline:activate", + "release-history:append", +) +BACKEND_FACTORY_ENV = "CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND" +BACKEND_SITE_ENV = "CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SITE" +GITHUB_TOKEN_ENV = "GITHUB_TOKEN" +SOURCE_REPOSITORY = "crypta-network/cryptad" +CATALOG_INPUT_ENV = "CRYPTAD_STABLE_CATALOG_PUBLICATION_INPUT" +CORE_UPDATE_INPUT_ENV = "CRYPTAD_CORE_UPDATE_PUBLICATION_INPUT" +MAINTENANCE_STATE_INPUT_ENV = "CRYPTAD_STABLE_MAINTENANCE_STATE_INPUT" +PRIVATE_INPUT_ENV = CORE_UPDATE_INPUT_ENV +_CATALOG_PURPOSE = "stable-catalog" +_CORE_UPDATE_PURPOSE = "core-update" +_MAINTENANCE_STATE_PURPOSE = "maintenance-state" +WORKFLOW_PATH = "/.github/workflows/stable-1.0-maintenance-release.yml@" +MAX_JSON_BYTES = 16 * 1024 * 1024 +FAILURE_AUDIT_FILE = "stable-1.0-maintenance-publication-failure-audit.json" +FOLLOW_UP_CLOSURE_FILE = "stable-1.0-hotfix-follow-up-closure.json" +UNRESOLVED_FOLLOW_UP_STATUSES = frozenset({"open", "overdue"}) +_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +_BUILD_RE = re.compile(r"^[1-9][0-9]*$") +_COMMIT_RE = re.compile(r"^[0-9a-f]{40,64}$") +_SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_FACTORY_RE = re.compile( + r"^[A-Za-z_][A-Za-z0-9_.]*:[A-Za-z_][A-Za-z0-9_]*$" +) +_ABSOLUTE_PATH_RE = re.compile(r"^(?:/|[A-Za-z]:[\\/]|\\\\)") +_PASS_REDACTION = {"status": "pass", "findingCount": 0, "findings": []} +_SENSITIVE_KEYS = frozenset( + { + "authorizationheader", + "cookie", + "identitymaterial", + "inserturi", + "localpath", + "password", + "privateinserturi", + "privatekey", + "rawappdata", + "rawcontent", + "token", + } +) +_ALLOWED_PROTECTED_KEY = "protectedInsertInputName" + + +class AdapterError(RuntimeError): + """One safe, non-secret fail-closed adapter error.""" + + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +@dataclasses.dataclass(frozen=True, repr=False) +class SecretMaterial: + """Opaque protected material that must never enter public output or diagnostics.""" + + purpose: str + value: str + + def __repr__(self) -> str: + return "SecretMaterial()" + + def __str__(self) -> str: + return "" + + +@dataclasses.dataclass(frozen=True, repr=False) +class PublicationProtectedInputs: + """Closed least-privilege inputs for the two side-effecting publication targets.""" + + stable_catalog: SecretMaterial + core_update: SecretMaterial + + def __post_init__(self) -> None: + if self.stable_catalog.purpose != _CATALOG_PURPOSE: + raise AdapterError("catalog-protected-input-purpose-mismatch") + if self.core_update.purpose != _CORE_UPDATE_PURPOSE: + raise AdapterError("core-update-protected-input-purpose-mismatch") + + def for_target(self, target: str) -> SecretMaterial | None: + if target == "stableCatalog": + return self.stable_catalog + if target == "coreUpdate": + return self.core_update + if target in {"tag", "githubRelease", "assets", "artifactBase"}: + return None + raise AdapterError("unknown-publication-target") + + +@dataclasses.dataclass(frozen=True) +class PublicSnapshot: + """One independent public observation used for conflict and idempotency decisions.""" + + predecessor_pointer_digest: str | None + targets: Mapping[str, str] + latest_candidate_identity_digest: str | None = None + + +@dataclasses.dataclass(frozen=True) +class PointerSnapshot: + """Observed latest-baseline pointer state.""" + + status: str + pointer_digest: str | None + active_baseline_digest: str | None + candidate_identity_digest: str | None = None + + +@dataclasses.dataclass(frozen=True) +class VerificationMaterial: + """Exact public observations returned by an independent provider verifier.""" + + maintenance_receipt: Mapping[str, Any] + core_update_receipt: Mapping[str, Any] + successor_baseline: Mapping[str, Any] + history_entry: Mapping[str, Any] + + +@dataclasses.dataclass(frozen=True) +class PublicationBundle: + """Authenticated, exact-byte candidate publication inputs.""" + + root: Path + legacy: Path + plan_path: Path + plan: Mapping[str, Any] + candidate_path: Path + candidate: Mapping[str, Any] + authorization_path: Path + authorization: Mapping[str, Any] + lineage_path: Path + lineage: Mapping[str, Any] + core_plan_path: Path + core_plan: Mapping[str, Any] + core_info_path: Path + core_info: Mapping[str, Any] + candidate_input_path: Path + candidate_input: Mapping[str, Any] + candidate_freeze_path: Path + candidate_freeze: Mapping[str, Any] + ga_baseline_path: Path + ga_baseline: Mapping[str, Any] + predecessor_baseline_path: Path + predecessor_baseline: Mapping[str, Any] + evidence_path: Path + evidence: Mapping[str, Any] + fingerprint: str + follow_up_obligation_path: Path | None = None + follow_up_obligation: Mapping[str, Any] | None = None + follow_up_closure_path: Path | None = None + follow_up_closure: Mapping[str, Any] | None = None + + +@dataclasses.dataclass(frozen=True) +class PublicationRequest: + """Public, non-secret request passed to an injected provider implementation.""" + + bundle: PublicationBundle + + @property + def release_id(self) -> str: + return str(self.bundle.plan["releaseId"]) + + @property + def build_version(self) -> str: + return str(self.bundle.plan["buildVersion"]) + + @property + def release_class(self) -> str: + return str(self.bundle.plan["releaseClass"]) + + @property + def candidate_identity_digest(self) -> str: + return str(self.bundle.plan["candidateIdentityDigest"]) + + +@dataclasses.dataclass(frozen=True) +class ActivationRequest: + """Exact verified records authorized for compare-and-swap activation.""" + + successor_path: Path + successor: Mapping[str, Any] + history_path: Path + history: Mapping[str, Any] + receipt_path: Path + receipt: Mapping[str, Any] + authorization_path: Path + authorization: Mapping[str, Any] + authorization_digest: str + activation_authorization_path: Path + activation_authorization: Mapping[str, Any] + activation_authorization_digest: str + expected_pointer_digest: str + successor_digest: str + history_digest: str + receipt_digest: str + activated_pointer: Mapping[str, Any] + activated_pointer_bytes: bytes + activated_pointer_digest: str + + +class ExternalOperations(Protocol): + """Closed provider boundary; no branch or merge operation exists in this interface.""" + + def observe_public_state(self, request: PublicationRequest) -> PublicSnapshot: + """Fetch current public tag, release, assets, catalog, updater, and predecessor state.""" + + def publish_target( + self, + target: str, + request: PublicationRequest, + protected_input: SecretMaterial | None, + ) -> None: + """Create one exact target or verify an exact concurrent creation.""" + + def verify_publication(self, request: PublicationRequest) -> VerificationMaterial: + """Independently fetch exact public state and return public-safe receipts and lineage.""" + + def observe_latest_pointer(self, request: ActivationRequest) -> PointerSnapshot: + """Fetch the latest published maintenance pointer without mutation.""" + + def activate_latest( + self, request: ActivationRequest, protected_input: SecretMaterial + ) -> None: + """Compare-and-swap the latest pointer to ``request.activated_pointer_bytes``.""" + + +@dataclasses.dataclass(frozen=True) +class PreflightOutcome: + artifact: Mapping[str, Any] + snapshot: PublicSnapshot + passed: bool + + +@dataclasses.dataclass(frozen=True) +class CommandOutcome: + passed: bool + artifacts: Mapping[Path, Mapping[str, Any]] + + +def _utcnow() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc).replace(microsecond=0) + + +def _timestamp(value: dt.datetime) -> str: + return value.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + + +def _parse_timestamp(value: Any) -> dt.datetime | None: + if not isinstance(value, str): + return None + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(dt.timezone.utc) + + +def _canonical_bytes(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n" + + +def _semantic_digest(value: Any) -> str: + encoded = json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _file_digest(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise AdapterError("unsafe-or-missing-exact-file") from exc + return "sha256:" + digest.hexdigest() + + +def _strict_json_bytes(data: bytes, source_code: str) -> Any: + def reject_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise AdapterError("duplicate-json-field") + result[key] = value + return result + + try: + return json.loads(data.decode("utf-8"), object_pairs_hook=reject_duplicates) + except AdapterError: + raise + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise AdapterError(source_code) from exc + + +def _regular_file(path: Path) -> None: + try: + mode = path.stat(follow_symlinks=False).st_mode + except OSError as exc: + raise AdapterError("unsafe-or-missing-exact-file") from exc + if path.is_symlink() or not stat.S_ISREG(mode): + raise AdapterError("unsafe-or-missing-exact-file") + + +def _read_json(path: Path, *, canonical: bool = True) -> dict[str, Any]: + _regular_file(path) + try: + size = path.stat(follow_symlinks=False).st_size + if size <= 0 or size > MAX_JSON_BYTES: + raise AdapterError("json-size-outside-policy") + data = path.read_bytes() + except OSError as exc: + raise AdapterError("unsafe-or-missing-json") from exc + value = _strict_json_bytes(data, "malformed-json") + if not isinstance(value, dict): + raise AdapterError("json-root-is-not-object") + if canonical and data != _canonical_bytes(value): + raise AdapterError("noncanonical-json-bytes") + _scan_public_value(value) + return value + + +def _scan_public_value(value: Any, *, key: str | None = None, secret: str | None = None) -> None: + if key is not None: + normalized = re.sub(r"[^a-z0-9]", "", key.lower()) + if normalized in _SENSITIVE_KEYS and key != _ALLOWED_PROTECTED_KEY: + raise AdapterError("public-artifact-sensitive-field") + if isinstance(value, Mapping): + for child_key, child in value.items(): + if not isinstance(child_key, str): + raise AdapterError("public-artifact-nonstring-field") + _scan_public_value(child, key=child_key, secret=secret) + return + if isinstance(value, list): + for child in value: + _scan_public_value(child, secret=secret) + return + if not isinstance(value, str): + return + if any(ord(character) < 32 and character not in "\n\t" for character in value): + raise AdapterError("public-artifact-control-character") + lowered = value.lower() + if secret and secret in value: + raise AdapterError("protected-input-leak") + if "file://" in lowered or "authorization: bearer " in lowered: + raise AdapterError("public-artifact-private-reference") + if ("usk@" in lowered or "ssk@" in lowered) and "insert" in lowered: + raise AdapterError("public-artifact-private-insert-uri") + if _ABSOLUTE_PATH_RE.match(value) and key not in {"route", "endpoint"}: + raise AdapterError("public-artifact-absolute-path") + + +def _validate_schema(value: Mapping[str, Any], schema: str) -> None: + if validate_schema(dict(value), schema): + raise AdapterError("artifact-schema-validation-failed") + + +def _safe_tree(root: Path) -> None: + try: + root_mode = root.stat(follow_symlinks=False).st_mode + except OSError as exc: + raise AdapterError("unsafe-bundle-root") from exc + if root.is_symlink() or not stat.S_ISDIR(root_mode): + raise AdapterError("unsafe-bundle-root") + for current, directories, files in os.walk(root, topdown=True, followlinks=False): + current_path = Path(current) + for name in [*directories, *files]: + entry = current_path / name + try: + mode = entry.stat(follow_symlinks=False).st_mode + except OSError as exc: + raise AdapterError("unsafe-bundle-entry") from exc + if entry.is_symlink() or not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)): + raise AdapterError("unsafe-bundle-entry") + + +def _legacy_root(root: Path) -> Path: + direct = root + nested = root / "component" / "artifacts" / "legacy" + matches = [ + candidate + for candidate in (direct, nested) + if (candidate / "stable-1.0-maintenance-publication-plan.json").is_file() + ] + if len(matches) != 1: + raise AdapterError("ambiguous-or-missing-publication-bundle") + return matches[0] + + +def _plan_asset_paths(legacy: Path, plan: Mapping[str, Any]) -> dict[str, Path]: + result: dict[str, Path] = {} + rows = plan.get("assets") + if not isinstance(rows, list) or not rows: + raise AdapterError("publication-plan-assets-missing") + for row in rows: + if not isinstance(row, Mapping): + raise AdapterError("publication-plan-asset-malformed") + name = row.get("fileName") + if not isinstance(name, str) or not _SAFE_NAME_RE.fullmatch(name): + raise AdapterError("publication-plan-asset-name-unsafe") + if name in result: + raise AdapterError("publication-plan-asset-duplicate") + path = legacy / name + _regular_file(path) + if path.parent != legacy: + raise AdapterError("publication-plan-asset-escape") + if row.get("digest") != _file_digest(path) or row.get("sizeBytes") != path.stat().st_size: + raise AdapterError("publication-plan-asset-byte-mismatch") + result[name] = path + return result + + +def _load_follow_up_closure( + authenticated_inputs: Path, + lineage: Mapping[str, Any], + predecessor_baseline: Mapping[str, Any], + predecessor_baseline_path: Path, +) -> tuple[Path | None, Mapping[str, Any] | None]: + """Load and authenticate the optional predecessor follow-up closure overlay.""" + + predecessor_lineage = lineage.get("predecessor") + predecessor_lineage = ( + predecessor_lineage if isinstance(predecessor_lineage, Mapping) else {} + ) + expected_digest = predecessor_lineage.get("hotfixFollowUpClosureDigest") + closure_path = authenticated_inputs / FOLLOW_UP_CLOSURE_FILE + if expected_digest is None: + if closure_path.exists(): + raise AdapterError("unexpected-hotfix-follow-up-closure") + return None, None + if not isinstance(expected_digest, str) or not _DIGEST_RE.fullmatch(expected_digest): + raise AdapterError("hotfix-follow-up-closure-digest-malformed") + _regular_file(closure_path) + if _file_digest(closure_path) != expected_digest: + raise AdapterError("hotfix-follow-up-closure-digest-mismatch") + closure = _read_json(closure_path) + _validate_schema(closure, "stable-1.0-hotfix-follow-up-closure-v1.schema.json") + + outstanding = predecessor_baseline.get("hotfixFollowUp") + publication = predecessor_baseline.get("publication") + outstanding = outstanding if isinstance(outstanding, Mapping) else {} + publication = publication if isinstance(publication, Mapping) else {} + predecessor_digest = _file_digest(predecessor_baseline_path) + if ( + outstanding.get("status") not in UNRESOLVED_FOLLOW_UP_STATUSES + or closure.get("status") != "closed" + or closure.get("releaseClass") != "security-hotfix" + or closure.get("releaseId") != outstanding.get("obligatedReleaseId") + or closure.get("buildVersion") != outstanding.get("obligatedBuildVersion") + or closure.get("productDigest") != outstanding.get("obligatedProductDigest") + or closure.get("candidateIdentityDigest") + != outstanding.get("obligatedCandidateIdentityDigest") + or closure.get("predecessorBuild") + != outstanding.get("obligatedPredecessorBuild") + or closure.get("predecessorProductDigest") + != outstanding.get("obligatedPredecessorProductDigest") + or closure.get("authorizationDigest") != outstanding.get("authorizationDigest") + or closure.get("obligationDigest") != outstanding.get("obligationDigest") + or closure.get("successorBaselineDigest") != predecessor_digest + or predecessor_lineage.get("successorBaselineDigest") != predecessor_digest + or closure.get("publicationReceiptDigest") + != predecessor_lineage.get("publicationReceiptDigest") + or closure.get("publicationReceiptIdentityDigest") + != publication.get("receiptIdentityDigest") + or closure.get("latestPublishedPointerDigest") + != lineage.get("latestPublishedPointerDigest") + ): + raise AdapterError("hotfix-follow-up-closure-binding-mismatch") + return closure_path, closure + + +def _load_bundle(root: Path) -> PublicationBundle: + _safe_tree(root) + legacy = _legacy_root(root) + authenticated_inputs = root / "authenticated-inputs" + if authenticated_inputs.is_symlink() or not authenticated_inputs.is_dir(): + raise AdapterError("authenticated-public-inputs-missing") + candidate_input_path = authenticated_inputs / "maintenance-candidate-input.json" + candidate_freeze_path = authenticated_inputs / "maintenance-candidate-freeze.json" + ga_baseline_path = authenticated_inputs / "stable-ga-maintenance-baseline.json" + predecessor_baseline_path = authenticated_inputs / "predecessor-maintenance-baseline.json" + evidence_path = authenticated_inputs / "maintenance-evidence.json" + plan_path = legacy / "stable-1.0-maintenance-publication-plan.json" + candidate_path = legacy / "stable-1.0-maintenance-candidate.json" + authorization_path = legacy / "stable-1.0-maintenance-authorization-summary.json" + lineage_path = legacy / "stable-1.0-maintenance-lineage.json" + core_plan_path = legacy / "core-update-publication-plan.json" + core_info_path = legacy / "core-info.json" + provenance_path = legacy / "stable-1.0-maintenance-provenance.json" + comparison_path = legacy / "stable-1.0-maintenance-comparison.json" + checksums_path = legacy / "stable-1.0-maintenance-checksums.txt" + notes_path = legacy / "stable-1.0-maintenance-release-notes.md" + for path in (provenance_path, comparison_path, checksums_path, notes_path): + _regular_file(path) + + plan = _read_json(plan_path) + candidate = _read_json(candidate_path) + authorization = _read_json(authorization_path) + lineage = _read_json(lineage_path) + core_plan = _read_json(core_plan_path) + core_info = _read_json(core_info_path) + provenance = _read_json(provenance_path) + candidate_input = _read_json(candidate_input_path) + candidate_freeze = _read_json(candidate_freeze_path) + ga_baseline = _read_json(ga_baseline_path) + predecessor_baseline = _read_json(predecessor_baseline_path) + evidence = _read_json(evidence_path) + _validate_schema(plan, "stable-1.0-maintenance-publication-plan-v1.schema.json") + _validate_schema(authorization, "stable-1.0-maintenance-authorization-v1.schema.json") + _validate_schema(lineage, "stable-1.0-maintenance-lineage-v1.schema.json") + _validate_schema(core_plan, "cryptad-core-update-publication-plan-v1.schema.json") + _validate_schema(core_info, "cryptad-core-info-v1.schema.json") + _validate_schema( + candidate_freeze, "stable-1.0-maintenance-candidate-freeze-v1.schema.json" + ) + follow_up_closure_path, follow_up_closure = _load_follow_up_closure( + authenticated_inputs, + lineage, + predecessor_baseline, + predecessor_baseline_path, + ) + + release_id = plan.get("releaseId") + build = plan.get("buildVersion") + release_class = plan.get("releaseClass") + source_commit = plan.get("sourceCommit") + candidate_digest = _semantic_digest(candidate) + source = candidate.get("source") if isinstance(candidate.get("source"), Mapping) else {} + product = candidate.get("product") if isinstance(candidate.get("product"), Mapping) else {} + if ( + plan.get("publicationState") != "publication-authorized" + or plan.get("sideEffectsPerformed") is not False + or not isinstance(release_id, str) + or not _SAFE_NAME_RE.fullmatch(release_id) + or not isinstance(build, str) + or not _BUILD_RE.fullmatch(build) + or release_class not in {"maintenance", "security-hotfix"} + or not isinstance(source_commit, str) + or not _COMMIT_RE.fullmatch(source_commit) + or plan.get("expectedTag") != f"v{build}" + or candidate.get("releaseId") != release_id + or candidate.get("buildVersion") != build + or candidate.get("releaseClass") != release_class + or source.get("commit") != source_commit + or source.get("branch") != plan.get("sourceBranch") + or candidate_digest != plan.get("candidateIdentityDigest") + or product.get("digest") != plan.get("productDigest") + or candidate.get("builtOnce") is not True + or candidate.get("rebuildPerformedAfterFreeze") is not False + or candidate.get("redaction", {}).get("status") != "pass" + ): + raise AdapterError("candidate-publication-identity-mismatch") + + candidate_section_digests = { + "platformApiDigest": "platformApi", + "stableCatalogDigest": "stableCatalog", + "firstPartyAppsDigest": "firstPartyApps", + "contentProfilesDigest": "contentFormatProfiles", + "knownLimitationsDigest": "limitations", + "securityDigest": "security", + "supportDigest": "support", + "legacyBoundariesDigest": "legacyBoundaries", + } + candidate_freeze_digest = _file_digest(candidate_freeze_path) + if ( + candidate_input.get("releaseId") != release_id + or candidate_input.get("buildVersion") != build + or candidate_input.get("releaseClass") != release_class + or candidate_input.get("source", {}).get("commit") != source_commit + or candidate.get("candidateInputDigest") != _file_digest(candidate_input_path) + or candidate.get("candidateFreezeDigest") != candidate_freeze_digest + or candidate_input.get("candidateFreezeDigest") != candidate_freeze_digest + or candidate_freeze.get("releaseId") != release_id + or candidate_freeze.get("buildVersion") != build + or candidate_freeze.get("releaseClass") != release_class + or candidate_freeze.get("source") != candidate_input.get("source") + or candidate_freeze.get("toolchain") != candidate_input.get("toolchain") + or candidate_freeze.get("frozenAt") != candidate.get("frozenAt") + or candidate_input.get("product", {}).get("frozenAt") + != candidate_freeze.get("frozenAt") + or any( + candidate.get(digest_field) + != _semantic_digest(candidate_input.get(section_name)) + for digest_field, section_name in candidate_section_digests.items() + ) + or candidate_input.get("redaction", {}).get("status") != "pass" + ): + raise AdapterError("authenticated-candidate-input-binding-mismatch") + + expected_branch = ( + f"release/{build}" if release_class == "maintenance" else f"hotfix/{build}" + ) + if plan.get("sourceBranch") != expected_branch: + raise AdapterError("candidate-branch-class-mismatch") + + predecessor = lineage.get("predecessor", {}) + expected_predecessor_identity = _semantic_digest( + { + "releaseId": predecessor.get("releaseId"), + "buildVersion": predecessor.get("buildVersion"), + "sourceCommit": predecessor.get("sourceCommit"), + "productDigest": predecessor.get("productDigest"), + "baselineDigest": _file_digest(predecessor_baseline_path), + } + ) + change_scope = candidate_input.get("changeScope", {}) + warnings = candidate_input.get("operationalWarnings", []) + expected_warning_ids = sorted( + row.get("warningId") for row in warnings if isinstance(row, Mapping) + ) + expected_role = ( + "stable-maintenance-release-manager" + if release_class == "maintenance" + else "stable-security-release-manager" + ) + follow_up_path = legacy / "stable-1.0-hotfix-follow-up-obligation.json" + if follow_up_path.exists(): + follow_up_obligation_path: Path | None = follow_up_path + follow_up_obligation: Mapping[str, Any] | None = _read_json(follow_up_path) + _validate_schema( + follow_up_obligation, + "stable-1.0-hotfix-follow-up-obligation-v1.schema.json", + ) + expected_follow_up_digest = _file_digest(follow_up_path) + else: + follow_up_obligation_path = None + follow_up_obligation = None + expected_follow_up_digest = None + if ( + authorization.get("releaseId") != release_id + or authorization.get("buildVersion") != build + or authorization.get("releaseClass") != release_class + or authorization.get("candidateIdentityDigest") != candidate_digest + or authorization.get("gaBaselineDigest") != _file_digest(ga_baseline_path) + or authorization.get("predecessorIdentityDigest") + != expected_predecessor_identity + or authorization.get("predecessorProductDigest") + != predecessor.get("productDigest") + or authorization.get("predecessorPublicationReceiptDigest") + != predecessor.get("publicationReceiptDigest") + or authorization.get("candidateFreezeDigest") + != candidate_freeze_digest + or authorization.get("productDigest") != plan.get("productDigest") + or authorization.get("checksumsDigest") != plan.get("checksumsDigest") + or authorization.get("provenanceDigest") != plan.get("provenanceDigest") + or authorization.get("comparisonDigest") != _file_digest(comparison_path) + or authorization.get("evidenceDigest") != _file_digest(evidence_path) + or authorization.get("coreInfoDigest") != plan.get("coreInfoDigest") + or authorization.get("stableCatalogDigest") != plan.get("stableCatalogDigest") + or authorization.get("knownLimitationsDeltaDigest") + != plan.get("knownLimitationsDeltaDigest") + or authorization.get("releaseNotesDigest") != plan.get("releaseNotesDigest") + or authorization.get("publicationTargetsDigest") + != plan.get("publicationTargetsDigest") + or authorization.get("allowedPublicationScopes") != list(AUTHORIZATION_SCOPES) + or "*" in authorization.get("allowedPublicationScopes", []) + or authorization.get("acceptedWarningIds") != expected_warning_ids + or authorization.get("role") != expected_role + or authorization.get("hotfixIncidentId") != change_scope.get("incidentId") + or authorization.get("hotfixPolicyAuthorizationDigest") + != change_scope.get("hotfixPolicyAuthorizationDigest") + or authorization.get("hotfixShortenedEvidenceIds") + != sorted(change_scope.get("shortenedEvidenceIds", [])) + or authorization.get("hotfixFollowUpObligationDigest") + != expected_follow_up_digest + or authorization.get("decision") not in {"go", "go-with-waivers"} + or authorization.get("status") != "approved" + or plan.get("authorizationDigest") != _file_digest(authorization_path) + ): + raise AdapterError("authorization-binding-mismatch") + + if ( + lineage.get("candidate", {}).get("releaseId") != release_id + or lineage.get("candidate", {}).get("buildVersion") != build + or lineage.get("candidate", {}).get("sourceCommit") != source_commit + or lineage.get("candidate", {}).get("releaseClass") != release_class + or lineage.get("status") != "pass" + or lineage.get("noGap") is not True + or lineage.get("noFork") is not True + or not _DIGEST_RE.fullmatch(str(lineage.get("latestPublishedPointerDigest", ""))) + ): + raise AdapterError("lineage-binding-mismatch") + + descriptor_digest = _file_digest(core_info_path) + package_map_digest = _semantic_digest(core_info.get("packages")) + if ( + core_plan.get("releaseId") != release_id + or core_plan.get("buildVersion") != build + or core_plan.get("releaseClass") != release_class + or core_plan.get("candidateIdentityDigest") != candidate_digest + or core_plan.get("descriptorDigest") != descriptor_digest + or core_plan.get("descriptorSizeBytes") != core_info_path.stat().st_size + or core_plan.get("packageMapDigest") != package_map_digest + or core_plan.get("edition") != int(build) + or core_plan.get("publicFetchUri") != plan.get("coreUpdateTarget", {}).get("publicUri") + or core_plan.get("authorizationDigest") != _file_digest(authorization_path) + or core_plan.get("protectedInsertInputName") != PRIVATE_INPUT_ENV + or core_plan.get("sideEffectsPerformed") is not False + or plan.get("coreInfoDigest") != descriptor_digest + or plan.get("coreUpdateTarget", {}).get("descriptorDigest") != descriptor_digest + ): + raise AdapterError("core-update-plan-binding-mismatch") + + if ( + plan.get("checksumsDigest") != _file_digest(checksums_path) + or plan.get("provenanceDigest") != _file_digest(provenance_path) + or plan.get("releaseNotesDigest") != _file_digest(notes_path) + or provenance.get("candidateIdentityDigest") != candidate_digest + or provenance.get("candidateFreezeDigest") != candidate_freeze_digest + or provenance.get("candidateProductDigest") != plan.get("productDigest") + or provenance.get("lineageDigest") != _file_digest(lineage_path) + or provenance.get("redaction", {}).get("status") != "pass" + ): + raise AdapterError("frozen-provenance-binding-mismatch") + + if ( + _file_digest(ga_baseline_path) + != lineage.get("gaRoot", {}).get("maintenanceBaselineDigest") + or _file_digest(predecessor_baseline_path) + != provenance.get("predecessorBaselineDigest") + or evidence.get("releaseId") != release_id + or evidence.get("buildVersion") != build + or evidence.get("releaseClass") != release_class + or evidence.get("candidateProductDigest") != plan.get("productDigest") + or evidence.get("candidateFreezeDigest") != candidate_freeze_digest + or provenance.get("evidenceDigest") != _file_digest(evidence_path) + or ga_baseline.get("redaction", {}).get("status") != "pass" + or predecessor_baseline.get("redaction", {}).get("status") != "pass" + or evidence.get("redaction", {}).get("status") != "pass" + ): + raise AdapterError("authenticated-baseline-or-evidence-binding-mismatch") + + asset_paths = _plan_asset_paths(legacy, plan) + planned_roles = { + str(row.get("role")): str(row.get("fileName")) for row in plan.get("assets", []) + } + if planned_roles.get("product") != product.get("fileName"): + raise AdapterError("product-asset-binding-mismatch") + catalog = candidate_input.get("stableCatalog", {}) + catalog_name = catalog.get("fileName") + signature_name = catalog.get("signatureFileName") + catalog_path = asset_paths.get(catalog_name) if isinstance(catalog_name, str) else None + signature_path = ( + asset_paths.get(signature_name) if isinstance(signature_name, str) else None + ) + if ( + catalog_path is None + or signature_path is None + or planned_roles.get("stable-catalog") != catalog_name + or planned_roles.get("stable-catalog-signature") != signature_name + or catalog.get("digest") != _file_digest(catalog_path) + or catalog.get("sizeBytes") != catalog_path.stat().st_size + or catalog.get("signatureDigest") != _file_digest(signature_path) + or catalog.get("signatureSizeBytes") != signature_path.stat().st_size + ): + raise AdapterError("stable-catalog-asset-binding-mismatch") + packages = candidate.get("packages") + if not isinstance(packages, list) or not packages: + raise AdapterError("candidate-package-set-missing") + descriptor_packages = core_info.get("packages") + if not isinstance(descriptor_packages, Mapping): + raise AdapterError("core-info-package-map-missing") + expected_keys: set[str] = set() + for package in packages: + if not isinstance(package, Mapping): + raise AdapterError("candidate-package-malformed") + key = package.get("packageKey") + name = package.get("fileName") + if not isinstance(key, str) or not isinstance(name, str) or name not in asset_paths: + raise AdapterError("candidate-package-asset-missing") + expected_keys.add(key) + descriptor_row = descriptor_packages.get(key) + if not isinstance(descriptor_row, Mapping): + raise AdapterError("core-info-package-omitted") + reference = package.get("publicChk") or package.get("storeUrl") + observed_reference = descriptor_row.get("chk") or descriptor_row.get("store_url") + if ( + package.get("digest") != _file_digest(asset_paths[name]) + or package.get("sizeBytes") != asset_paths[name].stat().st_size + or descriptor_row.get("size") != package.get("sizeBytes") + or observed_reference != reference + ): + raise AdapterError("core-info-package-substitution") + if set(descriptor_packages) != expected_keys: + raise AdapterError("core-info-package-set-mismatch") + + fingerprint = _semantic_digest( + { + "plan": _file_digest(plan_path), + "candidate": _file_digest(candidate_path), + "authorization": _file_digest(authorization_path), + "lineage": _file_digest(lineage_path), + "comparison": _file_digest(comparison_path), + "corePlan": _file_digest(core_plan_path), + "coreInfo": descriptor_digest, + "candidateInput": _file_digest(candidate_input_path), + "candidateFreeze": candidate_freeze_digest, + "gaBaseline": _file_digest(ga_baseline_path), + "predecessorBaseline": _file_digest(predecessor_baseline_path), + "evidence": _file_digest(evidence_path), + "hotfixFollowUpClosure": ( + _file_digest(follow_up_closure_path) + if follow_up_closure_path is not None + else None + ), + "hotfixFollowUpObligation": expected_follow_up_digest, + "assets": { + name: _file_digest(path) for name, path in sorted(asset_paths.items()) + }, + } + ) + return PublicationBundle( + root=root, + legacy=legacy, + plan_path=plan_path, + plan=plan, + candidate_path=candidate_path, + candidate=candidate, + authorization_path=authorization_path, + authorization=authorization, + lineage_path=lineage_path, + lineage=lineage, + core_plan_path=core_plan_path, + core_plan=core_plan, + core_info_path=core_info_path, + core_info=core_info, + candidate_input_path=candidate_input_path, + candidate_input=candidate_input, + candidate_freeze_path=candidate_freeze_path, + candidate_freeze=candidate_freeze, + ga_baseline_path=ga_baseline_path, + ga_baseline=ga_baseline, + predecessor_baseline_path=predecessor_baseline_path, + predecessor_baseline=predecessor_baseline, + evidence_path=evidence_path, + evidence=evidence, + fingerprint=fingerprint, + follow_up_obligation_path=follow_up_obligation_path, + follow_up_obligation=follow_up_obligation, + follow_up_closure_path=follow_up_closure_path, + follow_up_closure=follow_up_closure, + ) + + +def _normalize_snapshot(snapshot: PublicSnapshot) -> PublicSnapshot: + if snapshot.predecessor_pointer_digest is not None and not _DIGEST_RE.fullmatch( + snapshot.predecessor_pointer_digest + ): + raise AdapterError("public-predecessor-observation-malformed") + if snapshot.latest_candidate_identity_digest is not None and not _DIGEST_RE.fullmatch( + snapshot.latest_candidate_identity_digest + ): + raise AdapterError("public-latest-candidate-observation-malformed") + if set(snapshot.targets) != set(TARGETS): + raise AdapterError("public-target-observation-incomplete") + statuses = dict(snapshot.targets) + if any(value not in TARGET_STATUSES for value in statuses.values()): + raise AdapterError("public-target-observation-malformed") + return PublicSnapshot( + snapshot.predecessor_pointer_digest, + {target: statuses[target] for target in TARGETS}, + snapshot.latest_candidate_identity_digest, + ) + + +def _observe(operations: ExternalOperations, request: PublicationRequest) -> PublicSnapshot: + try: + return _normalize_snapshot(operations.observe_public_state(request)) + except AdapterError: + raise + except Exception as exc: # noqa: BLE001 - provider messages may contain protected material + raise AdapterError("public-state-observation-unavailable") from exc + + +def _unavailable_snapshot() -> PublicSnapshot: + return PublicSnapshot(None, {target: "unavailable" for target in TARGETS}) + + +def _public_state_class(snapshot: PublicSnapshot) -> str: + statuses = list(snapshot.targets.values()) + if "unavailable" in statuses: + return "unavailable" + if "conflict" in statuses: + return "conflict" + if all(status == "absent" for status in statuses): + return "absent" + if all(status == "matching" for status in statuses): + return "matching-existing" + seen_absent = False + matching_prefix = False + for target in TARGETS: + status = snapshot.targets[target] + if status == "absent": + seen_absent = True + elif status == "matching" and not seen_absent: + matching_prefix = True + else: + break + else: + if matching_prefix and seen_absent: + return "resumable-prefix" + return "partial" + + +def _completed_target_prefix(snapshot: PublicSnapshot) -> list[str]: + """Return the exact ordered matching prefix that is safe to resume after.""" + + completed: list[str] = [] + for target in TARGETS: + if snapshot.targets[target] != "matching": + break + completed.append(target) + return completed + + +def _authorization_current(bundle: PublicationBundle, now: dt.datetime) -> bool: + authorized = _parse_timestamp(bundle.authorization.get("authorizedAt")) + expires = _parse_timestamp(bundle.authorization.get("expiresAt")) + return bool( + authorized is not None + and expires is not None + and authorized <= now.astimezone(dt.timezone.utc) < expires + ) + + +def preflight( + root: Path, + expected_predecessor_pointer_digest: str, + operations: ExternalOperations, + *, + now: dt.datetime | None = None, +) -> PreflightOutcome: + """Validate exact frozen bytes and observe all public targets without mutation.""" + + if not _DIGEST_RE.fullmatch(expected_predecessor_pointer_digest): + raise AdapterError("expected-predecessor-pointer-digest-malformed") + bundle = _load_bundle(root) + request = PublicationRequest(bundle) + try: + snapshot = _observe(operations, request) + except AdapterError: + snapshot = _unavailable_snapshot() + public_state = _public_state_class(snapshot) + artifact_base_pre_staged = snapshot.targets.get("artifactBase") == "matching" + expected_from_lineage = bundle.lineage.get("latestPublishedPointerDigest") + pointer_matches_authorization = expected_from_lineage == expected_predecessor_pointer_digest + pointer_unchanged = snapshot.predecessor_pointer_digest == expected_predecessor_pointer_digest + if ( + public_state == "matching-existing" + and snapshot.latest_candidate_identity_digest + == bundle.plan.get("candidateIdentityDigest") + ): + pointer_unchanged = True + current = _authorization_current(bundle, now or _utcnow()) + passed = bool( + pointer_matches_authorization + and pointer_unchanged + and current + and artifact_base_pre_staged + and public_state in {"matching-existing", "resumable-prefix"} + ) + failure: str | None = None + if not pointer_matches_authorization or not pointer_unchanged: + failure = "predecessor-conflict" + elif not current: + failure = "authorization-expired" + elif public_state == "unavailable": + failure = "verification-unavailable" + elif public_state in {"conflict", "partial"}: + failure = "publication-conflict" + elif not artifact_base_pre_staged: + failure = "artifact-base-not-prestaged" + artifact = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-publication-preflight", + "generatedAt": _timestamp(now or _utcnow()), + "releaseId": bundle.plan.get("releaseId"), + "buildVersion": bundle.plan.get("buildVersion"), + "releaseClass": bundle.plan.get("releaseClass"), + "candidateIdentityDigest": bundle.plan.get("candidateIdentityDigest"), + "status": "pass" if passed else "fail", + "sideEffectsPerformed": False, + "latestPredecessorUnchanged": pointer_matches_authorization and pointer_unchanged, + "authorizationCurrent": current, + "freezeBytesExact": True, + "publicState": public_state, + "publicStateObservation": dict(snapshot.targets), + "failureCategory": failure, + "redaction": dict(_PASS_REDACTION), + } + _scan_public_value(artifact) + return PreflightOutcome(artifact, snapshot, passed) + + +def _revalidate_bundle(request: PublicationRequest) -> PublicationRequest: + refreshed = _load_bundle(request.bundle.root) + if refreshed.fingerprint != request.bundle.fingerprint: + raise AdapterError("candidate-byte-drift-before-mutation") + return PublicationRequest(refreshed) + + +def _expected_mutation_snapshot( + snapshot: PublicSnapshot, + completed: Sequence[str], + remaining: Sequence[str], + expected_pointer: str, +) -> bool: + return bool( + snapshot.predecessor_pointer_digest == expected_pointer + and all(snapshot.targets[target] == "matching" for target in completed) + and all(snapshot.targets[target] == "absent" for target in remaining) + ) + + +def _receipt_identity(receipt: Mapping[str, Any]) -> str: + """Match the engine's current non-circular publication-receipt identity exactly.""" + + fields = { + key: receipt.get(key) + for key in ( + "schemaVersion", + "kind", + "releaseId", + "buildVersion", + "releaseClass", + "sourceCommit", + "githubReleasePageUri", + "deploymentServicePublicUri", + "latestPointerPublicUri", + "publicationState", + "candidateIdentityDigest", + "productDigest", + "checksumsDigest", + "provenanceDigest", + "authorizationDigest", + "publicationPlanDigest", + "releaseNotesDigest", + "coreInfoDigest", + "coreUpdateReceiptDigest", + "tag", + "githubRelease", + "assets", + "stableCatalog", + "coreUpdate", + "workflow", + "publicObservations", + "finalVerificationStatus", + "failureCategory", + "redaction", + ) + } + return _semantic_digest(fields) + + +def _successor_identity(successor: Mapping[str, Any]) -> str: + """Match the engine's non-circular successor-baseline identity exactly.""" + + return _semantic_digest( + { + key: successor.get(key) + for key in ( + "schemaVersion", + "kind", + "generatedAt", + "stableMilestone", + "status", + "gaRoot", + "previousBaselineDigest", + "chainDepth", + "previousLineageDigest", + "publication", + "release", + "platformApi", + "stableCatalog", + "firstPartyApps", + "contentFormatProfiles", + "limitations", + "security", + "support", + "legacyBoundaries", + "evidenceWindowPolicy", + "hotfixFollowUp", + "releaseHistoryDigest", + "redaction", + ) + } + ) + + +def _expected_successor_follow_up(bundle: PublicationBundle) -> dict[str, Any]: + """Return the authenticated unresolved obligation carried into the successor.""" + + predecessor_value = bundle.predecessor_baseline.get("hotfixFollowUp") + inherited = ( + predecessor_value + if isinstance(predecessor_value, Mapping) + and predecessor_value.get("status") in UNRESOLVED_FOLLOW_UP_STATUSES + else None + ) + if inherited is not None and bundle.follow_up_closure is not None: + inherited = None + follow_up_path = bundle.legacy / "stable-1.0-hotfix-follow-up-obligation.json" + current: Mapping[str, Any] | None = None + current_digest: str | None = None + if follow_up_path.exists(): + current = _read_json(follow_up_path) + current_digest = _file_digest(follow_up_path) + effective = inherited or current + effective_digest = ( + inherited.get("obligationDigest") if inherited is not None else current_digest + ) + result = { + "status": effective.get("status", "open") if effective else "not-required", + "generatedAt": ( + effective.get("generatedAt") + if effective + else bundle.candidate_input.get("generatedAt") + ), + "obligationDigest": effective_digest, + "deadline": effective.get("deadline") if effective else None, + "closureEvidenceDigest": ( + effective.get("closureEvidenceDigest") if effective else None + ), + "blocksRoutineMaintenance": bool(effective), + } + if effective: + result.update( + { + "obligatedReleaseId": effective.get( + "obligatedReleaseId", effective.get("releaseId") + ), + "obligatedBuildVersion": effective.get( + "obligatedBuildVersion", effective.get("buildVersion") + ), + "obligatedProductDigest": effective.get( + "obligatedProductDigest", effective.get("productDigest") + ), + "obligatedCandidateIdentityDigest": effective.get( + "obligatedCandidateIdentityDigest", + effective.get("candidateIdentityDigest"), + ), + "obligatedCandidateFreezeDigest": effective.get( + "obligatedCandidateFreezeDigest", + effective.get("candidateFreezeDigest"), + ), + "obligatedCandidateFrozenAt": effective.get( + "obligatedCandidateFrozenAt", effective.get("candidateFrozenAt") + ), + "obligatedPredecessorBuild": effective.get( + "obligatedPredecessorBuild", effective.get("predecessorBuild") + ), + "obligatedPredecessorProductDigest": effective.get( + "obligatedPredecessorProductDigest", + effective.get("predecessorProductDigest"), + ), + "authorizationDigest": ( + effective.get("authorizationDigest") + if inherited is not None + else _file_digest(bundle.authorization_path) + ), + } + ) + return result + + +def _validate_history( + history: Mapping[str, Any], bundle: PublicationBundle, receipt_identity: str +) -> None: + lineage = bundle.lineage + provenance = _read_json(bundle.legacy / "stable-1.0-maintenance-provenance.json") + expected = { + "generatedAt": bundle.candidate_input.get("generatedAt"), + "releaseId": bundle.plan.get("releaseId"), + "buildVersion": bundle.plan.get("buildVersion"), + "tag": bundle.plan.get("expectedTag"), + "sourceCommit": bundle.plan.get("sourceCommit"), + "releaseClass": bundle.plan.get("releaseClass"), + "chainDepth": lineage.get("chainDepth"), + "gaBaselineDigest": lineage.get("gaRoot", {}).get("maintenanceBaselineDigest"), + "previousBaselineDigest": provenance.get("predecessorBaselineDigest"), + "previousLineageDigest": lineage.get("previousLineageDigest"), + "candidateIdentityDigest": bundle.plan.get("candidateIdentityDigest"), + "productDigest": bundle.plan.get("productDigest"), + "publicationReceiptIdentityDigest": receipt_identity, + "coreInfoDigest": bundle.plan.get("coreInfoDigest"), + "evidenceDigest": provenance.get("evidenceDigest"), + "status": "published-and-verified", + } + if ( + set(history) + != { + "schemaVersion", + "kind", + "generatedAt", + "stableMilestone", + "releaseId", + "buildVersion", + "tag", + "sourceCommit", + "releaseClass", + "chainDepth", + "gaBaselineDigest", + "previousBaselineDigest", + "previousLineageDigest", + "candidateIdentityDigest", + "productDigest", + "publicationReceiptIdentityDigest", + "coreInfoDigest", + "evidenceDigest", + "status", + "redaction", + } + or history.get("schemaVersion") != 1 + or history.get("kind") != "stable-1.0-maintenance-history-entry" + or history.get("stableMilestone") != "1.0" + or any(history.get(key) != value for key, value in expected.items()) + or history.get("redaction") != _PASS_REDACTION + ): + raise AdapterError("release-history-binding-mismatch") + + +def _validate_successor( + successor: Mapping[str, Any], + history: Mapping[str, Any], + history_digest: str, + bundle: PublicationBundle, + receipt_identity: str, +) -> None: + _validate_schema( + successor, "stable-1.0-maintenance-successor-baseline-v2.schema.json" + ) + lineage = bundle.lineage + provenance = _read_json(bundle.legacy / "stable-1.0-maintenance-provenance.json") + release = successor.get("release") if isinstance(successor.get("release"), Mapping) else {} + publication = ( + successor.get("publication") + if isinstance(successor.get("publication"), Mapping) + else {} + ) + candidate_input = bundle.candidate_input + platform_api = candidate_input.get("platformApi", {}) + stable_catalog = candidate_input.get("stableCatalog", {}) + expected_platform = { + key: platform_api.get(key) + for key in ( + "baselineName", + "baselineDigest", + "baselineContractVersion", + "currentContractVersion", + "currentContractDigest", + "stableSurfaceDigest", + "compatibilityWindowPolicyDigest", + "deprecationHistoryDigest", + "deprecationHistory", + ) + } + expected_catalog = { + key: stable_catalog.get(key) + for key in ( + "catalogId", + "channel", + "revision", + "edition", + "digest", + "signatureFileName", + "signatureSizeBytes", + "signatureDigest", + "signingKeyId", + ) + } + app_fields = ( + "appId", + "version", + "channel", + "supportLevel", + "bundleDigest", + "reviewReceiptDigest", + "appSigningKeyId", + "reviewerKeyId", + "manifestDigest", + "permissionSetDigest", + "appDataSchemaVersion", + "supportMetadataDigest", + ) + profile_fields = ( + "profileId", + "version", + "status", + "descriptorDigest", + "canonicalizationRulesDigest", + "maximumSizePolicyDigest", + "signaturePayloadRulesDigest", + ) + expected_apps = [ + {key: row.get(key) for key in app_fields} + for row in candidate_input.get("firstPartyApps", []) + if isinstance(row, Mapping) + ] + expected_profiles = [ + {key: row.get(key) for key in profile_fields} + for row in candidate_input.get("contentFormatProfiles", []) + if isinstance(row, Mapping) + ] + ga_baseline = bundle.ga_baseline + predecessor = bundle.predecessor_baseline + limitations = candidate_input.get("limitations", {}) + current_limitation_ids = sorted( + { + item + for field in ("addedIds", "unchangedIds") + for item in ( + limitations.get(field) + if isinstance(limitations.get(field), list) + else [] + ) + if isinstance(item, str) + } + ) + security = candidate_input.get("security", {}) + support = candidate_input.get("support", {}) + expected_sections = { + "limitations": { + "currentDigest": _semantic_digest( + {"limitationIds": current_limitation_ids} + ), + "currentIds": current_limitation_ids, + "gaBaselineDigest": _semantic_digest(ga_baseline.get("limitations")), + "predecessorDigest": _semantic_digest(predecessor.get("limitations")), + }, + "security": { + "currentDigest": _semantic_digest(security), + "gaBaselineDigest": _semantic_digest(ga_baseline.get("securityBaseline")), + "predecessorDigest": _semantic_digest( + predecessor.get("security", predecessor.get("securityBaseline")) + ), + }, + "support": { + "currentDigest": _semantic_digest(support), + "gaBaselineDigest": _semantic_digest(ga_baseline.get("supportBaseline")), + "predecessorDigest": _semantic_digest( + predecessor.get("support", predecessor.get("supportBaseline")) + ), + }, + } + evidence_rows = bundle.evidence.get("evidenceRows", []) + evidence_ids = sorted( + row.get("evidenceId") for row in evidence_rows if isinstance(row, Mapping) + ) + expected_evidence_policy = { + "windowClass": bundle.evidence.get("windowClass"), + "policyDigest": provenance.get("policyDigest"), + "requiredEvidenceDigest": _semantic_digest(evidence_ids), + "completedEvidenceDigest": _file_digest(bundle.evidence_path), + } + expected_follow_up = _expected_successor_follow_up(bundle) + ga_root = lineage.get("gaRoot", {}) + expected_ga_root = { + key: ga_root.get(key) + for key in ( + "releaseId", + "buildVersion", + "tag", + "sourceCommit", + "productDigest", + "maintenanceBaselineDigest", + "publicationReceiptDigest", + ) + } + if ( + successor.get("generatedAt") != candidate_input.get("generatedAt") + or successor.get("gaRoot") != expected_ga_root + or successor.get("previousBaselineDigest") + != provenance.get("predecessorBaselineDigest") + or successor.get("chainDepth") != lineage.get("chainDepth") + or successor.get("previousLineageDigest") != lineage.get("previousLineageDigest") + or successor.get("releaseHistoryDigest") != history_digest + or release.get("releaseId") != bundle.plan.get("releaseId") + or release.get("buildVersion") != bundle.plan.get("buildVersion") + or release.get("tag") != bundle.plan.get("expectedTag") + or release.get("sourceCommit") != bundle.plan.get("sourceCommit") + or release.get("releaseClass") != bundle.plan.get("releaseClass") + or release.get("productDigest") != bundle.plan.get("productDigest") + or release.get("checksumsDigest") != bundle.plan.get("checksumsDigest") + or release.get("provenanceDigest") != bundle.plan.get("provenanceDigest") + or release.get("coreInfoDigest") != bundle.plan.get("coreInfoDigest") + or release.get("publicationReceiptIdentityDigest") != receipt_identity + or publication.get("receiptIdentityDigest") != receipt_identity + or publication.get("publicationState") != "publication-complete" + or publication.get("verificationStatus") != "pass" + or successor.get("platformApi") != expected_platform + or successor.get("stableCatalog") != expected_catalog + or successor.get("firstPartyApps") != expected_apps + or successor.get("contentFormatProfiles") != expected_profiles + or successor.get("limitations") != expected_sections["limitations"] + or successor.get("security") != expected_sections["security"] + or successor.get("support") != expected_sections["support"] + or successor.get("legacyBoundaries") != candidate_input.get("legacyBoundaries") + or successor.get("evidenceWindowPolicy") != expected_evidence_policy + or successor.get("hotfixFollowUp") != expected_follow_up + or successor.get("gaRoot", {}).get("maintenanceBaselineDigest") + != lineage.get("gaRoot", {}).get("maintenanceBaselineDigest") + or successor.get("gaRoot", {}).get("publicationReceiptDigest") + != lineage.get("gaRoot", {}).get("publicationReceiptDigest") + or successor.get("redaction") != _PASS_REDACTION + ): + raise AdapterError("successor-baseline-binding-mismatch") + + predecessor_history: list[Mapping[str, Any]] + if predecessor.get("schemaVersion") == 1: + ga = bundle.lineage.get("gaRoot", {}) + predecessor_history = [ + { + "chainDepth": 0, + "releaseId": ga.get("releaseId"), + "buildVersion": ga.get("buildVersion"), + "tag": ga.get("tag"), + "sourceCommit": ga.get("sourceCommit"), + "releaseClass": "stable-ga", + "productDigest": ga.get("productDigest"), + "baselineIdentityDigest": _file_digest(bundle.ga_baseline_path), + "publicationReceiptIdentityDigest": ga.get("publicationReceiptDigest"), + "previousLineageDigest": bundle.lineage.get("previousLineageDigest"), + } + ] + else: + predecessor_lineage = predecessor.get("lineage", {}) + predecessor_history = list(predecessor_lineage.get("history", [])) + expected_history = [*predecessor_history] + expected_history.append( + { + "chainDepth": bundle.lineage.get("chainDepth"), + "releaseId": bundle.plan.get("releaseId"), + "buildVersion": bundle.plan.get("buildVersion"), + "tag": bundle.plan.get("expectedTag"), + "sourceCommit": bundle.plan.get("sourceCommit"), + "releaseClass": bundle.plan.get("releaseClass"), + "productDigest": bundle.plan.get("productDigest"), + "baselineIdentityDigest": _successor_identity(successor), + "publicationReceiptIdentityDigest": receipt_identity, + "previousLineageDigest": bundle.lineage.get("previousLineageDigest"), + } + ) + expected_lineage = { + "gaBaselineDigest": _file_digest(bundle.ga_baseline_path), + "gaPublicationReceiptDigest": bundle.lineage.get("gaRoot", {}).get( + "publicationReceiptDigest" + ), + "chainDepth": bundle.lineage.get("chainDepth"), + "lineageDigest": _semantic_digest(expected_history), + "history": expected_history, + } + if successor.get("lineage") != expected_lineage: + raise AdapterError("successor-lineage-binding-mismatch") + + +def _validate_verification_material( + material: VerificationMaterial, bundle: PublicationBundle +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: + receipt = dict(material.maintenance_receipt) + core_receipt = dict(material.core_update_receipt) + successor = dict(material.successor_baseline) + history = dict(material.history_entry) + for value in (receipt, core_receipt, successor, history): + _scan_public_value(value) + _validate_schema( + receipt, "stable-1.0-maintenance-publication-receipt-v1.schema.json" + ) + _validate_schema( + core_receipt, "cryptad-core-update-publication-receipt-v1.schema.json" + ) + + plan = bundle.plan + planned_assets = { + row.get("fileName"): ( + row.get("role"), + row.get("digest"), + row.get("sizeBytes"), + row.get("publicUri"), + ) + for row in plan.get("assets", []) + if isinstance(row, Mapping) + } + receipt_assets = receipt.get("assets", []) + receipt_asset_names = [ + row.get("fileName") for row in receipt_assets if isinstance(row, Mapping) + ] + assets_are_exact = ( + isinstance(receipt_assets, list) + and len(receipt_assets) == len(planned_assets) + and len(receipt_asset_names) == len(receipt_assets) + and len(set(receipt_asset_names)) == len(receipt_asset_names) + and all( + row.get("operation") in {"created", "verified-existing"} + and row.get("verificationStatus") == "verified" + for row in receipt_assets + if isinstance(row, Mapping) + ) + ) + observed_assets = { + row.get("fileName"): ( + row.get("role"), + row.get("digest"), + row.get("sizeBytes"), + row.get("publicUri"), + ) + for row in receipt_assets + if isinstance(row, Mapping) + } + public_observations = receipt.get("publicObservations") + exact_operations = {"created", "verified-existing"} + catalog_target = plan.get("stableCatalogTarget", {}) + core_target = plan.get("coreUpdateTarget", {}) + if ( + receipt.get("releaseId") != plan.get("releaseId") + or receipt.get("buildVersion") != plan.get("buildVersion") + or receipt.get("releaseClass") != plan.get("releaseClass") + or receipt.get("sourceCommit") != plan.get("sourceCommit") + or receipt.get("githubReleasePageUri") != plan.get("githubReleasePageUri") + or receipt.get("deploymentServicePublicUri") + != plan.get("deploymentServicePublicUri") + or receipt.get("latestPointerPublicUri") != plan.get("latestPointerPublicUri") + or receipt.get("candidateIdentityDigest") != plan.get("candidateIdentityDigest") + or receipt.get("productDigest") != plan.get("productDigest") + or receipt.get("checksumsDigest") != plan.get("checksumsDigest") + or receipt.get("provenanceDigest") != plan.get("provenanceDigest") + or receipt.get("authorizationDigest") != plan.get("authorizationDigest") + or receipt.get("coreUpdateReceiptDigest") + != "sha256:" + hashlib.sha256(_canonical_bytes(core_receipt)).hexdigest() + or receipt.get("publicationPlanDigest") != _file_digest(bundle.plan_path) + or receipt.get("releaseNotesDigest") != plan.get("releaseNotesDigest") + or receipt.get("coreInfoDigest") != plan.get("coreInfoDigest") + or not assets_are_exact + or planned_assets != observed_assets + or receipt.get("tag", {}).get("name") != plan.get("expectedTag") + or receipt.get("tag", {}).get("objectType") != "annotated" + or receipt.get("tag", {}).get("targetCommit") != plan.get("sourceCommit") + or receipt.get("tag", {}).get("operation") not in exact_operations + or receipt.get("tag", {}).get("verificationStatus") != "verified" + or receipt.get("githubRelease", {}).get("tag") != plan.get("expectedTag") + or receipt.get("githubRelease", {}).get("pageUri") + != plan.get("githubReleasePageUri") + or receipt.get("githubRelease", {}).get("notesDigest") + != plan.get("releaseNotesDigest") + or receipt.get("githubRelease", {}).get("operation") not in exact_operations + or receipt.get("githubRelease", {}).get("verificationStatus") != "verified" + or any( + receipt.get("stableCatalog", {}).get(key) != catalog_target.get(key) + for key in ( + "catalogId", + "revision", + "edition", + "digest", + "signatureDigest", + "publicUri", + "signaturePublicUri", + "mirrorSetDigest", + "rollbackStateDigest", + ) + ) + or catalog_target.get("signatureDigest") + != bundle.candidate_input.get("stableCatalog", {}).get("signatureDigest") + or receipt.get("stableCatalog", {}).get("operation") not in exact_operations + or receipt.get("stableCatalog", {}).get("verificationStatus") != "verified" + or any( + receipt.get("coreUpdate", {}).get(key) != core_target.get(key) + for key in ("edition", "descriptorDigest", "publicUri") + ) + or receipt.get("coreUpdate", {}).get("packageMapDigest") + != _semantic_digest(bundle.core_info.get("packages")) + or receipt.get("coreUpdate", {}).get("operation") not in exact_operations + or receipt.get("coreUpdate", {}).get("verificationStatus") != "verified" + or not isinstance(public_observations, Mapping) + or set(public_observations) != set(TARGETS) + or set(public_observations.values()) != {"verified"} + or receipt.get("publicationState") != "publication-complete" + or receipt.get("finalVerificationStatus") != "pass" + or receipt.get("failureCategory") is not None + or receipt.get("redaction") != _PASS_REDACTION + ): + raise AdapterError("maintenance-publication-receipt-mismatch") + + packages = bundle.candidate.get("packages") + expected_packages = { + row.get("packageKey"): ( + row.get("digest"), + row.get("sizeBytes"), + row.get("publicChk") or row.get("storeUrl"), + ) + for row in packages + if isinstance(row, Mapping) + } + referenced_packages = core_receipt.get("referencedPackages", []) + referenced_package_keys = [ + row.get("packageKey") + for row in referenced_packages + if isinstance(row, Mapping) + ] + packages_are_exact = ( + isinstance(referenced_packages, list) + and len(referenced_packages) == len(expected_packages) + and len(referenced_package_keys) == len(referenced_packages) + and len(set(referenced_package_keys)) == len(referenced_package_keys) + and all( + row.get("verificationStatus") == "pass" + for row in referenced_packages + if isinstance(row, Mapping) + ) + ) + observed_packages = { + row.get("packageKey"): ( + row.get("candidateAssetDigest"), + row.get("candidateAssetSizeBytes"), + row.get("publicReference"), + ) + for row in referenced_packages + if isinstance(row, Mapping) + } + if ( + core_receipt.get("releaseId") != plan.get("releaseId") + or core_receipt.get("buildVersion") != plan.get("buildVersion") + or core_receipt.get("releaseClass") != plan.get("releaseClass") + or core_receipt.get("candidateIdentityDigest") != plan.get("candidateIdentityDigest") + or core_receipt.get("publicationPlanDigest") != _file_digest(bundle.core_plan_path) + or core_receipt.get("descriptorDigest") != plan.get("coreInfoDigest") + or core_receipt.get("descriptorSizeBytes") != bundle.core_info_path.stat().st_size + or core_receipt.get("packageMapDigest") + != _semantic_digest(bundle.core_info.get("packages")) + or core_receipt.get("edition") != int(str(plan.get("buildVersion"))) + or core_receipt.get("publicFetchUri") + != plan.get("coreUpdateTarget", {}).get("publicUri") + or core_receipt.get("fetchedDescriptorDigest") != plan.get("coreInfoDigest") + or not packages_are_exact + or observed_packages != expected_packages + or core_receipt.get("operation") not in exact_operations + or core_receipt.get("conflictStatus") != "clear" + or core_receipt.get("verificationStatus") != "pass" + or core_receipt.get("publicationState") != "publication-complete" + or core_receipt.get("redaction") != _PASS_REDACTION + or receipt.get("coreUpdate", {}).get("descriptorDigest") + != core_receipt.get("descriptorDigest") + or receipt.get("coreUpdate", {}).get("packageMapDigest") + != core_receipt.get("packageMapDigest") + ): + raise AdapterError("core-update-publication-receipt-mismatch") + + receipt_identity = _receipt_identity(receipt) + _validate_history(history, bundle, receipt_identity) + history_bytes = _canonical_bytes(history) + history_digest = "sha256:" + hashlib.sha256(history_bytes).hexdigest() + _validate_successor(successor, history, history_digest, bundle, receipt_identity) + successor_bytes = _canonical_bytes(successor) + successor_digest = "sha256:" + hashlib.sha256(successor_bytes).hexdigest() + if ( + receipt.get("successorBaselineDigest") != successor_digest + or receipt.get("releaseHistoryDigest") != history_digest + ): + raise AdapterError("publication-successor-digest-mismatch") + return receipt, core_receipt, successor, history + + +def _failure_audit_path(receipt_path: Path) -> Path: + """Return the canonical non-receipt path for a failed publication attempt.""" + + return receipt_path.with_name(FAILURE_AUDIT_FILE) + + +def _failure_audit( + bundle: PublicationBundle, + snapshot: PublicSnapshot, + failure_category: str, + now: dt.datetime, + attempted_targets: Sequence[str] = (), + completed_targets: Sequence[str] = (), +) -> dict[str, Any]: + attempted = [target for target in TARGETS if target in set(attempted_targets)] + completed = [ + target + for target in TARGETS + if target in set(completed_targets) or snapshot.targets.get(target) == "matching" + ] + public_state = _public_state_class(snapshot) + publication_state = ( + "publication-partial" + if attempted or completed or public_state == "partial" + else "publication-verification-failed" + ) + audit = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-publication-failure-audit", + "generatedAt": _timestamp(now), + "releaseId": bundle.plan.get("releaseId"), + "buildVersion": bundle.plan.get("buildVersion"), + "releaseClass": bundle.plan.get("releaseClass"), + "sourceCommit": bundle.plan.get("sourceCommit"), + "candidateIdentityDigest": bundle.plan.get("candidateIdentityDigest"), + "productDigest": bundle.plan.get("productDigest"), + "publicationPlanDigest": _file_digest(bundle.plan_path), + "publicationState": publication_state, + "failureCategory": failure_category, + "predecessorPointerDigest": snapshot.predecessor_pointer_digest, + "latestCandidateIdentityDigest": snapshot.latest_candidate_identity_digest, + "observedPublicState": dict(snapshot.targets), + "attemptedTargets": attempted, + "completedTargets": completed, + "sideEffectsMayHaveOccurred": bool(attempted or completed), + "finalVerificationStatus": "fail", + "redaction": dict(_PASS_REDACTION), + } + _validate_schema( + audit, "stable-1.0-maintenance-publication-failure-audit-v1.schema.json" + ) + return audit + + +def publish_or_verify_exact( + root: Path, + expected_predecessor_pointer_digest: str, + operations: ExternalOperations, + protected_inputs: PublicationProtectedInputs, + source_ref_revalidator: Callable[[PublicationBundle | ActivationRequest], None], + receipt_path: Path, + core_receipt_path: Path, + *, + now: Callable[[], dt.datetime] = _utcnow, +) -> CommandOutcome: + """Publish each exact target once or verify an entirely matching pre-existing release.""" + + initial = preflight(root, expected_predecessor_pointer_digest, operations, now=now()) + bundle = _load_bundle(root) + request = PublicationRequest(bundle) + if not initial.passed: + audit = _failure_audit( + bundle, + initial.snapshot, + str(initial.artifact.get("failureCategory") or "verification-unavailable"), + now(), + ) + return CommandOutcome(False, {_failure_audit_path(receipt_path): audit}) + + initial_state = _public_state_class(initial.snapshot) + attempted: list[str] = [] + completed = _completed_target_prefix(initial.snapshot) + try: + if initial_state in {"absent", "resumable-prefix"}: + for index in range(len(completed), len(MUTATION_TARGETS)): + target = MUTATION_TARGETS[index] + request = _revalidate_bundle(request) + if not _authorization_current(request.bundle, now()): + raise AdapterError("authorization-expired-before-mutation") + snapshot = _observe(operations, request) + remaining = MUTATION_TARGETS[index:] + if not _expected_mutation_snapshot( + snapshot, completed, remaining, expected_predecessor_pointer_digest + ): + raise AdapterError("public-state-changed-before-mutation") + source_ref_revalidator(request.bundle) + if not _authorization_current(request.bundle, now()): + raise AdapterError("authorization-expired-before-mutation") + attempted.append(target) + try: + operations.publish_target( + target, request, protected_inputs.for_target(target) + ) + except Exception as exc: # noqa: BLE001 - never expose provider/secret details + raise AdapterError("protected-publication-operation-failed") from exc + completed.append(target) + elif initial_state != "matching-existing": + raise AdapterError("publication-preflight-not-mutation-safe") + else: + source_ref_revalidator(request.bundle) + + request = _revalidate_bundle(request) + final_snapshot = _observe(operations, request) + if ( + _public_state_class(final_snapshot) != "matching-existing" + or ( + final_snapshot.predecessor_pointer_digest + != expected_predecessor_pointer_digest + and final_snapshot.latest_candidate_identity_digest + != request.candidate_identity_digest + ) + ): + raise AdapterError("post-publication-public-state-mismatch") + try: + material = operations.verify_publication(request) + except Exception as exc: # noqa: BLE001 + raise AdapterError("independent-publication-verification-unavailable") from exc + receipt, core, _successor, _history = _validate_verification_material( + material, request.bundle + ) + return CommandOutcome(True, {receipt_path: receipt, core_receipt_path: core}) + except AdapterError as failure: + try: + snapshot = _observe(operations, request) + except AdapterError: + snapshot = _unavailable_snapshot() + audit = _failure_audit( + bundle, + snapshot, + failure.code, + now(), + attempted, + completed, + ) + return CommandOutcome(False, {_failure_audit_path(receipt_path): audit}) + + +def verify_public_state( + root: Path, + operations: ExternalOperations, + receipt_path: Path, + core_receipt_path: Path, + successor_path: Path, + history_path: Path, +) -> CommandOutcome: + """Independently verify public bytes and materialize only authenticated public records.""" + + bundle = _load_bundle(root) + request = PublicationRequest(bundle) + try: + snapshot = _observe(operations, request) + if _public_state_class(snapshot) != "matching-existing": + raise AdapterError("independent-public-state-is-not-exact") + expected_pointer = str(bundle.lineage.get("latestPublishedPointerDigest")) + if ( + snapshot.predecessor_pointer_digest != expected_pointer + and snapshot.latest_candidate_identity_digest != request.candidate_identity_digest + ): + raise AdapterError("independent-predecessor-observation-changed") + try: + material = operations.verify_publication(request) + except Exception as exc: # noqa: BLE001 + raise AdapterError("independent-publication-verification-unavailable") from exc + receipt, core, successor, history = _validate_verification_material( + material, bundle + ) + return CommandOutcome( + True, + { + receipt_path: receipt, + core_receipt_path: core, + successor_path: successor, + history_path: history, + }, + ) + except AdapterError as failure: + audit = _failure_audit( + bundle, + snapshot if "snapshot" in locals() else _unavailable_snapshot(), + failure.code, + _utcnow(), + ) + return CommandOutcome(False, {_failure_audit_path(receipt_path): audit}) + + +def _activated_latest_pointer( + successor: Mapping[str, Any], + successor_digest: str, + receipt: Mapping[str, Any], + receipt_digest: str, + history_digest: str, +) -> dict[str, Any]: + """Build the one canonical latest-published pointer authorized for activation.""" + + release_value = successor.get("release") + release = release_value if isinstance(release_value, Mapping) else {} + lineage_value = successor.get("lineage") + lineage = lineage_value if isinstance(lineage_value, Mapping) else {} + return { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-latest-published", + "generatedAt": successor.get("generatedAt"), + "releaseId": release.get("releaseId"), + "buildVersion": release.get("buildVersion"), + "releaseClass": release.get("releaseClass"), + "baselineDigest": successor_digest, + "baselineIdentityDigest": _successor_identity(successor), + "publicationReceiptDigest": receipt_digest, + "publicationReceiptIdentityDigest": _receipt_identity(receipt), + "lineageDigest": lineage.get("lineageDigest"), + "historyDigest": history_digest, + "compareAndSwapPredecessorBaselineDigest": successor.get( + "previousBaselineDigest" + ), + "status": "active", + "redaction": dict(_PASS_REDACTION), + } + + +def _load_activation_request( + successor_path: Path, + history_path: Path, + receipt_path: Path, + authorization_path: Path, + activation_authorization_path: Path, + expected_pointer_digest: str, +) -> ActivationRequest: + if not _DIGEST_RE.fullmatch(expected_pointer_digest): + raise AdapterError("expected-current-pointer-digest-malformed") + successor = _read_json(successor_path) + history = _read_json(history_path) + receipt = _read_json(receipt_path) + authorization = _read_json(authorization_path) + activation_authorization = _read_json(activation_authorization_path) + _validate_schema( + successor, "stable-1.0-maintenance-successor-baseline-v2.schema.json" + ) + _validate_schema( + receipt, "stable-1.0-maintenance-publication-receipt-v1.schema.json" + ) + _validate_schema( + authorization, "stable-1.0-maintenance-authorization-v1.schema.json" + ) + _validate_schema( + activation_authorization, + "stable-1.0-maintenance-activation-authorization-v1.schema.json", + ) + _scan_public_value(authorization) + _scan_public_value(activation_authorization) + successor_digest = _file_digest(successor_path) + history_digest = _file_digest(history_path) + receipt_digest = _file_digest(receipt_path) + authorization_digest = _file_digest(authorization_path) + activation_authorization_digest = _file_digest(activation_authorization_path) + release = successor.get("release") if isinstance(successor.get("release"), Mapping) else {} + release_class = receipt.get("releaseClass") + expected_role = ( + "stable-maintenance-release-manager" + if release_class == "maintenance" + else "stable-security-release-manager" + ) + expected_environment = ( + "stable-1.0-maintenance-publication" + if release_class == "maintenance" + else "stable-1.0-security-hotfix-publication" + ) + if ( + receipt.get("publicationState") != "publication-complete" + or receipt.get("finalVerificationStatus") != "pass" + or receipt.get("successorBaselineDigest") != successor_digest + or receipt.get("releaseHistoryDigest") != history_digest + or successor.get("status") != "published" + or successor.get("releaseHistoryDigest") != history_digest + or release.get("releaseId") != receipt.get("releaseId") + or release.get("buildVersion") != receipt.get("buildVersion") + or release.get("releaseClass") != receipt.get("releaseClass") + or release.get("sourceCommit") != receipt.get("sourceCommit") + or history.get("releaseId") != receipt.get("releaseId") + or history.get("buildVersion") != receipt.get("buildVersion") + or history.get("publicationReceiptIdentityDigest") != _receipt_identity(receipt) + or successor.get("publication", {}).get("receiptIdentityDigest") + != _receipt_identity(receipt) + or receipt.get("authorizationDigest") != authorization_digest + or authorization.get("releaseId") != receipt.get("releaseId") + or authorization.get("buildVersion") != receipt.get("buildVersion") + or authorization.get("releaseClass") != release_class + or authorization.get("candidateIdentityDigest") + != receipt.get("candidateIdentityDigest") + or authorization.get("productDigest") != receipt.get("productDigest") + or authorization.get("checksumsDigest") != receipt.get("checksumsDigest") + or authorization.get("provenanceDigest") != receipt.get("provenanceDigest") + or authorization.get("coreInfoDigest") != receipt.get("coreInfoDigest") + or authorization.get("releaseNotesDigest") + != receipt.get("releaseNotesDigest") + or authorization.get("allowedPublicationScopes") != list(AUTHORIZATION_SCOPES) + or authorization.get("role") != expected_role + or authorization.get("decision") not in {"go", "go-with-waivers"} + or authorization.get("status") != "approved" + or authorization.get("redaction") != _PASS_REDACTION + or activation_authorization.get("protectedEnvironment") + != expected_environment + or activation_authorization.get("sourceCommit") + != receipt.get("sourceCommit") + or activation_authorization.get("releaseId") != receipt.get("releaseId") + or activation_authorization.get("buildVersion") + != receipt.get("buildVersion") + or activation_authorization.get("releaseClass") != release_class + or activation_authorization.get("candidateIdentityDigest") + != receipt.get("candidateIdentityDigest") + or activation_authorization.get("publicationReceiptDigest") + != receipt_digest + or activation_authorization.get("successorBaselineDigest") + != successor_digest + or activation_authorization.get("historyDigest") != history_digest + or activation_authorization.get("originalAuthorizationDigest") + != authorization_digest + or activation_authorization.get("expectedCurrentPointerDigest") + != expected_pointer_digest + or activation_authorization.get("allowedScope") + != "successor-baseline:activate" + or activation_authorization.get("status") != "approved" + or activation_authorization.get("redaction") != _PASS_REDACTION + ): + raise AdapterError("baseline-activation-input-binding-mismatch") + activated_pointer = _activated_latest_pointer( + successor, + successor_digest, + receipt, + receipt_digest, + history_digest, + ) + activated_pointer_bytes = _canonical_bytes(activated_pointer) + activated_pointer_digest = ( + "sha256:" + hashlib.sha256(activated_pointer_bytes).hexdigest() + ) + return ActivationRequest( + successor_path, + successor, + history_path, + history, + receipt_path, + receipt, + authorization_path, + authorization, + authorization_digest, + activation_authorization_path, + activation_authorization, + activation_authorization_digest, + expected_pointer_digest, + successor_digest, + history_digest, + receipt_digest, + activated_pointer, + activated_pointer_bytes, + activated_pointer_digest, + ) + + +def _revalidate_activation_authorization( + request: ActivationRequest, now: dt.datetime +) -> None: + """Require the exact renewable activation-only grant to remain current.""" + + current = _read_json(request.activation_authorization_path) + _validate_schema( + current, "stable-1.0-maintenance-activation-authorization-v1.schema.json" + ) + _scan_public_value(current) + if ( + _file_digest(request.activation_authorization_path) + != request.activation_authorization_digest + or current != request.activation_authorization + or current.get("releaseId") != request.receipt.get("releaseId") + or current.get("buildVersion") != request.receipt.get("buildVersion") + or current.get("releaseClass") != request.receipt.get("releaseClass") + or current.get("candidateIdentityDigest") + != request.receipt.get("candidateIdentityDigest") + or current.get("publicationReceiptDigest") != request.receipt_digest + or current.get("successorBaselineDigest") != request.successor_digest + or current.get("historyDigest") != request.history_digest + or current.get("originalAuthorizationDigest") + != request.authorization_digest + or current.get("expectedCurrentPointerDigest") + != request.expected_pointer_digest + or current.get("allowedScope") != "successor-baseline:activate" + or current.get("status") != "approved" + or current.get("redaction") != _PASS_REDACTION + ): + raise AdapterError("baseline-activation-authorization-changed") + authorized = _parse_timestamp(current.get("authorizedAt")) + expires = _parse_timestamp(current.get("expiresAt")) + if ( + authorized is None + or expires is None + or expires <= authorized + or expires - authorized > dt.timedelta(hours=1) + or not (authorized <= now < expires) + ): + raise AdapterError("activation-authorization-expired") + + +def _normalize_pointer(snapshot: PointerSnapshot) -> PointerSnapshot: + if snapshot.status not in {"observed", "unavailable"}: + raise AdapterError("latest-pointer-observation-malformed") + for digest in ( + snapshot.pointer_digest, + snapshot.active_baseline_digest, + snapshot.candidate_identity_digest, + ): + if digest is not None and not _DIGEST_RE.fullmatch(digest): + raise AdapterError("latest-pointer-observation-malformed") + return snapshot + + +def activate_latest_baseline( + successor_path: Path, + history_path: Path, + receipt_path: Path, + authorization_path: Path, + activation_authorization_path: Path, + expected_pointer_digest: str, + operations: ExternalOperations, + maintenance_state_input: SecretMaterial, + source_ref_revalidator: Callable[[PublicationBundle | ActivationRequest], None], + activation_receipt_path: Path, + *, + now: dt.datetime | Callable[[], dt.datetime] | None = None, +) -> CommandOutcome: + """Activate the verified successor with an exact compare-and-swap operation.""" + + if maintenance_state_input.purpose != _MAINTENANCE_STATE_PURPOSE: + raise AdapterError("maintenance-state-protected-input-purpose-mismatch") + if callable(now): + clock = now + elif isinstance(now, dt.datetime): + clock = lambda: now + else: + clock = _utcnow + generated_at = clock() + request = _load_activation_request( + successor_path, + history_path, + receipt_path, + authorization_path, + activation_authorization_path, + expected_pointer_digest, + ) + mutation_started = False + operation = "created" + observed_pointer_digest: str | None = None + try: + try: + before = _normalize_pointer(operations.observe_latest_pointer(request)) + except Exception as exc: # noqa: BLE001 + raise AdapterError("latest-pointer-observation-unavailable") from exc + observed_pointer_digest = before.pointer_digest + already_active = ( + before.status == "observed" + and before.pointer_digest == request.activated_pointer_digest + and before.active_baseline_digest == request.successor_digest + and before.candidate_identity_digest + == request.receipt.get("candidateIdentityDigest") + ) + if already_active: + source_ref_revalidator(request) + _revalidate_activation_authorization(request, clock()) + operation = "verified-existing" + else: + if ( + before.status != "observed" + or before.pointer_digest != expected_pointer_digest + ): + raise AdapterError("latest-pointer-compare-and-swap-conflict") + source_ref_revalidator(request) + _revalidate_activation_authorization(request, clock()) + mutation_started = True + try: + operations.activate_latest(request, maintenance_state_input) + except Exception as exc: # noqa: BLE001 + raise AdapterError("latest-pointer-activation-failed") from exc + try: + after = _normalize_pointer(operations.observe_latest_pointer(request)) + except Exception as exc: # noqa: BLE001 + raise AdapterError("latest-pointer-post-activation-unavailable") from exc + observed_pointer_digest = after.pointer_digest + if ( + after.status != "observed" + or after.pointer_digest != request.activated_pointer_digest + or after.active_baseline_digest != request.successor_digest + or after.candidate_identity_digest + != request.receipt.get("candidateIdentityDigest") + ): + raise AdapterError("latest-pointer-post-activation-mismatch") + artifact = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-baseline-activation-receipt", + "generatedAt": _timestamp(generated_at), + "releaseId": request.receipt.get("releaseId"), + "buildVersion": request.receipt.get("buildVersion"), + "releaseClass": request.receipt.get("releaseClass"), + "candidateIdentityDigest": request.receipt.get("candidateIdentityDigest"), + "authorizationDigest": request.authorization_digest, + "activationAuthorizationDigest": request.activation_authorization_digest, + "successorBaselineDigest": request.successor_digest, + "historyDigest": request.history_digest, + "expectedPreviousPointerDigest": expected_pointer_digest, + "expectedActivatedPointerDigest": request.activated_pointer_digest, + "observedPointerDigest": observed_pointer_digest, + "operation": operation, + "pointerUpdate": "activated", + "status": "pass", + "verificationStatus": "pass", + "failureCategory": None, + "redaction": dict(_PASS_REDACTION), + } + return CommandOutcome(True, {activation_receipt_path: artifact}) + except AdapterError as failure: + artifact = { + "schemaVersion": 1, + "kind": "stable-1.0-maintenance-baseline-activation-receipt", + "generatedAt": _timestamp(generated_at), + "releaseId": request.receipt.get("releaseId"), + "buildVersion": request.receipt.get("buildVersion"), + "releaseClass": request.receipt.get("releaseClass"), + "candidateIdentityDigest": request.receipt.get("candidateIdentityDigest"), + "authorizationDigest": request.authorization_digest, + "activationAuthorizationDigest": request.activation_authorization_digest, + "successorBaselineDigest": request.successor_digest, + "historyDigest": request.history_digest, + "expectedPreviousPointerDigest": expected_pointer_digest, + "expectedActivatedPointerDigest": request.activated_pointer_digest, + "observedPointerDigest": observed_pointer_digest, + "operation": "partial" if mutation_started else "none", + "pointerUpdate": "not-activated", + "status": "fail", + "verificationStatus": "fail", + "failureCategory": failure.code, + "redaction": dict(_PASS_REDACTION), + } + return CommandOutcome(False, {activation_receipt_path: artifact}) + + +def _write_canonical( + path: Path, + value: Mapping[str, Any], + protected_inputs: Sequence[SecretMaterial] = (), +) -> None: + for protected_input in protected_inputs: + _scan_public_value(value, secret=protected_input.value) + data = _canonical_bytes(value) + if any( + protected_input.value.encode("utf-8") in data + for protected_input in protected_inputs + ): + raise AdapterError("protected-input-leak") + parent = path.parent + parent.mkdir(parents=True, exist_ok=True) + if parent.is_symlink() or path.is_symlink(): + raise AdapterError("unsafe-output-path") + if path.exists(): + _regular_file(path) + if path.read_bytes() == data: + return + raise AdapterError("refusing-to-overwrite-different-audit-output") + descriptor, temporary_name = tempfile.mkstemp( + dir=parent, prefix=f".{path.name}.", suffix=".tmp" + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(data) + os.chmod(temporary, 0o644) + if path.is_symlink() or path.exists(): + raise AdapterError("unsafe-output-race") + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _assert_execution_context(mode: str, environ: Mapping[str, str]) -> None: + forbidden_markers = ( + "CRYPTAD_SELF_TEST", + "CODEX_SELF_TEST", + "PYTEST_CURRENT_TEST", + "UNITTEST_CURRENT_TEST", + ) + expected_job = { + "preflight-only": "protected-publication", + "publish-or-verify-exact": "protected-publication", + "verify-public-state-only": "independent-verification", + "activate-latest-baseline": "activate-latest-baseline", + }[mode] + workflow_ref = environ.get("GITHUB_WORKFLOW_REF", "") + if ( + any(environ.get(marker) for marker in forbidden_markers) + or environ.get("GITHUB_ACTIONS") != "true" + or environ.get("GITHUB_EVENT_NAME") != "workflow_dispatch" + or environ.get("GITHUB_REPOSITORY") != "crypta-network/cryptad" + or WORKFLOW_PATH not in workflow_ref + or environ.get("GITHUB_JOB") != expected_job + or not re.fullmatch(r"refs/heads/(?:release|hotfix)/[1-9][0-9]*", environ.get("GITHUB_REF", "")) + ): + raise AdapterError("protected-workflow-context-required") + + +def _load_secret( + name: str | None, + expected_name: str, + purpose: str, + environ: Mapping[str, str], +) -> SecretMaterial: + if name != expected_name: + raise AdapterError("protected-input-environment-name-rejected") + value = environ.get(name, "") + if len(value) < 12 or "\x00" in value or "\n" in value or "\r" in value: + raise AdapterError("protected-input-not-materialized") + return SecretMaterial(purpose, value) + + +def _scrub_protected_input_environment( + environ: Mapping[str, str], +) -> dict[str, str]: + """Remove target-specific protected inputs before any provider code executes.""" + + sanitized = dict(environ) + for name in ( + CATALOG_INPUT_ENV, + CORE_UPDATE_INPUT_ENV, + MAINTENANCE_STATE_INPUT_ENV, + ): + sanitized.pop(name, None) + os.environ.pop(name, None) + return sanitized + + +def _make_remote_source_ref_revalidator( + repository: str | None, + branch: str | None, + commit: str | None, + token_environment_name: str | None, + environ: Mapping[str, str], + *, + opener: Callable[..., Any] = urllib_request.urlopen, +) -> Callable[[PublicationBundle | ActivationRequest], None]: + """Create a secret-safe GitHub ref verifier bound to the authorized source identity.""" + + if ( + repository != SOURCE_REPOSITORY + or not isinstance(branch, str) + or not re.fullmatch(r"(?:release|hotfix)/[1-9][0-9]*", branch) + or not isinstance(commit, str) + or not _COMMIT_RE.fullmatch(commit) + or token_environment_name != GITHUB_TOKEN_ENV + ): + raise AdapterError("remote-source-ref-command-binding-invalid") + token = environ.get(GITHUB_TOKEN_ENV, "") + if len(token) < 12 or any(character in token for character in "\x00\r\n"): + raise AdapterError("remote-source-ref-token-not-materialized") + + def verify(record: PublicationBundle | ActivationRequest) -> None: + if isinstance(record, PublicationBundle): + record_branch = record.plan.get("sourceBranch") + record_commit = record.plan.get("sourceCommit") + else: + record_commit = record.receipt.get("sourceCommit") + record_branch = ( + f"release/{record.receipt.get('buildVersion')}" + if record.receipt.get("releaseClass") == "maintenance" + else f"hotfix/{record.receipt.get('buildVersion')}" + ) + if record_branch != branch or record_commit != commit: + raise AdapterError("remote-source-ref-artifact-binding-mismatch") + + encoded_branch = urllib_parse.quote(branch, safe="/") + endpoint = ( + "https://api.github.com/repos/crypta-network/cryptad/git/ref/heads/" + f"{encoded_branch}" + ) + source_request = urllib_request.Request( + endpoint, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "cryptad-stable-maintenance-publication", + }, + method="GET", + ) + try: + with opener(source_request, timeout=30) as response: + status = getattr(response, "status", None) + encoded = response.read(MAX_JSON_BYTES + 1) + except Exception as exc: # noqa: BLE001 - never expose request or token details + raise AdapterError("remote-source-ref-observation-unavailable") from exc + if status != 200 or len(encoded) > MAX_JSON_BYTES: + raise AdapterError("remote-source-ref-observation-unavailable") + payload = _strict_json_bytes(encoded, "remote-source-ref-response-malformed") + observed_object = ( + payload.get("object") if isinstance(payload, Mapping) else None + ) + if ( + not isinstance(observed_object, Mapping) + or payload.get("ref") != f"refs/heads/{branch}" + or observed_object.get("type") != "commit" + or observed_object.get("sha") != commit + ): + raise AdapterError("remote-source-ref-moved") + + return verify + + +_BACKEND_METHODS = ( + "observe_public_state", + "publish_target", + "verify_publication", + "observe_latest_pointer", + "activate_latest", +) + + +def _path_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def _authenticated_backend_site(environ: Mapping[str, str]) -> Path: + raw_site = environ.get(BACKEND_SITE_ENV, "") + supplied = Path(raw_site) + if not raw_site or not supplied.is_absolute(): + raise AdapterError("protected-publication-backend-site-not-authenticated") + try: + resolved = supplied.resolve(strict=True) + except OSError as exc: + raise AdapterError( + "protected-publication-backend-site-not-authenticated" + ) from exc + if supplied != resolved or not resolved.is_dir(): + raise AdapterError("protected-publication-backend-site-not-authenticated") + return resolved + + +def _module_origin(module: ModuleType) -> Path | None: + raw_origin = getattr(module, "__file__", None) + if not isinstance(raw_origin, str) or not raw_origin: + return None + try: + return Path(raw_origin).resolve(strict=True) + except OSError: + return None + + +def _module_is_from_site(module: ModuleType, site: Path) -> bool: + origin = _module_origin(module) + return origin is not None and _path_within(origin, site) + + +def _stdlib_roots() -> tuple[Path, ...]: + roots: list[Path] = [] + for key in ("stdlib", "platstdlib"): + value = sysconfig.get_path(key) + if not value: + continue + resolved = Path(value).resolve() + if resolved not in roots: + roots.append(resolved) + return tuple(roots) + + +_STDLIB_ROOTS = _stdlib_roots() + + +def _path_is_stdlib(path: Path) -> bool: + for root in _STDLIB_ROOTS: + if not _path_within(path, root): + continue + relative_parts = path.relative_to(root).parts + if not {"site-packages", "dist-packages"}.intersection(relative_parts): + return True + return False + + +def _module_is_trusted_for_backend(module: ModuleType, site: Path) -> bool: + if module is sys.modules.get(__name__): + return True + origin = _module_origin(module) + if origin is not None: + return _path_within(origin, site) or _path_is_stdlib(origin) + spec = getattr(module, "__spec__", None) + spec_origin = getattr(spec, "origin", None) + if spec_origin in {"built-in", "frozen"}: + return True + namespace_paths = getattr(module, "__path__", None) + if namespace_paths is None: + return False + resolved_paths: list[Path] = [] + try: + for item in namespace_paths: + resolved_paths.append(Path(item).resolve(strict=True)) + except (OSError, TypeError, ValueError): + return False + return bool(resolved_paths) and all( + _path_within(path, site) or _path_is_stdlib(path) for path in resolved_paths + ) + + +def _trusted_backend_sys_path(site: Path) -> list[str]: + trusted = [str(site)] + for raw_path in sys.path: + if not raw_path: + continue + candidate = Path(raw_path).resolve() + if candidate == site or not _path_is_stdlib(candidate): + continue + rendered = str(candidate) + if rendered not in trusted: + trusted.append(rendered) + return trusted + + +@contextlib.contextmanager +def _authenticated_backend_import_scope(site: Path) -> Iterator[None]: + original_path = list(sys.path) + original_modules = dict(sys.modules) + removed_modules: dict[str, ModuleType] = {} + for name, module in original_modules.items(): + if not isinstance(module, ModuleType): + continue + if not _module_is_trusted_for_backend(module, site): + removed_modules[name] = module + sys.modules.pop(name, None) + sys.path[:] = _trusted_backend_sys_path(site) + importlib.invalidate_caches() + invalid_modules: list[str] = [] + try: + yield + finally: + for name, module in tuple(sys.modules.items()): + if not isinstance(module, ModuleType): + continue + if original_modules.get(name) is module: + continue + if not _module_is_trusted_for_backend(module, site): + invalid_modules.append(name) + for name in invalid_modules: + sys.modules.pop(name, None) + sys.path[:] = original_path + importlib.invalidate_caches() + for name, original in removed_modules.items(): + current = sys.modules.get(name) + if current is None: + sys.modules[name] = original + elif not _module_is_trusted_for_backend(current, site): + sys.modules[name] = original + if invalid_modules: + raise AdapterError("protected-publication-backend-origin-invalid") + + +def _require_site_module(module: ModuleType | None, site: Path) -> ModuleType: + if module is None or not _module_is_from_site(module, site): + raise AdapterError("protected-publication-backend-origin-invalid") + return module + + +def _require_site_callable(value: Any, site: Path) -> Callable[..., Any]: + if not callable(value): + raise AdapterError("protected-publication-backend-contract-invalid") + module = inspect.getmodule(value) + if module is None and inspect.ismethod(value): + module = inspect.getmodule(value.__func__) + _require_site_module(module, site) + return value + + +class _AuthenticatedBackend: + """Invoke every provider method with checkout and global packages excluded.""" + + def __init__(self, backend: ExternalOperations, site: Path) -> None: + self._backend = backend + self._site = site + + def __getattr__(self, name: str) -> Callable[..., Any]: + if name not in _BACKEND_METHODS: + raise AttributeError(name) + + def invoke(*args: Any, **kwargs: Any) -> Any: + with _authenticated_backend_import_scope(self._site): + _require_site_module(inspect.getmodule(type(self._backend)), self._site) + operation = _require_site_callable( + getattr(self._backend, name, None), self._site + ) + return operation(*args, **kwargs) + + return invoke + + +def _load_backend(environ: Mapping[str, str]) -> ExternalOperations: + if any( + name in environ or name in os.environ + for name in ( + CATALOG_INPUT_ENV, + CORE_UPDATE_INPUT_ENV, + MAINTENANCE_STATE_INPUT_ENV, + ) + ): + raise AdapterError("protected-input-environment-not-scrubbed") + factory_name = environ.get(BACKEND_FACTORY_ENV, "") + if not _FACTORY_RE.fullmatch(factory_name): + raise AdapterError("protected-publication-backend-not-materialized") + site = _authenticated_backend_site(environ) + module_name, attribute = factory_name.split(":", 1) + try: + with _authenticated_backend_import_scope(site): + module = importlib.import_module(module_name) + _require_site_module(module, site) + factory = _require_site_callable(getattr(module, attribute, None), site) + backend = factory() + _require_site_module(inspect.getmodule(type(backend)), site) + for name in _BACKEND_METHODS: + _require_site_callable(getattr(backend, name, None), site) + except Exception as exc: # noqa: BLE001 + raise AdapterError("protected-publication-backend-load-failed") from exc + return _AuthenticatedBackend(backend, site) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument( + "--mode", + required=True, + choices=( + "preflight-only", + "publish-or-verify-exact", + "verify-public-state-only", + "activate-latest-baseline", + ), + ) + parser.add_argument("--bundle", type=Path) + parser.add_argument("--catalog-input-env") + parser.add_argument("--core-update-input-env") + parser.add_argument("--maintenance-state-input-env") + parser.add_argument("--no-protected-inputs", action="store_true") + parser.add_argument("--expected-predecessor-pointer-digest") + parser.add_argument("--expected-current-pointer-digest") + parser.add_argument("--expected-source-repository") + parser.add_argument("--expected-source-branch") + parser.add_argument("--expected-source-commit") + parser.add_argument("--github-token-env") + parser.add_argument("--out", type=Path) + parser.add_argument("--receipt", type=Path) + parser.add_argument("--core-update-receipt", type=Path) + parser.add_argument("--successor-baseline", type=Path) + parser.add_argument("--history-entry", type=Path) + parser.add_argument("--publication-receipt", type=Path) + parser.add_argument("--authorization", type=Path) + parser.add_argument("--activation-authorization", type=Path) + parser.add_argument("--activation-receipt", type=Path) + parser.add_argument("--idempotency", choices=("exact-match-only",)) + parser.add_argument("--conflict-action", choices=("fail",)) + parser.add_argument("--partial-state-action", choices=("record-only",)) + for name in ( + "check-latest-predecessor", + "check-authorization-expiry", + "check-exact-freeze-bytes", + "check-tag-release-artifact-catalog-and-updater-conflicts", + "recheck-remote-source-ref", + "no-side-effects", + "revalidate-before-each-mutation", + "forbid-overwrite", + "forbid-delete-recovery", + "verify-annotated-tag", + "verify-github-release-and-assets", + "verify-artifact-base", + "verify-stable-catalog-primary-mirrors-and-rollback", + "verify-core-info-exact-bytes", + "verify-every-core-info-package-reference", + "forbid-unexpected-assets", + "compare-and-swap", + "forbid-overwrite-on-conflict", + "verify-after-activation", + ): + parser.add_argument(f"--{name}", action="store_true") + return parser + + +def _require(value: Any, code: str) -> Any: + if value is None or value is False: + raise AdapterError(code) + return value + + +def _validate_mode_args(arguments: argparse.Namespace) -> None: + mode = arguments.mode + if mode == "preflight-only": + for value in ( + arguments.bundle, + arguments.no_protected_inputs, + arguments.expected_predecessor_pointer_digest, + arguments.out, + arguments.check_latest_predecessor, + arguments.check_authorization_expiry, + arguments.check_exact_freeze_bytes, + arguments.check_tag_release_artifact_catalog_and_updater_conflicts, + arguments.no_side_effects, + ): + _require(value, "preflight-command-contract-incomplete") + if arguments.idempotency != "exact-match-only" or arguments.conflict_action != "fail": + raise AdapterError("preflight-command-policy-mismatch") + if any( + value is not None + for value in ( + arguments.catalog_input_env, + arguments.core_update_input_env, + arguments.maintenance_state_input_env, + arguments.authorization, + arguments.activation_authorization, + ) + ): + raise AdapterError("preflight-protected-input-forbidden") + elif mode == "publish-or-verify-exact": + for value in ( + arguments.bundle, + arguments.catalog_input_env, + arguments.core_update_input_env, + arguments.expected_predecessor_pointer_digest, + arguments.expected_source_repository, + arguments.expected_source_branch, + arguments.expected_source_commit, + arguments.github_token_env, + arguments.recheck_remote_source_ref, + arguments.receipt, + arguments.core_update_receipt, + arguments.revalidate_before_each_mutation, + arguments.forbid_overwrite, + arguments.forbid_delete_recovery, + ): + _require(value, "publication-command-contract-incomplete") + if ( + arguments.idempotency != "exact-match-only" + or arguments.conflict_action != "fail" + or arguments.partial_state_action != "record-only" + ): + raise AdapterError("publication-command-policy-mismatch") + if ( + arguments.maintenance_state_input_env is not None + or arguments.authorization is not None + or arguments.activation_authorization is not None + or arguments.no_protected_inputs + ): + raise AdapterError("publication-protected-input-contract-mismatch") + elif mode == "verify-public-state-only": + for value in ( + arguments.bundle, + arguments.no_protected_inputs, + arguments.receipt, + arguments.core_update_receipt, + arguments.successor_baseline, + arguments.history_entry, + arguments.verify_annotated_tag, + arguments.verify_github_release_and_assets, + arguments.verify_artifact_base, + arguments.verify_stable_catalog_primary_mirrors_and_rollback, + arguments.verify_core_info_exact_bytes, + arguments.verify_every_core_info_package_reference, + arguments.forbid_unexpected_assets, + ): + _require(value, "verification-command-contract-incomplete") + if any( + value is not None + for value in ( + arguments.catalog_input_env, + arguments.core_update_input_env, + arguments.maintenance_state_input_env, + arguments.authorization, + arguments.activation_authorization, + ) + ): + raise AdapterError("verification-protected-input-forbidden") + else: + for value in ( + arguments.maintenance_state_input_env, + arguments.expected_current_pointer_digest, + arguments.expected_source_repository, + arguments.expected_source_branch, + arguments.expected_source_commit, + arguments.github_token_env, + arguments.recheck_remote_source_ref, + arguments.successor_baseline, + arguments.history_entry, + arguments.publication_receipt, + arguments.authorization, + arguments.activation_authorization, + arguments.activation_receipt, + arguments.check_authorization_expiry, + arguments.compare_and_swap, + arguments.forbid_overwrite_on_conflict, + arguments.verify_after_activation, + ): + _require(value, "activation-command-contract-incomplete") + if ( + arguments.catalog_input_env is not None + or arguments.core_update_input_env is not None + or arguments.no_protected_inputs + ): + raise AdapterError("activation-protected-input-contract-mismatch") + + +def main( + argv: Sequence[str] | None = None, + *, + operations: ExternalOperations | None = None, + source_ref_revalidator: Callable[ + [PublicationBundle | ActivationRequest], None + ] + | None = None, + environ: Mapping[str, str] | None = None, +) -> int: + """Execute one exact workflow-aligned adapter mode.""" + + environment = dict(os.environ if environ is None else environ) + protected_values: tuple[SecretMaterial, ...] = () + try: + arguments = _parser().parse_args(argv) + _assert_execution_context(arguments.mode, environment) + _validate_mode_args(arguments) + publication_inputs: PublicationProtectedInputs | None = None + maintenance_state_input: SecretMaterial | None = None + try: + if arguments.mode == "publish-or-verify-exact": + catalog_input = _load_secret( + arguments.catalog_input_env, + CATALOG_INPUT_ENV, + _CATALOG_PURPOSE, + environment, + ) + core_update_input = _load_secret( + arguments.core_update_input_env, + CORE_UPDATE_INPUT_ENV, + _CORE_UPDATE_PURPOSE, + environment, + ) + protected_values = (catalog_input, core_update_input) + publication_inputs = PublicationProtectedInputs( + catalog_input, core_update_input + ) + elif arguments.mode == "activate-latest-baseline": + maintenance_state_input = _load_secret( + arguments.maintenance_state_input_env, + MAINTENANCE_STATE_INPUT_ENV, + _MAINTENANCE_STATE_PURPOSE, + environment, + ) + protected_values = (maintenance_state_input,) + finally: + environment = _scrub_protected_input_environment(environment) + backend = operations or _load_backend(environment) + if arguments.mode == "preflight-only": + outcome = preflight( + arguments.bundle, + arguments.expected_predecessor_pointer_digest, + backend, + ) + _write_canonical(arguments.out, outcome.artifact) + return 0 if outcome.passed else 1 + if arguments.mode == "publish-or-verify-exact": + active_source_ref_revalidator = ( + source_ref_revalidator + or _make_remote_source_ref_revalidator( + arguments.expected_source_repository, + arguments.expected_source_branch, + arguments.expected_source_commit, + arguments.github_token_env, + environment, + ) + ) + assert publication_inputs is not None + outcome = publish_or_verify_exact( + arguments.bundle, + arguments.expected_predecessor_pointer_digest, + backend, + publication_inputs, + active_source_ref_revalidator, + arguments.receipt, + arguments.core_update_receipt, + ) + elif arguments.mode == "verify-public-state-only": + outcome = verify_public_state( + arguments.bundle, + backend, + arguments.receipt, + arguments.core_update_receipt, + arguments.successor_baseline, + arguments.history_entry, + ) + else: + active_source_ref_revalidator = ( + source_ref_revalidator + or _make_remote_source_ref_revalidator( + arguments.expected_source_repository, + arguments.expected_source_branch, + arguments.expected_source_commit, + arguments.github_token_env, + environment, + ) + ) + assert maintenance_state_input is not None + outcome = activate_latest_baseline( + arguments.successor_baseline, + arguments.history_entry, + arguments.publication_receipt, + arguments.authorization, + arguments.activation_authorization, + arguments.expected_current_pointer_digest, + backend, + maintenance_state_input, + active_source_ref_revalidator, + arguments.activation_receipt, + ) + for path, value in outcome.artifacts.items(): + _write_canonical(path, value, protected_values) + return 0 if outcome.passed else 1 + except AdapterError as exc: + print( + f"stable maintenance publication adapter failed closed: {exc.code}", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release-certification/publication-backend/README.md b/tools/release-certification/publication-backend/README.md new file mode 100644 index 00000000000..e062b5e982f --- /dev/null +++ b/tools/release-certification/publication-backend/README.md @@ -0,0 +1,99 @@ +# Stable maintenance publication backend + +This directory contains the dependency-free provider used by the protected Stable 1.0 +maintenance workflow. It is the concrete implementation behind +`cryptad_stable_maintenance_backend:factory`; it is not loaded from the candidate checkout. + +The canonical producer is +`.github/workflows/stable-1.0-maintenance-publication-backend-producer.yml`. Dispatch it for the +current reviewed `main` commit. The workflow builds the wheel twice, requires byte-identical +results, checks the closed wheel layout and provider protocol, attests the one wheel, and uploads +the fixed `stable-1.0-maintenance-publication-backend` artifact. Release environments then pin the +source commit, wheel SHA-256, canonical signer workflow, and canonical entrypoint. A release +dispatch additionally supplies the exact producer run id and Actions artifact digest. The setup +action verifies all six bindings and the GitHub attestation before installing the wheel without +dependency resolution. + +Configure these immutable public-safe identity pins where the evidence verifier and both +publication environments can read the same values: + +- `CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SOURCE_COMMIT`; +- `CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_WHEEL_SHA256`; +- `CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND_SIGNER_WORKFLOW`; +- `CRYPTAD_STABLE_MAINTENANCE_PUBLICATION_BACKEND`, fixed to + `cryptad_stable_maintenance_backend:factory`. + +Each `publish` dispatch separately supplies the producer run id, fixed artifact name, and Actions +artifact digest. Keep the catalog, CoreUpdater, and latest-pointer capability values in their +purpose-specific protected environments; they are not provider identity configuration and must not +be visible while the wheel is imported or while an unrelated target is observed or mutated. + +## Protected deployment-service protocol + +GitHub annotated tags, Releases, and assets are observed and mutated directly through the GitHub +API. Public artifact-base objects are streamed and checked against the exact size and digest in the +authorized plan before the first GitHub mutation. The artifact base is a verify-only target for +this provider: an independent deployment step must pre-stage every planned object, and an absent, +partial, or mismatched artifact base fails preflight. The first mutation-safe state is therefore an +exactly matching artifact base followed by an absent tag, Release, assets, catalog, and CoreUpdater +suffix; the adapter treats that state as a resumable matching prefix and begins with the tag. +After the Release is created, an empty asset list remains the absent `assets` target. If an upload +is interrupted, the provider streams and digest-verifies every existing planned asset, skips those +exact rows, and uploads only missing rows in deterministic plan order. The target becomes matching +only when the complete planned set is exact. Any unexpected, duplicate, malformed, size-mismatched, +or digest-mismatched row is a conflict; recovery never deletes or overwrites an asset. +The authorization plan must use the canonical +`https://github.com/crypta-network/cryptad/releases/tag/v` page. Release observation binds +that page, the integer-build tag, target commit, deterministic `Cryptad v` title, exact note +bytes, draft/prerelease flags, and assets before treating existing state as idempotent. +Catalog, CoreUpdater, and latest-baseline state +are handled by a deployment service whose public observation endpoint is the exact +`deploymentServicePublicUri` in the publication plan. The plan also carries the exact catalog +primary, signature, mirror, rollback, CoreUpdater, GitHub Release, artifact-base, and latest-pointer +URIs. These fields are schema closed, part of `publicationTargetsDigest`, repeated in the verified +maintenance receipt where needed for activation, and compared byte-for-byte with the authorized +plan. The service endpoint follows the certification URI contract: an HTTPS authority root may be +spelled with or without its trailing slash, and a non-root endpoint may end in one slash. The +provider preserves that authorized spelling while rejecting internal empty path segments and dot +segments. + +Requests and responses use canonical UTF-8 JSON with sorted keys, two-space indentation, one final +newline, no duplicate keys, and `schemaVersion: 1`. The public endpoint supports these +side-effect-free operations: + +- `observe-publication`, returning predecessor/candidate identities and exact catalog/CoreUpdater + target statuses; +- `verify-publication`, returning the complete maintenance receipt, CoreUpdater receipt, successor + baseline, and history entry for adapter validation; +- `observe-latest-pointer`, returning the exact active pointer digest and baseline identity. + +The three protected environment values are purpose-specific capability URLs, not general account +credentials. The stable-catalog capability accepts only `publish-stable-catalog`, the CoreUpdater +capability only `publish-core-update`, and the maintenance-state capability only +`activate-latest-pointer`. Mutation responses are closed and must report either an exact idempotent +match or the exact newly created state. Pointer activation is compare-and-swap against the +authorized predecessor pointer digest. A conflict, unavailable observation, redirect chain, +non-global address, malformed response, or byte mismatch fails closed and is never overwritten. + +Capability URLs are materialized only for their matching call. The adapter removes them from the +environment before importing the wheel, passes an opaque purpose-bound object to the target call, +then drops the reference. They must never be placed in manifests, logs, artifacts, job summaries, +or public receipts. + +`verify-publication` is self-contained. Its subject adds a closed `verificationInputs` map whose +required entries are the publication plan, candidate, candidate input, lineage, CoreUpdater plan +and descriptor, GA baseline, predecessor baseline, evidence summary, and provenance. The current +hotfix follow-up obligation and authenticated closure overlay are nullable entries. Every present +entry has exactly `digest` and `record`: `digest` is the SHA-256 of the canonical physical JSON +bytes, and `record` is the parsed value of those same bytes. Before sending the request, the +provider cross-checks the bindings already frozen into the candidate, lineage, authorization, +publication plan, and provenance. This gives the deployment verifier all data needed to construct +the deterministic receipts, successor baseline, and history entry without undocumented state. +Observation and mutation requests do not receive this expanded record set. + +All verification records have already passed the adapter's schema, canonical-JSON, path +confinement, and public-value/redaction checks. The subject must never contain target capability +URLs, tokens, private insert material, raw content or app data, identity material, or local paths. +The deployment service must treat the input digests as construction bindings rather than producer +authentication: the independently running adapter remains authoritative and rejects every returned +record that does not match its own authenticated bundle. diff --git a/tools/release-certification/publication-backend/build_wheel.py b/tools/release-certification/publication-backend/build_wheel.py new file mode 100644 index 00000000000..26a778735d2 --- /dev/null +++ b/tools/release-certification/publication-backend/build_wheel.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Build the dependency-free Stable maintenance provider wheel deterministically.""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import hashlib +import io +from pathlib import Path +import zipfile + + +NAME = "cryptad_stable_maintenance_backend" +VERSION = "1" +DIST_INFO = f"{NAME}-{VERSION}.dist-info" +ROOT = Path(__file__).resolve().parent +SOURCE = ROOT / "src" / NAME + + +def _record_digest(data: bytes) -> str: + encoded = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).decode("ascii") + return "sha256=" + encoded.rstrip("=") + + +def _metadata() -> dict[str, bytes]: + return { + f"{DIST_INFO}/METADATA": ( + "Metadata-Version: 2.3\n" + "Name: cryptad-stable-maintenance-backend\n" + f"Version: {VERSION}\n" + "Summary: Protected Stable maintenance publication provider\n" + "Requires-Python: >=3.12\n\n" + ).encode("utf-8"), + f"{DIST_INFO}/WHEEL": ( + "Wheel-Version: 1.0\n" + "Generator: cryptad-deterministic-wheel\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n\n" + ).encode("utf-8"), + f"{DIST_INFO}/top_level.txt": (NAME + "\n").encode("utf-8"), + } + + +def build(output: Path) -> Path: + files = { + f"{NAME}/__init__.py": (SOURCE / "__init__.py").read_bytes(), + f"{NAME}/provider.py": (SOURCE / "provider.py").read_bytes(), + **_metadata(), + } + record_path = f"{DIST_INFO}/RECORD" + rows = [ + (path, _record_digest(data), str(len(data))) + for path, data in sorted(files.items()) + ] + rows.append((record_path, "", "")) + record = io.StringIO(newline="") + csv.writer(record, lineterminator="\n").writerows(rows) + files[record_path] = record.getvalue().encode("utf-8") + + output.mkdir(parents=True, exist_ok=True) + wheel = output / f"{NAME}-{VERSION}-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for path, data in sorted(files.items()): + info = zipfile.ZipInfo(path, (1980, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + return wheel + + +def main() -> int: + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--out", required=True, type=Path) + arguments = parser.parse_args() + print(build(arguments.out)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release-certification/publication-backend/src/cryptad_stable_maintenance_backend/__init__.py b/tools/release-certification/publication-backend/src/cryptad_stable_maintenance_backend/__init__.py new file mode 100644 index 00000000000..612c855b941 --- /dev/null +++ b/tools/release-certification/publication-backend/src/cryptad_stable_maintenance_backend/__init__.py @@ -0,0 +1,5 @@ +"""Authenticated Stable 1.0 maintenance publication provider.""" + +from .provider import StableMaintenanceBackend, create_backend, factory + +__all__ = ["StableMaintenanceBackend", "create_backend", "factory"] diff --git a/tools/release-certification/publication-backend/src/cryptad_stable_maintenance_backend/provider.py b/tools/release-certification/publication-backend/src/cryptad_stable_maintenance_backend/provider.py new file mode 100644 index 00000000000..7facb4b473e --- /dev/null +++ b/tools/release-certification/publication-backend/src/cryptad_stable_maintenance_backend/provider.py @@ -0,0 +1,894 @@ +"""Standard-library protected publication provider for Stable maintenance releases. + +GitHub tag, Release, and asset operations are performed directly. Catalog, CoreUpdater, and +latest-pointer infrastructure use the versioned deployment-service protocol documented beside the +release-certification tooling. Target capability URLs are accepted only through the adapter's +purpose-bound ``SecretMaterial`` argument and are never retained or rendered. +""" + +from __future__ import annotations + +import base64 +import dataclasses +import hashlib +import http.client +import ipaddress +import json +import mimetypes +import os +from pathlib import Path +import socket +import ssl +from typing import Any, Mapping +from urllib.parse import quote, urlencode, urlsplit + + +REPOSITORY = "crypta-network/cryptad" +API_ROOT = "https://api.github.com" +UPLOAD_ROOT = "https://uploads.github.com" +MAX_RESPONSE_BYTES = 32 * 1024 * 1024 +TARGETS = ( + "artifactBase", + "tag", + "githubRelease", + "assets", + "stableCatalog", + "coreUpdate", +) +STATUSES = frozenset({"absent", "matching", "conflict", "unavailable"}) + + +class ProviderError(RuntimeError): + """One public-safe fail-closed provider failure.""" + + +@dataclasses.dataclass(frozen=True) +class Snapshot: + predecessor_pointer_digest: str | None + targets: Mapping[str, str] + latest_candidate_identity_digest: str | None = None + + +@dataclasses.dataclass(frozen=True) +class PointerSnapshot: + status: str + pointer_digest: str | None + active_baseline_digest: str | None + candidate_identity_digest: str | None = None + + +@dataclasses.dataclass(frozen=True) +class VerificationMaterial: + maintenance_receipt: Mapping[str, Any] + core_update_receipt: Mapping[str, Any] + successor_baseline: Mapping[str, Any] + history_entry: Mapping[str, Any] + + +def _canonical_bytes(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n" + + +def _digest(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def _strict_json(data: bytes, *, canonical: bool) -> dict[str, Any]: + def no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProviderError("deployment-response-duplicate-field") + result[key] = value + return result + + try: + value = json.loads(data.decode("utf-8"), object_pairs_hook=no_duplicates) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ProviderError("deployment-response-malformed") from exc + if not isinstance(value, dict): + raise ProviderError("deployment-response-not-object") + if canonical and data != _canonical_bytes(value): + raise ProviderError("deployment-response-not-canonical") + return value + + +def _canonical_https_uri(value: Any) -> str: + if not isinstance(value, str) or value != value.strip() or "\\" in value: + raise ProviderError("public-uri-invalid") + parsed = urlsplit(value) + try: + port = parsed.port or 443 + except ValueError as exc: + raise ProviderError("public-uri-invalid") from exc + path_parts = parsed.path.split("/")[1:] + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or port != 443 + or any(part in {".", ".."} for part in path_parts) + or any(not part for part in path_parts[:-1]) + ): + raise ProviderError("public-uri-invalid") + host = parsed.hostname.rstrip(".").encode("idna").decode("ascii").lower() + authority = host if ":" not in host else f"[{host}]" + canonical = f"https://{authority}{parsed.path}" + if parsed.query: + canonical += "?" + parsed.query + if canonical != value: + raise ProviderError("public-uri-not-canonical") + return canonical + + +def _global_addresses(host: str, port: int) -> tuple[str, ...]: + try: + rows = socket.getaddrinfo( + host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP + ) + except OSError as exc: + raise ProviderError("public-host-resolution-failed") from exc + addresses: list[str] = [] + for row in rows: + address = str(ipaddress.ip_address(row[4][0])) + if not ipaddress.ip_address(address).is_global: + raise ProviderError("public-host-resolution-not-global") + if address not in addresses: + addresses.append(address) + if not addresses: + raise ProviderError("public-host-resolution-empty") + return tuple(addresses) + + +class _PinnedHTTPSConnection(http.client.HTTPSConnection): + def __init__(self, hostname: str, address: str, port: int, timeout: float) -> None: + super().__init__(hostname, port=port, timeout=timeout, context=ssl.create_default_context()) + self._address = address + + def connect(self) -> None: + raw = socket.create_connection((self._address, self.port), self.timeout) + try: + peer = ipaddress.ip_address(raw.getpeername()[0]) + if str(peer) != str(ipaddress.ip_address(self._address)) or not peer.is_global: + raise OSError("connected peer differs from pinned public address") + self.sock = self._context.wrap_socket(raw, server_hostname=self.host) + except BaseException: + raw.close() + raise + + +class StdlibTransport: + """HTTPS transport with global-address pinning, TLS hostname checks, and no redirects.""" + + def request( + self, + method: str, + uri: str, + *, + headers: Mapping[str, str] | None = None, + body: bytes | None = None, + ) -> tuple[int, Mapping[str, str], bytes]: + canonical = _canonical_https_uri(uri) + parsed = urlsplit(canonical) + host = str(parsed.hostname) + port = parsed.port or 443 + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + failures: list[BaseException] = [] + for address in _global_addresses(host, port): + connection = _PinnedHTTPSConnection(host, address, port, 30.0) + try: + connection.request(method, path, body=body, headers=dict(headers or {})) + response = connection.getresponse() + if 300 <= response.status < 400: + raise ProviderError("http-redirect-forbidden") + length = response.getheader("Content-Length") + if length is not None and int(length) > MAX_RESPONSE_BYTES: + raise ProviderError("http-response-too-large") + data = response.read(MAX_RESPONSE_BYTES + 1) + if len(data) > MAX_RESPONSE_BYTES: + raise ProviderError("http-response-too-large") + return ( + response.status, + {key.lower(): value for key, value in response.getheaders()}, + data, + ) + except ProviderError: + raise + except (OSError, http.client.HTTPException, ValueError) as exc: + failures.append(exc) + finally: + connection.close() + raise ProviderError("https-request-unavailable") from ( + failures[-1] if failures else None + ) + + def digest( + self, + uri: str, + expected_size: int, + *, + headers: Mapping[str, str] | None = None, + redirect_budget: int = 1, + ) -> tuple[int, int, str | None]: + """Stream one exact public object without materializing release-sized bytes.""" + + if not isinstance(expected_size, int) or expected_size < 1: + raise ProviderError("download-expected-size-invalid") + canonical = _canonical_https_uri(uri) + parsed = urlsplit(canonical) + host = str(parsed.hostname) + port = parsed.port or 443 + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + failures: list[BaseException] = [] + for address in _global_addresses(host, port): + connection = _PinnedHTTPSConnection(host, address, port, 30.0) + try: + connection.request("GET", path, headers=dict(headers or {})) + response = connection.getresponse() + if 300 <= response.status < 400: + if redirect_budget != 1: + raise ProviderError("download-redirect-forbidden") + location = response.getheader("Location") + if not isinstance(location, str): + raise ProviderError("download-redirect-invalid") + redirected = _canonical_https_uri(location) + safe_headers = { + key: value + for key, value in dict(headers or {}).items() + if key.lower() != "authorization" + } + return self.digest( + redirected, + expected_size, + headers=safe_headers, + redirect_budget=0, + ) + if response.status != 200: + response.read(min(MAX_RESPONSE_BYTES, 64 * 1024)) + return response.status, 0, None + length = response.getheader("Content-Length") + if length is not None and int(length) != expected_size: + return response.status, int(length), None + digest = hashlib.sha256() + size = 0 + while True: + chunk = response.read(min(1024 * 1024, expected_size + 1 - size)) + if not chunk: + break + size += len(chunk) + if size > expected_size: + return response.status, size, None + digest.update(chunk) + return response.status, size, "sha256:" + digest.hexdigest() + except ProviderError: + raise + except (OSError, http.client.HTTPException, ValueError) as exc: + failures.append(exc) + finally: + connection.close() + raise ProviderError("https-download-unavailable") from ( + failures[-1] if failures else None + ) + + +class StableMaintenanceBackend: + """Concrete exact-byte provider used only by the protected publication adapter.""" + + def __init__(self, token: str, transport: Any | None = None) -> None: + if len(token) < 12 or "\r" in token or "\n" in token: + raise ProviderError("github-token-not-materialized") + self._token = token + self._transport = transport or StdlibTransport() + + def _github( + self, + method: str, + path: str, + *, + body: bytes | None = None, + content_type: str = "application/json", + ) -> tuple[int, Mapping[str, str], bytes]: + headers = { + "Accept": "application/vnd.github+json", + "Authorization": "Bearer " + self._token, + "Content-Type": content_type, + "User-Agent": "cryptad-stable-maintenance-backend/1", + "X-GitHub-Api-Version": "2022-11-28", + } + return self._transport.request(method, API_ROOT + path, headers=headers, body=body) + + def _github_json( + self, method: str, path: str, value: Any | None = None + ) -> tuple[int, dict[str, Any]]: + body = _canonical_bytes(value) if value is not None else None + status, _headers, data = self._github(method, path, body=body) + if status == 404: + return status, {} + if status < 200 or status >= 300: + raise ProviderError("github-api-operation-failed") + return status, _strict_json(data, canonical=False) + + @staticmethod + def _tag_message(request: Any) -> str: + return ( + f"Cryptad Stable 1.0 {request.release_class} build " + f"{request.build_version}" + ) + + @staticmethod + def _release_title(request: Any) -> str: + return f"Cryptad {request.bundle.plan['expectedTag']}" + + def _tag_status(self, request: Any) -> str: + encoded = quote(str(request.bundle.plan["expectedTag"]), safe="") + status, reference = self._github_json( + "GET", f"/repos/{REPOSITORY}/git/ref/tags/{encoded}" + ) + if status == 404: + return "absent" + obj = reference.get("object") + if not isinstance(obj, Mapping) or obj.get("type") != "tag": + return "conflict" + _status, tag = self._github_json( + "GET", f"/repos/{REPOSITORY}/git/tags/{obj.get('sha')}" + ) + target = tag.get("object") + return ( + "matching" + if tag.get("tag") == request.bundle.plan.get("expectedTag") + and tag.get("message") == self._tag_message(request) + and isinstance(target, Mapping) + and target.get("type") == "commit" + and target.get("sha") == request.bundle.plan.get("sourceCommit") + else "conflict" + ) + + def _release(self, request: Any) -> tuple[str, dict[str, Any]]: + encoded = quote(str(request.bundle.plan["expectedTag"]), safe="") + status, release = self._github_json( + "GET", f"/repos/{REPOSITORY}/releases/tags/{encoded}" + ) + if status == 404: + return "absent", {} + notes = (request.bundle.legacy / "stable-1.0-maintenance-release-notes.md").read_text( + encoding="utf-8" + ) + expected_page = request.bundle.plan.get("githubReleasePageUri") + matching = ( + release.get("tag_name") == request.bundle.plan.get("expectedTag") + and release.get("target_commitish") == request.bundle.plan.get("sourceCommit") + and release.get("name") == self._release_title(request) + and release.get("html_url") == expected_page + and release.get("body") == notes + and release.get("draft") is False + and release.get("prerelease") is False + ) + return ("matching" if matching else "conflict"), release + + def _observed_assets( + self, request: Any, release: Mapping[str, Any] + ) -> tuple[str, frozenset[str]]: + """Authenticate the exact uploaded subset while preserving target-level state.""" + + if not release: + return "absent", frozenset() + rows = release.get("assets") + if not isinstance(rows, list): + return "conflict", frozenset() + observed_names = [ + row.get("name") for row in rows if isinstance(row, Mapping) + ] + planned_rows = request.bundle.plan.get("assets", []) + planned_names = [ + row.get("fileName") for row in planned_rows if isinstance(row, Mapping) + ] + if ( + len(observed_names) != len(rows) + or len(set(observed_names)) != len(observed_names) + or len(planned_names) != len(planned_rows) + or len(set(planned_names)) != len(planned_names) + ): + return "conflict", frozenset() + observed = {row.get("name"): row for row in rows} + planned = {row.get("fileName"): row for row in planned_rows} + if not set(observed).issubset(planned): + return "conflict", frozenset() + for name, row in observed.items(): + expected = planned[name] + asset_id = row.get("id") + if ( + row.get("size") != expected.get("sizeBytes") + or type(asset_id) is not int + or asset_id < 1 + ): + return "conflict", frozenset() + status, size, digest = self._transport.digest( + f"{API_ROOT}/repos/{REPOSITORY}/releases/assets/{asset_id}", + int(expected["sizeBytes"]), + headers={ + "Accept": "application/octet-stream", + "Authorization": "Bearer " + self._token, + "User-Agent": "cryptad-stable-maintenance-backend/1", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + if ( + status != 200 + or size != expected.get("sizeBytes") + or digest != expected.get("digest") + ): + return "conflict", frozenset() + observed_set = frozenset(str(name) for name in observed) + return ( + "matching" if set(observed) == set(planned) else "absent", + observed_set, + ) + + def _assets_status(self, request: Any, release: Mapping[str, Any]) -> str: + status, _observed = self._observed_assets(request, release) + return status + + def _artifact_status(self, request: Any) -> str: + statuses: list[str] = [] + for row in request.bundle.plan.get("assets", []): + status, size, digest = self._transport.digest( + str(row.get("publicUri")), + int(row.get("sizeBytes")), + headers={"User-Agent": "cryptad-stable-maintenance-backend/1"}, + ) + if status == 404: + statuses.append("absent") + elif ( + status == 200 + and size == row.get("sizeBytes") + and digest == row.get("digest") + ): + statuses.append("matching") + else: + statuses.append("conflict") + if statuses and all(value == "absent" for value in statuses): + return "absent" + if statuses and all(value == "matching" for value in statuses): + return "matching" + return "conflict" + + @staticmethod + def _service_subject(request: Any) -> dict[str, Any]: + return { + "releaseId": request.release_id, + "buildVersion": request.build_version, + "releaseClass": request.release_class, + "candidateIdentityDigest": request.candidate_identity_digest, + "publicationPlan": dict(request.bundle.plan), + "coreInfo": dict(request.bundle.core_info), + "lineage": dict(request.bundle.lineage), + } + + @staticmethod + def _record_binding( + path: Path, + record: Mapping[str, Any], + *, + expected_digest: Any = None, + ) -> dict[str, Any]: + try: + data = path.read_bytes() + except OSError as exc: + raise ProviderError("verification-input-unavailable") from exc + if data != _canonical_bytes(record): + raise ProviderError("verification-input-not-canonical") + digest = _digest(data) + if expected_digest is not None and expected_digest != digest: + raise ProviderError("verification-input-digest-mismatch") + return {"digest": digest, "record": dict(record)} + + @classmethod + def _file_record_binding( + cls, + path: Path, + *, + expected_digest: Any = None, + ) -> dict[str, Any]: + try: + data = path.read_bytes() + except OSError as exc: + raise ProviderError("verification-input-unavailable") from exc + record = _strict_json(data, canonical=True) + digest = _digest(data) + if expected_digest is not None and expected_digest != digest: + raise ProviderError("verification-input-digest-mismatch") + return {"digest": digest, "record": record} + + @classmethod + def _verification_subject(cls, request: Any) -> dict[str, Any]: + """Return the public-safe records needed to reproduce successor state exactly.""" + + bundle = request.bundle + provenance_path = bundle.legacy / "stable-1.0-maintenance-provenance.json" + provenance = cls._file_record_binding( + provenance_path, + expected_digest=bundle.plan.get("provenanceDigest"), + ) + provenance_record = provenance["record"] + closure_expected_digest = bundle.lineage.get("predecessor", {}).get( + "hotfixFollowUpClosureDigest" + ) + if (bundle.follow_up_closure_path is None) != ( + bundle.follow_up_closure is None + ): + raise ProviderError("verification-input-incomplete") + if bundle.follow_up_closure_path is None: + if closure_expected_digest is not None: + raise ProviderError("verification-input-unavailable") + closure_binding = None + else: + closure_binding = cls._record_binding( + bundle.follow_up_closure_path, + bundle.follow_up_closure, + expected_digest=closure_expected_digest, + ) + if (bundle.follow_up_obligation_path is None) != ( + bundle.follow_up_obligation is None + ): + raise ProviderError("verification-input-incomplete") + obligation_expected_digest = bundle.authorization.get( + "hotfixFollowUpObligationDigest" + ) + if bundle.follow_up_obligation_path is None: + if obligation_expected_digest is not None: + raise ProviderError("verification-input-unavailable") + obligation_binding = None + else: + obligation_binding = cls._record_binding( + bundle.follow_up_obligation_path, + bundle.follow_up_obligation, + expected_digest=obligation_expected_digest, + ) + records: dict[str, Any] = { + "publicationPlan": cls._record_binding(bundle.plan_path, bundle.plan), + "candidate": cls._record_binding(bundle.candidate_path, bundle.candidate), + "candidateInput": cls._record_binding( + bundle.candidate_input_path, + bundle.candidate_input, + expected_digest=bundle.candidate.get("candidateInputDigest"), + ), + "lineage": cls._record_binding( + bundle.lineage_path, + bundle.lineage, + expected_digest=provenance_record.get("lineageDigest"), + ), + "corePublicationPlan": cls._record_binding( + bundle.core_plan_path, + bundle.core_plan, + ), + "coreInfo": cls._record_binding( + bundle.core_info_path, + bundle.core_info, + expected_digest=bundle.plan.get("coreInfoDigest"), + ), + "gaBaseline": cls._record_binding( + bundle.ga_baseline_path, + bundle.ga_baseline, + expected_digest=bundle.lineage.get("gaRoot", {}).get( + "maintenanceBaselineDigest" + ), + ), + "predecessorBaseline": cls._record_binding( + bundle.predecessor_baseline_path, + bundle.predecessor_baseline, + expected_digest=provenance_record.get("predecessorBaselineDigest"), + ), + "evidence": cls._record_binding( + bundle.evidence_path, + bundle.evidence, + expected_digest=provenance_record.get("evidenceDigest"), + ), + "provenance": provenance, + "hotfixFollowUpObligation": obligation_binding, + "hotfixFollowUpClosure": closure_binding, + } + subject = cls._service_subject(request) + subject["verificationInputs"] = records + return subject + + def _service(self, uri: str, operation: str, subject: Mapping[str, Any]) -> dict[str, Any]: + request = { + "schemaVersion": 1, + "kind": "cryptad-stable-maintenance-deployment-request", + "operation": operation, + "subject": dict(subject), + } + status, _headers, data = self._transport.request( + "POST", + uri, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "cryptad-stable-maintenance-backend/1", + }, + body=_canonical_bytes(request), + ) + if status != 200: + raise ProviderError("deployment-service-operation-failed") + return _strict_json(data, canonical=True) + + def _deployment_observation(self, request: Any) -> dict[str, Any]: + response = self._service( + str(request.bundle.plan["deploymentServicePublicUri"]), + "observe-publication", + self._service_subject(request), + ) + expected_keys = { + "schemaVersion", + "kind", + "predecessorPointerDigest", + "latestCandidateIdentityDigest", + "targets", + } + targets = response.get("targets") + if ( + set(response) != expected_keys + or response.get("schemaVersion") != 1 + or response.get("kind") + != "cryptad-stable-maintenance-deployment-observation" + or not isinstance(targets, Mapping) + or set(targets) != {"stableCatalog", "coreUpdate"} + or any(value not in STATUSES for value in targets.values()) + ): + raise ProviderError("deployment-observation-invalid") + return response + + def observe_public_state(self, request: Any) -> Snapshot: + tag = self._tag_status(request) + release_status, release = self._release(request) + assets = self._assets_status(request, release) + artifact_base = self._artifact_status(request) + deployment = self._deployment_observation(request) + remote_targets = deployment["targets"] + return Snapshot( + deployment.get("predecessorPointerDigest"), + { + "artifactBase": artifact_base, + "tag": tag, + "githubRelease": release_status, + "assets": assets, + "stableCatalog": remote_targets["stableCatalog"], + "coreUpdate": remote_targets["coreUpdate"], + }, + deployment.get("latestCandidateIdentityDigest"), + ) + + def _create_tag(self, request: Any) -> None: + _status, tag = self._github_json( + "POST", + f"/repos/{REPOSITORY}/git/tags", + { + "tag": request.bundle.plan["expectedTag"], + "message": self._tag_message(request), + "object": request.bundle.plan["sourceCommit"], + "type": "commit", + }, + ) + sha = tag.get("sha") + if not isinstance(sha, str): + raise ProviderError("github-tag-object-not-created") + self._github_json( + "POST", + f"/repos/{REPOSITORY}/git/refs", + {"ref": f"refs/tags/{request.bundle.plan['expectedTag']}", "sha": sha}, + ) + + def _create_release(self, request: Any) -> None: + notes = (request.bundle.legacy / "stable-1.0-maintenance-release-notes.md").read_text( + encoding="utf-8" + ) + self._github_json( + "POST", + f"/repos/{REPOSITORY}/releases", + { + "tag_name": request.bundle.plan["expectedTag"], + "target_commitish": request.bundle.plan["sourceCommit"], + "name": self._release_title(request), + "body": notes, + "draft": False, + "prerelease": False, + }, + ) + + def _upload_assets(self, request: Any) -> None: + status, release = self._release(request) + if status != "matching" or not isinstance(release.get("id"), int): + raise ProviderError("github-release-not-ready-for-assets") + asset_status, observed_names = self._observed_assets(request, release) + if asset_status == "conflict": + raise ProviderError("github-release-assets-conflict") + for row in request.bundle.plan.get("assets", []): + name = str(row["fileName"]) + if name in observed_names: + continue + path = request.bundle.legacy / name + data = path.read_bytes() + if len(data) != row.get("sizeBytes") or _digest(data) != row.get("digest"): + raise ProviderError("asset-byte-binding-changed") + uri = ( + f"{UPLOAD_ROOT}/repos/{REPOSITORY}/releases/{release['id']}/assets?" + + urlencode({"name": name}) + ) + status_code, _headers, _body = self._transport.request( + "POST", + uri, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": "Bearer " + self._token, + "Content-Type": mimetypes.guess_type(name)[0] + or "application/octet-stream", + "User-Agent": "cryptad-stable-maintenance-backend/1", + "X-GitHub-Api-Version": "2022-11-28", + }, + body=data, + ) + if status_code not in {200, 201}: + raise ProviderError("github-asset-upload-failed") + + def _capability_mutation( + self, target: str, request: Any, protected_input: Any + ) -> None: + expected_purpose = { + "stableCatalog": "stable-catalog", + "coreUpdate": "core-update", + }[target] + if protected_input is None or protected_input.purpose != expected_purpose: + raise ProviderError("target-capability-purpose-mismatch") + payload: dict[str, Any] = self._service_subject(request) + if target == "stableCatalog": + catalog = request.bundle.candidate_input["stableCatalog"] + payload["catalogBytes"] = base64.b64encode( + (request.bundle.legacy / catalog["fileName"]).read_bytes() + ).decode("ascii") + payload["signatureBytes"] = base64.b64encode( + (request.bundle.legacy / catalog["signatureFileName"]).read_bytes() + ).decode("ascii") + else: + payload["descriptorBytes"] = base64.b64encode( + request.bundle.core_info_path.read_bytes() + ).decode("ascii") + response = self._service( + protected_input.value, + "publish-" + ("stable-catalog" if target == "stableCatalog" else "core-update"), + payload, + ) + if ( + set(response) + != {"schemaVersion", "kind", "target", "candidateIdentityDigest", "status"} + or response.get("schemaVersion") != 1 + or response.get("kind") != "cryptad-stable-maintenance-deployment-mutation" + or response.get("target") != target + or response.get("candidateIdentityDigest") != request.candidate_identity_digest + or response.get("status") not in {"created", "matching"} + ): + raise ProviderError("deployment-mutation-result-invalid") + + def publish_target(self, target: str, request: Any, protected_input: Any) -> None: + if target not in TARGETS: + raise ProviderError("unknown-publication-target") + if target in {"tag", "githubRelease", "assets", "artifactBase"} and protected_input is not None: + raise ProviderError("untargeted-protected-input") + if target == "tag": + self._create_tag(request) + elif target == "githubRelease": + self._create_release(request) + elif target == "assets": + self._upload_assets(request) + elif target == "artifactBase": + if self._artifact_status(request) != "matching": + raise ProviderError("artifact-base-not-backed-by-exact-assets") + else: + self._capability_mutation(target, request, protected_input) + + def verify_publication(self, request: Any) -> VerificationMaterial: + response = self._service( + str(request.bundle.plan["deploymentServicePublicUri"]), + "verify-publication", + self._verification_subject(request), + ) + expected = { + "schemaVersion", + "kind", + "maintenanceReceipt", + "coreUpdateReceipt", + "successorBaseline", + "historyEntry", + } + if ( + set(response) != expected + or response.get("schemaVersion") != 1 + or response.get("kind") != "cryptad-stable-maintenance-deployment-verification" + or any( + not isinstance(response.get(key), Mapping) + for key in expected - {"schemaVersion", "kind"} + ) + ): + raise ProviderError("deployment-verification-invalid") + return VerificationMaterial( + response["maintenanceReceipt"], + response["coreUpdateReceipt"], + response["successorBaseline"], + response["historyEntry"], + ) + + def observe_latest_pointer(self, request: Any) -> PointerSnapshot: + subject = { + "releaseId": request.receipt.get("releaseId"), + "candidateIdentityDigest": request.receipt.get("candidateIdentityDigest"), + "latestPointerPublicUri": request.receipt.get("latestPointerPublicUri"), + } + response = self._service( + str(request.receipt["deploymentServicePublicUri"]), + "observe-latest-pointer", + subject, + ) + expected = { + "schemaVersion", + "kind", + "status", + "pointerDigest", + "activeBaselineDigest", + "candidateIdentityDigest", + } + if ( + set(response) != expected + or response.get("schemaVersion") != 1 + or response.get("kind") != "cryptad-stable-maintenance-pointer-observation" + or response.get("status") not in {"observed", "unavailable"} + ): + raise ProviderError("pointer-observation-invalid") + return PointerSnapshot( + response["status"], + response["pointerDigest"], + response["activeBaselineDigest"], + response["candidateIdentityDigest"], + ) + + def activate_latest(self, request: Any, protected_input: Any) -> None: + if protected_input.purpose != "maintenance-state": + raise ProviderError("maintenance-state-capability-purpose-mismatch") + subject = { + "releaseId": request.receipt.get("releaseId"), + "candidateIdentityDigest": request.receipt.get("candidateIdentityDigest"), + "expectedPointerDigest": request.expected_pointer_digest, + "activatedPointerDigest": request.activated_pointer_digest, + "activatedPointerBytes": base64.b64encode( + request.activated_pointer_bytes + ).decode("ascii"), + "latestPointerPublicUri": request.receipt.get("latestPointerPublicUri"), + } + response = self._service( + protected_input.value, "activate-latest-pointer", subject + ) + if ( + set(response) + != {"schemaVersion", "kind", "status", "activatedPointerDigest"} + or response.get("schemaVersion") != 1 + or response.get("kind") != "cryptad-stable-maintenance-pointer-activation" + or response.get("status") not in {"activated", "matching"} + or response.get("activatedPointerDigest") + != request.activated_pointer_digest + ): + raise ProviderError("pointer-activation-result-invalid") + + +def factory() -> StableMaintenanceBackend: + """Create the production backend from the workflow's GitHub token only.""" + + if os.environ.get("GITHUB_REPOSITORY") != REPOSITORY: + raise ProviderError("publication-repository-mismatch") + return StableMaintenanceBackend(os.environ.get("GITHUB_TOKEN", "")) + + +create_backend = factory diff --git a/tools/release-certification/schemas/cryptad-core-info-v1.schema.json b/tools/release-certification/schemas/cryptad-core-info-v1.schema.json new file mode 100644 index 00000000000..58ad7b725b6 --- /dev/null +++ b/tools/release-certification/schemas/cryptad-core-info-v1.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/cryptad-core-info-v1.schema.json", + "title": "Cryptad deterministic CoreUpdater descriptor v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "release_page_url", "packages"], + "properties": { + "version": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "release_page_url": {"type": "string", "pattern": "^https://[^ ]+$"}, + "changelog_chk": {"$ref": "#/$defs/chk"}, + "fullchangelog_chk": {"$ref": "#/$defs/chk"}, + "packages": {"$ref": "#/$defs/packages"} + }, + "$defs": { + "chk": {"type": "string", "pattern": "^CHK@[A-Za-z0-9~-]{43},[A-Za-z0-9~-]{43},[A-Za-z0-9~-]{7}(/[A-Za-z0-9._~/-]+)?$"}, + "packages": { + "type": "object", + "additionalProperties": false, + "properties": { + "amd64.deb": {"$ref": "#/$defs/package"}, + "amd64.rpm": {"$ref": "#/$defs/package"}, + "amd64.dmg": {"$ref": "#/$defs/package"}, + "amd64.exe": {"$ref": "#/$defs/package"}, + "amd64.flatpak": {"$ref": "#/$defs/package"}, + "amd64.snap": {"$ref": "#/$defs/package"}, + "arm64.deb": {"$ref": "#/$defs/package"}, + "arm64.rpm": {"$ref": "#/$defs/package"}, + "arm64.dmg": {"$ref": "#/$defs/package"}, + "arm64.exe": {"$ref": "#/$defs/package"}, + "arm64.flatpak": {"$ref": "#/$defs/package"}, + "arm64.snap": {"$ref": "#/$defs/package"} + } + }, + "package": { + "type": "object", + "additionalProperties": false, + "required": ["size"], + "properties": { + "chk": {"$ref": "#/$defs/chk"}, + "size": {"type": "integer", "minimum": 1}, + "store_url": {"type": "string", "pattern": "^https://[^ ]+$"} + } + } + } +} diff --git a/tools/release-certification/schemas/cryptad-core-update-publication-plan-v1.schema.json b/tools/release-certification/schemas/cryptad-core-update-publication-plan-v1.schema.json new file mode 100644 index 00000000000..5ff3d447afe --- /dev/null +++ b/tools/release-certification/schemas/cryptad-core-update-publication-plan-v1.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/cryptad-core-update-publication-plan-v1.schema.json", + "title": "Cryptad CoreUpdater publication plan v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "candidateIdentityDigest", "descriptorDigest", "descriptorSizeBytes", "packageMapDigest", + "edition", "publicFetchUri", "protectedInsertInputName", "authorizationDigest", + "publicationTargetDigest", "preInsertionConflictStatus", "sideEffectsPerformed", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "cryptad-core-update-publication-plan"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "descriptorDigest": {"$ref": "#/$defs/digest"}, + "descriptorSizeBytes": {"type": "integer", "minimum": 1}, + "packageMapDigest": {"$ref": "#/$defs/digest"}, + "edition": {"type": "integer", "minimum": 1}, + "publicFetchUri": {"type": "string", "format": "uri"}, + "protectedInsertInputName": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]*$"}, + "authorizationDigest": {"$ref": "#/$defs/digest"}, + "publicationTargetDigest": {"$ref": "#/$defs/digest"}, + "preInsertionConflictStatus": {"const": "clear"}, + "sideEffectsPerformed": {"const": false}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/cryptad-core-update-publication-receipt-v1.schema.json b/tools/release-certification/schemas/cryptad-core-update-publication-receipt-v1.schema.json new file mode 100644 index 00000000000..315c280fe2c --- /dev/null +++ b/tools/release-certification/schemas/cryptad-core-update-publication-receipt-v1.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/cryptad-core-update-publication-receipt-v1.schema.json", + "title": "Cryptad CoreUpdater publication receipt v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "candidateIdentityDigest", "publicationPlanDigest", "descriptorDigest", "descriptorSizeBytes", + "packageMapDigest", "edition", "publicFetchUri", "operation", "fetchedDescriptorDigest", + "referencedPackages", "conflictStatus", "verificationStatus", "publicationState", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "cryptad-core-update-publication-receipt"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "publicationPlanDigest": {"$ref": "#/$defs/digest"}, + "descriptorDigest": {"$ref": "#/$defs/digest"}, + "descriptorSizeBytes": {"type": "integer", "minimum": 1}, + "packageMapDigest": {"$ref": "#/$defs/digest"}, + "edition": {"type": "integer", "minimum": 1}, + "publicFetchUri": {"type": "string", "format": "uri"}, + "operation": {"enum": ["created", "verified-existing", "partial"]}, + "fetchedDescriptorDigest": {"$ref": "#/$defs/digest"}, + "referencedPackages": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/packageObservation"}}, + "conflictStatus": {"enum": ["clear", "conflict"]}, + "verificationStatus": {"enum": ["pass", "fail", "unavailable"]}, + "publicationState": {"enum": ["publication-complete", "publication-partial", "publication-verification-failed"]}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "packageObservation": { + "type": "object", "additionalProperties": false, + "required": ["packageKey", "candidateAssetDigest", "candidateAssetSizeBytes", "publicReference", "verificationStatus"], + "properties": { + "packageKey": {"type": "string", "pattern": "^(amd64|arm64)\\.(deb|rpm|dmg|exe|flatpak|snap)$"}, + "candidateAssetDigest": {"$ref": "#/$defs/digest"}, + "candidateAssetSizeBytes": {"type": "integer", "minimum": 1}, + "publicReference": {"type": "string", "minLength": 1}, + "verificationStatus": {"enum": ["pass", "fail", "unavailable"]} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/release-run-v1.schema.json b/tools/release-certification/schemas/release-run-v1.schema.json index 692228d2d6f..8e0885c763a 100644 --- a/tools/release-certification/schemas/release-run-v1.schema.json +++ b/tools/release-certification/schemas/release-run-v1.schema.json @@ -66,6 +66,20 @@ "previousStableRcFreeze", "productionBeta", "publicBetaKnownIssues", + "coreUpdatePublicationReceipt", + "hotfixFollowUpEvidence", + "hotfixFollowUpClosure", + "hotfixFollowUpObligation", + "latestPublishedMaintenancePointer", + "maintenanceCandidate", + "maintenanceCandidateFreeze", + "maintenanceCandidateAssets", + "maintenanceCandidateChecksums", + "maintenanceCandidateProvenance", + "maintenanceEvidence", + "maintenancePolicy", + "predecessorBaseline", + "predecessorPublicationReceipt", "releaseCertification", "releaseHistory", "selectedStableRcArchive", @@ -79,9 +93,18 @@ "securityDrills", "stableCatalogOperations", "stableGaAuthorization", + "stableGaAuthorizationSummary", + "stableGaChecksums", + "stableGaMaintenanceBaseline", "stableGaPolicy", + "stableGaPromotionSummary", + "stableGaProvenance", + "stableGaPublicationPlan", "stableGaPublicationReceipt", + "stableGaValidation", "stableKnownLimitations", + "stableMaintenanceAuthorization", + "stableMaintenancePublicationReceipt", "stableRcFreezeExceptions", "stableRcValidation", "stableReadiness", @@ -102,6 +125,11 @@ "type": "string", "pattern": "^[0-9a-f]{40,64}$" }, + "candidateBaseCommit": { + "type": "string", + "pattern": "^[0-9a-f]{40,64}$" + }, + "candidateSourceBranch": {"type": "string", "minLength": 1}, "candidateSourceRef": {"type": "string", "minLength": 1}, "catalogChannel": {"enum": ["stable", "beta", "nightly", "deprecated"]}, "expectedPreviousReleaseId": {"type": "string", "minLength": 1}, @@ -109,11 +137,18 @@ "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "expectedPredecessorBuild": {"type": "string", "minLength": 1}, + "expectedPredecessorReleaseId": {"type": "string", "minLength": 1}, + "expectedPredecessorProductDigest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, "historyDir": {"type": "string", "minLength": 1}, "historyLabel": {"type": "string", "minLength": 1}, "publicationIntent": { "const": "prepare-explicit-protected-publication" }, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, "stableRcFreezeMode": {"enum": ["first-freeze", "refreeze"]}, "metadata": { "type": "object", @@ -156,6 +191,7 @@ "stable-readiness", "stable-rc", "stable-ga", + "stable-maintenance", "multi-node-beta", "security-response" ] diff --git a/tools/release-certification/schemas/stable-1.0-hotfix-follow-up-closure-v1.schema.json b/tools/release-certification/schemas/stable-1.0-hotfix-follow-up-closure-v1.schema.json new file mode 100644 index 00000000000..d14bc3a151c --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-hotfix-follow-up-closure-v1.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-hotfix-follow-up-closure-v1.schema.json", + "title": "Cryptad Stable 1.0 hotfix follow-up closure v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "closedAt", "status", "releaseId", + "buildVersion", "releaseClass", "productDigest", "candidateIdentityDigest", + "predecessorBuild", "predecessorProductDigest", + "successorBaselineDigest", "publicationReceiptDigest", + "publicationReceiptIdentityDigest", "authorizationDigest", "latestPublishedPointerDigest", + "obligationDigest", "fullEvidenceDigest", "owner", "approver", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-hotfix-follow-up-closure"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "closedAt": {"type": "string", "format": "date-time"}, + "status": {"enum": ["closed", "rejected"]}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"const": "security-hotfix"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "predecessorBuild": {"$ref": "#/$defs/buildVersion"}, + "predecessorProductDigest": {"$ref": "#/$defs/digest"}, + "successorBaselineDigest": {"$ref": "#/$defs/digest"}, + "publicationReceiptDigest": {"$ref": "#/$defs/digest"}, + "publicationReceiptIdentityDigest": {"$ref": "#/$defs/digest"}, + "authorizationDigest": {"$ref": "#/$defs/digest"}, + "latestPublishedPointerDigest": {"$ref": "#/$defs/digest"}, + "obligationDigest": {"$ref": "#/$defs/digest"}, + "fullEvidenceDigest": {"$ref": "#/$defs/digest"}, + "owner": {"$ref": "#/$defs/nonEmpty"}, + "approver": {"$ref": "#/$defs/nonEmpty"}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": { + "status": {"const": "pass"}, + "findingCount": {"const": 0}, + "findings": {"type": "array", "maxItems": 0} + } + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-hotfix-follow-up-obligation-v1.schema.json b/tools/release-certification/schemas/stable-1.0-hotfix-follow-up-obligation-v1.schema.json new file mode 100644 index 00000000000..f8f27f45cfd --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-hotfix-follow-up-obligation-v1.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-hotfix-follow-up-obligation-v1.schema.json", + "title": "Cryptad Stable 1.0 security hotfix follow-up obligation v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "productDigest", "candidateIdentityDigest", "candidateFreezeDigest", "candidateFrozenAt", + "predecessorBuild", "predecessorProductDigest", + "incidentId", "advisoryId", "severity", "shortenedEvidenceIds", + "fullEvidenceRequired", "deadline", "owner", "approver", "expectedEvidenceKinds", + "closureCriteria", "failureBehavior", "blocksRoutineMaintenance", "status", + "closureEvidenceDigest", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-hotfix-follow-up-obligation"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"const": "security-hotfix"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "candidateFreezeDigest": {"$ref": "#/$defs/digest"}, + "candidateFrozenAt": {"type": "string", "format": "date-time"}, + "predecessorBuild": {"$ref": "#/$defs/buildVersion"}, + "predecessorProductDigest": {"$ref": "#/$defs/digest"}, + "incidentId": {"$ref": "#/$defs/nonEmpty"}, + "advisoryId": {"$ref": "#/$defs/nonEmpty"}, + "severity": {"const": "critical"}, + "shortenedEvidenceIds": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/evidenceId"}}, + "fullEvidenceRequired": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/evidenceId"}}, + "deadline": {"type": "string", "format": "date-time"}, + "owner": {"$ref": "#/$defs/nonEmpty"}, + "approver": {"$ref": "#/$defs/nonEmpty"}, + "expectedEvidenceKinds": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}}, + "closureCriteria": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}}, + "failureBehavior": {"const": "open-release-incident-and-block-next-routine-maintenance"}, + "blocksRoutineMaintenance": {"const": true}, + "status": {"enum": ["open", "closed", "overdue"]}, + "closureEvidenceDigest": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "evidenceId": { + "enum": [ + "stable-maintenance.installation-packaging", + "stable-maintenance.direct-ga-upgrade", + "stable-maintenance.upgrade-rollback-migration-backup", + "stable-maintenance.live-network-interoperability", + "stable-maintenance.performance", + "stable-maintenance.sandbox", + "stable-maintenance.security", + "stable-maintenance.support-redaction" + ] + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-activation-authorization-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-activation-authorization-v1.schema.json new file mode 100644 index 00000000000..200c3c6e11a --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-activation-authorization-v1.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-activation-authorization-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance latest-baseline activation authorization v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "authorizationId", "authority", + "protectedEnvironment", "workflowRepository", "workflowRunId", "workflowRunAttempt", + "sourceCommit", "releaseId", "buildVersion", "releaseClass", + "candidateIdentityDigest", "publicationReceiptDigest", "successorBaselineDigest", + "historyDigest", "originalAuthorizationDigest", "expectedCurrentPointerDigest", + "allowedScope", "authorizedAt", "expiresAt", "status", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-activation-authorization"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "authorizationId": {"type": "string", "pattern": "^activation-[1-9][0-9]*-[1-9][0-9]*$"}, + "authority": {"const": "github-protected-environment"}, + "protectedEnvironment": { + "enum": [ + "stable-1.0-maintenance-publication", + "stable-1.0-security-hotfix-publication" + ] + }, + "workflowRepository": {"const": "crypta-network/cryptad"}, + "workflowRunId": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "workflowRunAttempt": {"type": "integer", "minimum": 1}, + "sourceCommit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "publicationReceiptDigest": {"$ref": "#/$defs/digest"}, + "successorBaselineDigest": {"$ref": "#/$defs/digest"}, + "historyDigest": {"$ref": "#/$defs/digest"}, + "originalAuthorizationDigest": {"$ref": "#/$defs/digest"}, + "expectedCurrentPointerDigest": {"$ref": "#/$defs/digest"}, + "allowedScope": {"const": "successor-baseline:activate"}, + "authorizedAt": {"type": "string", "format": "date-time"}, + "expiresAt": {"type": "string", "format": "date-time"}, + "status": {"const": "approved"}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": { + "status": {"const": "pass"}, + "findingCount": {"const": 0}, + "findings": {"type": "array", "maxItems": 0} + } + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-authorization-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-authorization-v1.schema.json new file mode 100644 index 00000000000..d24ef0cdf9d --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-authorization-v1.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-authorization-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance authorization v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "authorizationId", "releaseId", "buildVersion", "releaseClass", + "candidateIdentityDigest", "gaBaselineDigest", "predecessorIdentityDigest", + "predecessorProductDigest", "predecessorPublicationReceiptDigest", "candidateFreezeDigest", + "productDigest", "checksumsDigest", "provenanceDigest", "comparisonDigest", "evidenceDigest", + "coreInfoDigest", "stableCatalogDigest", "knownLimitationsDeltaDigest", "releaseNotesDigest", + "publicationTargetsDigest", "allowedPublicationScopes", "acceptedWarningIds", "role", "approverIdentity", + "authorizedAt", "expiresAt", "decision", "status", "hotfixIncidentId", "hotfixPolicyAuthorizationDigest", "hotfixShortenedEvidenceIds", + "hotfixFollowUpObligationDigest", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-authorization"}, + "authorizationId": {"$ref": "#/$defs/releaseId"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "gaBaselineDigest": {"$ref": "#/$defs/digest"}, + "predecessorIdentityDigest": {"$ref": "#/$defs/digest"}, + "predecessorProductDigest": {"$ref": "#/$defs/digest"}, + "predecessorPublicationReceiptDigest": {"$ref": "#/$defs/digest"}, + "candidateFreezeDigest": {"$ref": "#/$defs/digest"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "checksumsDigest": {"$ref": "#/$defs/digest"}, + "provenanceDigest": {"$ref": "#/$defs/digest"}, + "comparisonDigest": {"$ref": "#/$defs/digest"}, + "evidenceDigest": {"$ref": "#/$defs/digest"}, + "coreInfoDigest": {"$ref": "#/$defs/digest"}, + "stableCatalogDigest": {"$ref": "#/$defs/digest"}, + "knownLimitationsDeltaDigest": {"$ref": "#/$defs/digest"}, + "releaseNotesDigest": {"$ref": "#/$defs/digest"}, + "publicationTargetsDigest": {"$ref": "#/$defs/digest"}, + "allowedPublicationScopes": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": {"enum": ["tag:create-or-verify", "github-release:create-or-verify", "artifact-base:publish-or-verify", "stable-catalog:publish-or-verify", "core-update:insert-or-verify", "successor-baseline:activate", "release-history:append"]} + }, + "acceptedWarningIds": { + "type": "array", "uniqueItems": true, + "items": {"enum": ["stable-maintenance.catalog-mirror-latency-warning", "stable-maintenance.performance-comparable-runner-warning"]} + }, + "role": {"enum": ["stable-maintenance-release-manager", "stable-security-release-manager"]}, + "approverIdentity": {"$ref": "#/$defs/nonEmpty"}, + "authorizedAt": {"type": "string", "format": "date-time"}, + "expiresAt": {"type": "string", "format": "date-time"}, + "decision": {"enum": ["go", "no-go", "go-with-waivers"]}, + "status": {"const": "approved"}, + "hotfixIncidentId": {"oneOf": [{"$ref": "#/$defs/nonEmpty"}, {"type": "null"}]}, + "hotfixPolicyAuthorizationDigest": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "hotfixShortenedEvidenceIds": {"type": "array", "uniqueItems": true, "items": {"enum": ["stable-maintenance.live-network-interoperability", "stable-maintenance.performance", "stable-maintenance.support-redaction"]}}, + "hotfixFollowUpObligationDigest": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-candidate-freeze-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-candidate-freeze-v1.schema.json new file mode 100644 index 00000000000..b2e38cd1910 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-candidate-freeze-v1.schema.json @@ -0,0 +1,204 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-candidate-freeze-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance candidate freeze v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "frozenAt", "stableMilestone", "releaseId", + "buildVersion", "releaseClass", "source", "toolchain", "producer", + "predecessorObservation", "stableCatalogVerification", "buildCount", "rebuildPerformed", "checksumsDigest", + "assets", "assetSetDigest", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-candidate-freeze"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "frozenAt": {"type": "string", "format": "date-time"}, + "stableMilestone": {"const": "1.0"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "source": {"$ref": "#/$defs/source"}, + "toolchain": {"$ref": "#/$defs/toolchain"}, + "producer": {"$ref": "#/$defs/producer"}, + "predecessorObservation": {"$ref": "#/$defs/predecessorObservation"}, + "stableCatalogVerification": {"$ref": "#/$defs/stableCatalogVerification"}, + "buildCount": {"const": 1}, + "rebuildPerformed": {"const": false}, + "checksumsDigest": {"$ref": "#/$defs/digest"}, + "assets": { + "type": "array", + "minItems": 3, + "uniqueItems": true, + "items": {"$ref": "#/$defs/asset"} + }, + "assetSetDigest": {"$ref": "#/$defs/digest"}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "branch", "ref", "commit", "baseBranch", "baseCommit", "clean", "treeState", + "branchHeadVerified", "immutableRefVerified", "currentPublishedMainBaseVerified", + "sourceTreeDigest" + ], + "properties": { + "branch": {"type": "string", "pattern": "^(release|hotfix)/[1-9][0-9]*$"}, + "ref": {"type": "string", "pattern": "^commit:[0-9a-f]{40,64}$"}, + "commit": {"$ref": "#/$defs/commit"}, + "baseBranch": {"enum": ["develop", "main"]}, + "baseCommit": {"$ref": "#/$defs/commit"}, + "clean": {"const": true}, + "treeState": {"const": "clean"}, + "branchHeadVerified": {"const": true}, + "immutableRefVerified": {"const": true}, + "currentPublishedMainBaseVerified": {"type": "boolean"}, + "sourceTreeDigest": {"$ref": "#/$defs/digest"} + } + }, + "toolchain": { + "type": "object", + "additionalProperties": false, + "required": [ + "javaVersion", "javaMajorVersion", "gradleVersion", "gradleWrapperDigest", + "dependencyVerificationDigest", "dependencyVerificationStatus", "buildLogicDigest", + "buildTasks", "productionSigning", "testSigning" + ], + "properties": { + "javaVersion": {"$ref": "#/$defs/nonEmpty"}, + "javaMajorVersion": {"type": "integer", "minimum": 25}, + "gradleVersion": {"$ref": "#/$defs/nonEmpty"}, + "gradleWrapperDigest": {"$ref": "#/$defs/digest"}, + "dependencyVerificationDigest": {"$ref": "#/$defs/digest"}, + "dependencyVerificationStatus": {"const": "pass"}, + "buildLogicDigest": {"$ref": "#/$defs/digest"}, + "buildTasks": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": {"$ref": "#/$defs/nonEmpty"} + }, + "productionSigning": {"const": true}, + "testSigning": {"const": false} + } + }, + "producer": { + "type": "object", + "additionalProperties": false, + "required": [ + "system", "repository", "workflowPath", "workflowCommit", "runId", "runAttempt", + "runnerEnvironment", "producerIdentityReceiptDigest", "sourceRefReceiptDigest", + "buildReceiptDigest", "authenticationStatus" + ], + "properties": { + "system": {"const": "github-actions"}, + "repository": {"const": "crypta-network/cryptad"}, + "workflowPath": {"type": "string", "pattern": "^\\.github/workflows/[A-Za-z0-9._-]+\\.ya?ml$"}, + "workflowCommit": {"$ref": "#/$defs/commit"}, + "runId": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "runAttempt": {"type": "integer", "minimum": 1}, + "runnerEnvironment": {"const": "github-hosted"}, + "producerIdentityReceiptDigest": {"$ref": "#/$defs/digest"}, + "sourceRefReceiptDigest": {"$ref": "#/$defs/digest"}, + "buildReceiptDigest": {"$ref": "#/$defs/digest"}, + "authenticationStatus": {"const": "pass"} + } + }, + "predecessorObservation": { + "type": "object", + "additionalProperties": false, + "required": [ + "releaseId", "buildVersion", "productDigest", "baselineDigest", + "publicationReceiptDigest", "latestPublishedPointerDigest", "observedAt", "status" + ], + "properties": { + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "baselineDigest": {"$ref": "#/$defs/digest"}, + "publicationReceiptDigest": {"$ref": "#/$defs/digest"}, + "latestPublishedPointerDigest": { + "oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}] + }, + "observedAt": {"type": "string", "format": "date-time"}, + "status": {"const": "latest-published"} + } + }, + "stableCatalogVerification": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "catalogDigest", "signatureDigest", "signingKeyId", "trustedKeyRegistryDigest", + "signatureAlgorithm", "verifier", "cryptographicVerificationStatus", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-catalog-signature-verification"}, + "catalogDigest": {"$ref": "#/$defs/digest"}, + "signatureDigest": {"$ref": "#/$defs/digest"}, + "signingKeyId": {"$ref": "#/$defs/nonEmpty"}, + "trustedKeyRegistryDigest": {"$ref": "#/$defs/digest"}, + "signatureAlgorithm": {"const": "Ed25519"}, + "verifier": {"const": "network.crypta.platform.appcatalog.AppCatalogVerifier"}, + "cryptographicVerificationStatus": {"const": "pass"}, + "redaction": {"$ref": "#/$defs/redaction"} + } + }, + "asset": { + "type": "object", + "additionalProperties": false, + "required": [ + "role", "fileName", "digest", "sizeBytes", "packageKey", "os", "arch", + "producerArchitecture", "packageType", "publicAsset", "signingStatus", "signingReceiptDigest", + "notarizationStatus", "notarizationReceiptDigest" + ], + "properties": { + "role": {"enum": ["product", "package", "stable-catalog", "stable-catalog-signature"]}, + "fileName": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "digest": {"$ref": "#/$defs/digest"}, + "sizeBytes": {"type": "integer", "minimum": 1}, + "packageKey": { + "oneOf": [ + {"type": "string", "pattern": "^(amd64|arm64)\\.(deb|rpm|dmg|exe|flatpak|snap)$"}, + {"type": "null"} + ] + }, + "os": {"oneOf": [{"enum": ["linux", "macos", "windows"]}, {"type": "null"}]}, + "arch": {"oneOf": [{"enum": ["amd64", "arm64"]}, {"type": "null"}]}, + "producerArchitecture": { + "oneOf": [{"enum": ["amd64", "arm64"]}, {"type": "null"}] + }, + "packageType": { + "oneOf": [ + {"enum": ["deb", "rpm", "dmg", "exe", "flatpak", "snap"]}, + {"type": "null"} + ] + }, + "publicAsset": {"const": true}, + "signingStatus": {"const": "pass"}, + "signingReceiptDigest": {"$ref": "#/$defs/digest"}, + "notarizationStatus": {"enum": ["pass", "not-applicable"]}, + "notarizationReceiptDigest": { + "oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}] + } + } + }, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": { + "status": {"const": "pass"}, + "findingCount": {"const": 0}, + "findings": {"type": "array", "maxItems": 0} + } + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-candidate-input-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-candidate-input-v1.schema.json new file mode 100644 index 00000000000..62fbe727ff5 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-candidate-input-v1.schema.json @@ -0,0 +1,397 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-candidate-input-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance candidate input v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "stableMilestone", "releaseId", "buildVersion", + "releaseClass", "candidateFreezeDigest", "builtOnce", "rebuildCount", "source", "toolchain", "product", "packages", "platformApi", + "stableCatalog", "firstPartyApps", "contentFormatProfiles", "limitations", "security", + "support", "legacyBoundaries", "changeScope", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-candidate-input"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "stableMilestone": {"const": "1.0"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "candidateFreezeDigest": {"$ref": "#/$defs/digest"}, + "builtOnce": {"const": true}, + "rebuildCount": {"const": 0}, + "source": {"$ref": "#/$defs/source"}, + "toolchain": {"$ref": "#/$defs/toolchain"}, + "product": {"$ref": "#/$defs/product"}, + "packages": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": {"$ref": "#/$defs/package"} + }, + "platformApi": {"$ref": "#/$defs/platformApi"}, + "stableCatalog": {"$ref": "#/$defs/stableCatalog"}, + "firstPartyApps": { + "type": "array", "minItems": 7, "maxItems": 7, "uniqueItems": true, + "items": {"$ref": "#/$defs/firstPartyApp"} + }, + "contentFormatProfiles": { + "type": "array", "minItems": 5, "maxItems": 5, "uniqueItems": true, + "items": {"$ref": "#/$defs/contentProfile"} + }, + "limitations": {"$ref": "#/$defs/limitations"}, + "security": {"$ref": "#/$defs/security"}, + "support": {"$ref": "#/$defs/support"}, + "legacyBoundaries": {"$ref": "#/$defs/legacyBoundaries"}, + "changeScope": {"$ref": "#/$defs/changeScope"}, + "operationalWarnings": { + "type": "array", "uniqueItems": true, + "items": {"$ref": "#/$defs/operationalWarning"} + }, + "coreUpdateChangelog": {"$ref": "#/$defs/coreUpdateChangelog"}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "source": { + "type": "object", "additionalProperties": false, + "required": [ + "branch", "ref", "commit", "baseBranch", "baseCommit", "clean", "treeState", + "branchHeadVerified", "immutableRefVerified", "currentPublishedMainBaseVerified", + "sourceTreeDigest" + ], + "properties": { + "branch": {"type": "string", "pattern": "^(release|hotfix)/[1-9][0-9]*$"}, + "ref": {"type": "string", "pattern": "^commit:[0-9a-f]{40,64}$"}, + "commit": {"$ref": "#/$defs/commit"}, + "baseBranch": {"enum": ["develop", "main"]}, + "baseCommit": {"$ref": "#/$defs/commit"}, + "clean": {"const": true}, + "treeState": {"const": "clean"}, + "branchHeadVerified": {"const": true}, + "immutableRefVerified": {"const": true}, + "currentPublishedMainBaseVerified": {"type": "boolean"}, + "sourceTreeDigest": {"$ref": "#/$defs/digest"} + } + }, + "toolchain": { + "type": "object", "additionalProperties": false, + "required": [ + "javaVersion", "javaMajorVersion", "gradleVersion", "gradleWrapperDigest", + "dependencyVerificationDigest", "dependencyVerificationStatus", "buildLogicDigest", + "buildTasks", "productionSigning", "testSigning" + ], + "properties": { + "javaVersion": {"$ref": "#/$defs/nonEmpty"}, + "javaMajorVersion": {"type": "integer", "minimum": 25}, + "gradleVersion": {"$ref": "#/$defs/nonEmpty"}, + "gradleWrapperDigest": {"$ref": "#/$defs/digest"}, + "dependencyVerificationDigest": {"$ref": "#/$defs/digest"}, + "dependencyVerificationStatus": {"const": "pass"}, + "buildLogicDigest": {"$ref": "#/$defs/digest"}, + "buildTasks": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}}, + "productionSigning": {"const": true}, + "testSigning": {"const": false} + } + }, + "product": { + "type": "object", "additionalProperties": false, + "required": [ + "fileName", "digest", "sizeBytes", "archiveFormat", "archiveIntegrityStatus", + "checksumsDigest", "frozenAt", "rebuildPerformed" + ], + "properties": { + "fileName": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "digest": {"$ref": "#/$defs/digest"}, + "sizeBytes": {"type": "integer", "minimum": 1}, + "archiveFormat": {"enum": ["tar.gz", "zip"]}, + "archiveIntegrityStatus": {"const": "pass"}, + "checksumsDigest": {"$ref": "#/$defs/digest"}, + "frozenAt": {"type": "string", "format": "date-time"}, + "rebuildPerformed": {"const": false} + } + }, + "package": { + "type": "object", "additionalProperties": false, + "required": [ + "packageKey", "fileName", "os", "arch", "producerArchitecture", "packageType", "digest", "sizeBytes", + "buildVersion", "sourceCommit", "publicChk", "storeUrl", "signingStatus", + "notarizationStatus", "installLaunchStatus", "upgradeStatus", + "installLaunchEvidenceDigest", "upgradeEvidenceDigest", "uninstallDataRetention", + "redactionStatus" + ], + "properties": { + "packageKey": {"type": "string", "pattern": "^(amd64|arm64)\\.(deb|rpm|dmg|exe|flatpak|snap)$"}, + "fileName": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "os": {"enum": ["linux", "macos", "windows"]}, + "arch": {"enum": ["amd64", "arm64"]}, + "producerArchitecture": {"enum": ["amd64", "arm64"]}, + "packageType": {"enum": ["deb", "rpm", "dmg", "exe", "msi", "flatpak", "snap", "pkg", "tar.gz", "zip"]}, + "digest": {"$ref": "#/$defs/digest"}, + "sizeBytes": {"type": "integer", "minimum": 1}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "sourceCommit": {"$ref": "#/$defs/commit"}, + "publicChk": {"oneOf": [{"type": "string", "pattern": "^CHK@[^ ]{4,}$"}, {"type": "null"}]}, + "storeUrl": {"oneOf": [{"type": "string", "pattern": "^https://[^ ]+$"}, {"type": "null"}]}, + "signingStatus": {"const": "pass"}, + "notarizationStatus": {"enum": ["pass", "not-applicable"]}, + "installLaunchStatus": {"const": "pass"}, + "upgradeStatus": {"const": "pass"}, + "installLaunchEvidenceDigest": {"$ref": "#/$defs/digest"}, + "upgradeEvidenceDigest": {"$ref": "#/$defs/digest"}, + "uninstallDataRetention": {"enum": ["retained", "not-applicable"]}, + "redactionStatus": {"const": "pass"} + } + }, + "platformApi": { + "type": "object", "additionalProperties": false, + "required": [ + "baselineName", "baselineDigest", "baselineContractVersion", "currentContractVersion", + "currentContractDigest", "stableSurfaceDigest", "compatibilityWindowPolicyDigest", + "stableDiffDigest", "thirdPartyCompatibilityEvidenceDigest", "deprecationHistoryDigest", + "deprecationHistory", + "removedStableEndpoints", "removedStableCapabilities", "breakingStableChanges", + "criticalRemovalWaiverAttempt", "deprecationClockReset", "experimentalMislabelledStable", + "additionsBackwardCompatible", "thirdPartyCompatibilityStatus" + ], + "properties": { + "baselineName": {"const": "1.0"}, + "baselineDigest": {"$ref": "#/$defs/digest"}, + "baselineContractVersion": {"type": "integer", "minimum": 1}, + "currentContractVersion": {"type": "integer", "minimum": 1}, + "currentContractDigest": {"$ref": "#/$defs/digest"}, + "stableSurfaceDigest": {"$ref": "#/$defs/digest"}, + "compatibilityWindowPolicyDigest": {"$ref": "#/$defs/digest"}, + "stableDiffDigest": {"$ref": "#/$defs/digest"}, + "thirdPartyCompatibilityEvidenceDigest": {"$ref": "#/$defs/digest"}, + "deprecationHistoryDigest": {"$ref": "#/$defs/digest"} + ,"deprecationHistory": { + "type": "array", "uniqueItems": true, + "items": {"$ref": "#/$defs/deprecationHistoryEntry"} + } + ,"removedStableEndpoints": {"type": "array", "maxItems": 0} + ,"removedStableCapabilities": {"type": "array", "maxItems": 0} + ,"breakingStableChanges": {"type": "array", "maxItems": 0} + ,"criticalRemovalWaiverAttempt": {"const": false} + ,"deprecationClockReset": {"const": false} + ,"experimentalMislabelledStable": {"const": false} + ,"additionsBackwardCompatible": {"const": true} + ,"thirdPartyCompatibilityStatus": {"const": "pass"} + } + }, + "deprecationHistoryEntry": { + "type": "object", "additionalProperties": false, + "required": ["kind", "identity", "stability", "deprecatedSinceContractVersion", "removalContractVersion"], + "properties": { + "kind": {"enum": ["capability", "endpoint"]}, + "identity": {"$ref": "#/$defs/nonEmpty"}, + "stability": {"enum": ["deprecated", "scheduled-for-removal"]}, + "deprecatedSinceContractVersion": {"type": "integer", "minimum": 1}, + "removalContractVersion": { + "oneOf": [{"type": "integer", "minimum": 1}, {"type": "null"}] + } + } + }, + "stableCatalog": { + "type": "object", "additionalProperties": false, + "required": [ + "fileName", "sizeBytes", "signatureFileName", "signatureSizeBytes", "catalogId", "channel", "revision", "edition", "digest", "signatureDigest", + "signingKeyId", "deltaDigest", "signatureStatus", "keyTrustStatus", "mirrorStatus", + "rollbackStatus", "advisoryStatus", "denylistStatus", "keyRotationTrustTransitionStatus" + ], + "properties": { + "fileName": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "sizeBytes": {"type": "integer", "minimum": 1}, + "signatureFileName": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "signatureSizeBytes": {"type": "integer", "minimum": 1}, + "catalogId": {"$ref": "#/$defs/nonEmpty"}, + "channel": {"const": "stable"}, + "revision": {"type": "integer", "minimum": 0}, + "edition": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"}, + "signatureDigest": {"$ref": "#/$defs/digest"}, + "signingKeyId": {"$ref": "#/$defs/nonEmpty"}, + "deltaDigest": {"$ref": "#/$defs/digest"} + ,"signatureStatus": {"const": "pass"} + ,"keyTrustStatus": {"const": "pass"} + ,"mirrorStatus": {"const": "pass"} + ,"rollbackStatus": {"const": "pass"} + ,"advisoryStatus": {"const": "pass"} + ,"denylistStatus": {"const": "pass"} + ,"keyRotationTrustTransitionStatus": {"enum": ["complete", "not-applicable"]} + } + }, + "firstPartyApp": { + "type": "object", "additionalProperties": false, + "required": [ + "appId", "version", "channel", "supportLevel", "bundleDigest", "reviewReceiptDigest", + "appSigningKeyId", "reviewerKeyId", "manifestDigest", "permissionSetDigest", + "apiCompatibilityEvidenceDigest", "appDataSchemaVersion", "migrationEvidenceDigest", + "backupRestoreEvidenceDigest", "supportMetadataDigest", "trustState", "reviewStatus", + "signingStatus", "permissionExpansion", "permissionConsentStatus", + "permissionRationaleStatus", "apiCompatibilityStatus", "migrationStatus", "backupRestoreStatus", "serviceGrantStatus", "reviewedBundleDigest" + ], + "properties": { + "appId": {"enum": ["queue-manager", "publisher", "site-publisher", "profile-publisher", "social-inbox", "feed-reader", "trust-graph"]}, + "version": {"$ref": "#/$defs/nonEmpty"}, + "channel": {"const": "stable"}, + "supportLevel": {"enum": ["core", "maintained", "local-rc"]}, + "bundleDigest": {"$ref": "#/$defs/digest"}, + "reviewReceiptDigest": {"$ref": "#/$defs/digest"}, + "appSigningKeyId": {"$ref": "#/$defs/nonEmpty"}, + "reviewerKeyId": {"$ref": "#/$defs/nonEmpty"}, + "manifestDigest": {"$ref": "#/$defs/digest"}, + "permissionSetDigest": {"$ref": "#/$defs/digest"}, + "apiCompatibilityEvidenceDigest": {"$ref": "#/$defs/digest"}, + "appDataSchemaVersion": {"oneOf": [{"type": "integer", "minimum": 1}, {"const": "not-applicable"}]}, + "migrationEvidenceDigest": {"$ref": "#/$defs/digest"}, + "backupRestoreEvidenceDigest": {"$ref": "#/$defs/digest"}, + "supportMetadataDigest": {"$ref": "#/$defs/digest"} + ,"trustState": {"const": "trusted"} + ,"reviewStatus": {"const": "pass"} + ,"signingStatus": {"const": "pass"} + ,"permissionExpansion": {"type": "boolean"} + ,"permissionConsentStatus": {"enum": ["pass", "not-applicable"]} + ,"permissionRationaleStatus": {"enum": ["pass", "not-applicable"]} + ,"apiCompatibilityStatus": {"const": "pass"} + ,"migrationStatus": {"const": "pass"} + ,"backupRestoreStatus": {"const": "pass"} + ,"serviceGrantStatus": {"const": "pass"} + ,"reviewedBundleDigest": {"$ref": "#/$defs/digest"} + } + }, + "contentProfile": { + "type": "object", "additionalProperties": false, + "required": [ + "profileId", "version", "status", "descriptorDigest", "canonicalizationRulesDigest", + "maximumSizePolicyDigest", "signaturePayloadRulesDigest", "parserVerifierCompatibilityEvidenceDigest", + "existingValidDocumentsAccepted", "acceptanceStatus" + ], + "properties": { + "profileId": {"enum": ["crypta.profile.v1", "crypta.feed.snapshot.v1", "crypta.trust.statement.v1", "crypta.social.message.v1", "crypta.social.outbox.v1"]}, + "version": {"const": 1}, + "status": {"const": "stable"}, + "descriptorDigest": {"$ref": "#/$defs/digest"}, + "canonicalizationRulesDigest": {"$ref": "#/$defs/digest"}, + "maximumSizePolicyDigest": {"$ref": "#/$defs/digest"}, + "signaturePayloadRulesDigest": {"$ref": "#/$defs/digest"}, + "parserVerifierCompatibilityEvidenceDigest": {"$ref": "#/$defs/digest"} + ,"existingValidDocumentsAccepted": {"const": true} + ,"acceptanceStatus": {"const": "pass"} + } + }, + "limitations": { + "type": "object", "additionalProperties": false, + "required": [ + "knownLimitationsDigest", "deltaDigest", "addedCount", "resolvedCount", "unchangedCount", + "addedIds", "resolvedIds", "unchangedIds", "changesReviewed", "noHiddenLimitations" + ], + "properties": { + "knownLimitationsDigest": {"$ref": "#/$defs/digest"}, + "deltaDigest": {"$ref": "#/$defs/digest"}, + "addedCount": {"type": "integer", "minimum": 0}, + "resolvedCount": {"type": "integer", "minimum": 0}, + "unchangedCount": {"type": "integer", "minimum": 0} + ,"addedIds": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}} + ,"resolvedIds": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}} + ,"unchangedIds": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}} + ,"changesReviewed": {"const": true} + ,"noHiddenLimitations": {"const": true} + } + }, + "security": { + "type": "object", "additionalProperties": false, + "required": ["advisoryDigest", "denylistDigest", "keyStateDigest", "securityEvidenceDigest", "signingKeysUncompromised", "advisoryStatus", "denylistStatus", "securityEvidenceStatus"], + "properties": { + "advisoryDigest": {"$ref": "#/$defs/digest"}, + "denylistDigest": {"$ref": "#/$defs/digest"}, + "keyStateDigest": {"$ref": "#/$defs/digest"}, + "securityEvidenceDigest": {"$ref": "#/$defs/digest"}, + "signingKeysUncompromised": {"const": true} + ,"advisoryStatus": {"const": "pass"} + ,"denylistStatus": {"const": "pass"} + ,"securityEvidenceStatus": {"const": "pass"} + } + }, + "support": { + "type": "object", "additionalProperties": false, + "required": ["supportLevelDigest", "diagnosticsEvidenceDigest", "supportBundleDigest", "supportStatus", "diagnosticsStatus", "supportCommitmentReduced", "redactionStatus"], + "properties": { + "supportLevelDigest": {"$ref": "#/$defs/digest"}, + "diagnosticsEvidenceDigest": {"$ref": "#/$defs/digest"}, + "supportBundleDigest": {"$ref": "#/$defs/digest"}, + "supportStatus": {"const": "pass"}, + "diagnosticsStatus": {"const": "pass"}, + "supportCommitmentReduced": {"const": false}, + "redactionStatus": {"const": "pass"} + } + }, + "legacyBoundaries": { + "type": "object", "additionalProperties": false, + "required": ["pluginRuntime", "inCorePluginApi", "legacyAdminMutationRoutes", "fproxyBrowse", "contentFiltering", "emergencyFallbackRoutes"], + "properties": { + "pluginRuntime": {"const": "removed"}, + "inCorePluginApi": {"const": "removed"}, + "legacyAdminMutationRoutes": {"const": "disabled"}, + "fproxyBrowse": {"const": "retained"}, + "contentFiltering": {"const": "retained"}, + "emergencyFallbackRoutes": {"const": "retained"} + } + }, + "changeScope": { + "type": "object", "additionalProperties": false, + "required": [ + "categories", "changedModules", "publicUserVisibleFixes", "unrelatedFeatureChanges", "auditDigest", "incidentId", + "severity", "affectedPackageKeys", "shortenedEvidenceIds", "unaffectedPackageProofStatus", + "hotfixPolicyAuthorizationDigest", "followUpOwner", "followUpApprover" + ], + "properties": { + "categories": { + "type": "array", "minItems": 1, "uniqueItems": true, + "items": {"enum": ["compatible-bug-fixes", "security-fixes-and-hotfixes", "platform-api-compatible-additions-and-deprecations", "stable-catalog-and-app-patch-updates", "emergency-advisory-and-denylist-updates", "maintenance-release-upgrade-and-rollback-verification"]} + }, + "changedModules": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}}, + "publicUserVisibleFixes": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/nonEmpty"}}, + "unrelatedFeatureChanges": {"type": "array", "maxItems": 0}, + "auditDigest": {"$ref": "#/$defs/digest"}, + "incidentId": {"oneOf": [{"$ref": "#/$defs/nonEmpty"}, {"type": "null"}]}, + "severity": {"oneOf": [{"const": "critical"}, {"type": "null"}]}, + "affectedPackageKeys": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^(amd64|arm64)\\.(deb|rpm|dmg|exe|flatpak|snap)$"}}, + "shortenedEvidenceIds": {"type": "array", "uniqueItems": true, "items": {"enum": ["stable-maintenance.live-network-interoperability", "stable-maintenance.performance", "stable-maintenance.support-redaction"]}}, + "unaffectedPackageProofStatus": {"enum": ["pass", "not-applicable"]}, + "hotfixPolicyAuthorizationDigest": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "followUpOwner": {"oneOf": [{"$ref": "#/$defs/nonEmpty"}, {"type": "null"}]}, + "followUpApprover": {"oneOf": [{"$ref": "#/$defs/nonEmpty"}, {"type": "null"}]} + } + }, + "operationalWarning": { + "type": "object", "additionalProperties": false, + "required": ["warningId", "status", "evidenceDigest"], + "properties": { + "warningId": {"enum": ["stable-maintenance.catalog-mirror-latency-warning", "stable-maintenance.performance-comparable-runner-warning"]}, + "status": {"const": "warn"}, + "evidenceDigest": {"$ref": "#/$defs/digest"} + } + }, + "coreUpdateChangelog": { + "type": "object", "additionalProperties": false, + "properties": { + "shortChk": {"oneOf": [{"type": "string", "pattern": "^CHK@[^ ]{4,}$"}, {"type": "null"}]}, + "fullChk": {"oneOf": [{"type": "string", "pattern": "^CHK@[^ ]{4,}$"}, {"type": "null"}]} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": { + "status": {"const": "pass"}, + "findingCount": {"const": 0}, + "findings": {"type": "array", "maxItems": 0} + } + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-comparison-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-comparison-v1.schema.json new file mode 100644 index 00000000000..9704cc5e885 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-comparison-v1.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-comparison-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance comparison v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "gaBaselineDigest", "predecessorBaselineDigest", "candidateIdentityDigest", "sections", + "compatible", "decision", "blockers", "warnings", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-comparison"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "gaBaselineDigest": {"$ref": "#/$defs/digest"}, + "predecessorBaselineDigest": {"$ref": "#/$defs/digest"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "sections": {"$ref": "#/$defs/sections"}, + "compatible": {"type": "boolean"}, + "decision": {"$ref": "#/$defs/decision"}, + "blockers": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/issue"}}, + "warnings": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/issue"}}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "decision": {"enum": ["go", "no-go", "go-with-waivers"]}, + "sections": { + "type": "object", "additionalProperties": false, + "required": ["platformApi", "catalogAndApps", "contentProfiles", "limitations", "security", "support", "legacyBoundaries"], + "properties": { + "platformApi": {"$ref": "#/$defs/section"}, + "catalogAndApps": {"$ref": "#/$defs/section"}, + "contentProfiles": {"$ref": "#/$defs/section"}, + "limitations": {"$ref": "#/$defs/section"}, + "security": {"$ref": "#/$defs/section"}, + "support": {"$ref": "#/$defs/section"}, + "legacyBoundaries": {"$ref": "#/$defs/section"} + } + }, + "section": { + "type": "object", "additionalProperties": false, + "required": ["status", "gaDeltaDigest", "predecessorDeltaDigest", "blockerCount", "warningCount"], + "properties": { + "status": {"enum": ["pass", "warn", "fail"]}, + "gaDeltaDigest": {"$ref": "#/$defs/digest"}, + "predecessorDeltaDigest": {"$ref": "#/$defs/digest"}, + "blockerCount": {"type": "integer", "minimum": 0}, + "warningCount": {"type": "integer", "minimum": 0} + } + }, + "issue": { + "type": "object", "additionalProperties": false, + "required": ["id", "category", "message", "waivable"], + "properties": { + "id": {"$ref": "#/$defs/nonEmpty"}, + "category": {"$ref": "#/$defs/nonEmpty"}, + "message": {"$ref": "#/$defs/nonEmpty"}, + "waivable": {"type": "boolean"} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-evidence-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-evidence-v1.schema.json new file mode 100644 index 00000000000..19226b4e194 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-evidence-v1.schema.json @@ -0,0 +1,114 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-evidence-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance evidence v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "candidateProductDigest", "candidateFreezeDigest", "predecessorBuild", "predecessorProductDigest", "windowClass", + "validationStartedAt", "validationEndedAt", "productionEvidence", "fixtureOnly", + "simulatedOnly", "skipped", "evidenceRows", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-evidence"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "candidateProductDigest": {"$ref": "#/$defs/digest"}, + "candidateFreezeDigest": {"$ref": "#/$defs/digest"}, + "predecessorBuild": {"$ref": "#/$defs/buildVersion"}, + "predecessorProductDigest": {"$ref": "#/$defs/digest"}, + "windowClass": {"enum": ["normal", "shortened-security-hotfix"]}, + "validationStartedAt": {"type": "string", "format": "date-time"}, + "validationEndedAt": {"type": "string", "format": "date-time"}, + "productionEvidence": {"const": true}, + "fixtureOnly": {"const": false}, + "simulatedOnly": {"const": false}, + "skipped": {"const": false}, + "evidenceRows": { + "type": "array", "minItems": 10, "uniqueItems": true, + "items": {"$ref": "#/$defs/evidenceRow"} + }, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "evidenceId": { + "enum": [ + "stable-maintenance.ga-baseline-authentication", + "stable-maintenance.predecessor-lineage", + "stable-maintenance.candidate-identity", + "stable-maintenance.archive-integrity", + "stable-maintenance.platform-api-compatibility", + "stable-maintenance.catalog-app-compatibility", + "stable-maintenance.content-profile-compatibility", + "stable-maintenance.direct-ga-upgrade", + "stable-maintenance.installation-packaging", + "stable-maintenance.upgrade-rollback-migration-backup", + "stable-maintenance.live-network-interoperability", + "stable-maintenance.performance", + "stable-maintenance.sandbox", + "stable-maintenance.security", + "stable-maintenance.support-redaction", + "stable-maintenance.core-update-descriptor", + "stable-maintenance.authorization", + "stable-maintenance.publication-conflict", + "stable-maintenance.publication-verification", + "stable-maintenance.successor-baseline", + "stable-maintenance.hotfix-follow-up" + ] + }, + "evidenceRow": { + "type": "object", "additionalProperties": false, + "required": [ + "evidenceId", "status", "candidateReleaseId", "candidateBuild", "candidateProductDigest", "candidateFreezeDigest", + "predecessorBuild", "predecessorProductDigest", "gaReleaseId", "gaBuild", "gaProductDigest", + "startedAt", "endedAt", "environmentClass", + "production", "nodeCount", "operationCount", "evidenceDigest", "fresh", "redactionStatus" + ], + "properties": { + "evidenceId": {"$ref": "#/$defs/evidenceId"}, + "status": {"enum": ["pass", "warn", "fail"]}, + "candidateReleaseId": {"$ref": "#/$defs/releaseId"}, + "candidateBuild": {"$ref": "#/$defs/buildVersion"}, + "candidateProductDigest": {"$ref": "#/$defs/digest"}, + "candidateFreezeDigest": {"$ref": "#/$defs/digest"}, + "predecessorBuild": {"$ref": "#/$defs/buildVersion"}, + "predecessorProductDigest": {"$ref": "#/$defs/digest"}, + "gaReleaseId": { + "oneOf": [{"$ref": "#/$defs/releaseId"}, {"type": "null"}] + }, + "gaBuild": { + "oneOf": [{"$ref": "#/$defs/buildVersion"}, {"type": "null"}] + }, + "gaProductDigest": { + "oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}] + }, + "startedAt": {"type": "string", "format": "date-time"}, + "endedAt": {"type": "string", "format": "date-time"}, + "environmentClass": {"enum": ["production", "protected-production"]}, + "production": {"const": true}, + "nodeCount": {"type": "integer", "minimum": 0}, + "operationCount": {"type": "integer", "minimum": 0}, + "evidenceDigest": {"$ref": "#/$defs/digest"}, + "fresh": {"const": true}, + "redactionStatus": {"const": "pass"} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": { + "status": {"const": "pass"}, "findingCount": {"const": 0}, + "findings": {"type": "array", "maxItems": 0} + } + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-lineage-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-lineage-v1.schema.json new file mode 100644 index 00000000000..94b3495c147 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-lineage-v1.schema.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-lineage-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance lineage v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "stableMilestone", "gaRoot", "predecessor", + "candidate", "chainDepth", "previousLineageDigest", "latestPublishedPointerDigest", + "noGap", "noFork", "status", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-lineage"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "stableMilestone": {"const": "1.0"}, + "gaRoot": {"$ref": "#/$defs/gaRoot"}, + "predecessor": {"$ref": "#/$defs/predecessor"}, + "candidate": {"$ref": "#/$defs/candidate"}, + "chainDepth": {"type": "integer", "minimum": 1}, + "previousLineageDigest": {"$ref": "#/$defs/digest"}, + "latestPublishedPointerDigest": {"$ref": "#/$defs/digest"}, + "noGap": {"const": true}, + "noFork": {"const": true}, + "status": {"const": "pass"}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "gaRoot": { + "type": "object", "additionalProperties": false, + "required": ["releaseId", "buildVersion", "tag", "sourceCommit", "productDigest", "maintenanceBaselineDigest", "publicationReceiptDigest", "publicationState"], + "properties": { + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "tag": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, + "sourceCommit": {"$ref": "#/$defs/commit"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "maintenanceBaselineDigest": {"$ref": "#/$defs/digest"}, + "publicationReceiptDigest": {"$ref": "#/$defs/digest"}, + "publicationState": {"const": "publication-complete"} + } + }, + "predecessor": { + "type": "object", "additionalProperties": false, + "required": [ + "releaseId", "buildVersion", "tag", "sourceCommit", "productDigest", "releaseClass", + "publicationReceiptDigest", "successorBaselineDigest", "hotfixFollowUpClosureDigest", + "publicationState" + ], + "properties": { + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "tag": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, + "sourceCommit": {"$ref": "#/$defs/commit"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "releaseClass": {"oneOf": [{"$ref": "#/$defs/releaseClass"}, {"const": "stable-ga"}]}, + "publicationReceiptDigest": {"$ref": "#/$defs/digest"}, + "successorBaselineDigest": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "hotfixFollowUpClosureDigest": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "publicationState": {"const": "publication-complete"} + } + }, + "candidate": { + "type": "object", "additionalProperties": false, + "required": ["releaseId", "buildVersion", "tag", "sourceBranch", "sourceRef", "sourceCommit", "releaseClass"], + "properties": { + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "tag": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, + "sourceBranch": {"type": "string", "pattern": "^(release|hotfix)/[1-9][0-9]*$"}, + "sourceRef": {"type": "string", "pattern": "^commit:[0-9a-f]{40,64}$"}, + "sourceCommit": {"$ref": "#/$defs/commit"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-publication-failure-audit-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-publication-failure-audit-v1.schema.json new file mode 100644 index 00000000000..c9b04671fe8 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-publication-failure-audit-v1.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-publication-failure-audit-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance publication failure audit v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "sourceCommit", "candidateIdentityDigest", "productDigest", "publicationPlanDigest", + "publicationState", "failureCategory", "predecessorPointerDigest", + "latestCandidateIdentityDigest", "observedPublicState", "attemptedTargets", + "completedTargets", + "sideEffectsMayHaveOccurred", "finalVerificationStatus", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-publication-failure-audit"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "sourceCommit": {"$ref": "#/$defs/commit"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "publicationPlanDigest": {"$ref": "#/$defs/digest"}, + "publicationState": { + "enum": ["publication-partial", "publication-verification-failed", "unknown-or-partial"] + }, + "failureCategory": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"}, + "predecessorPointerDigest": { + "oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}] + }, + "latestCandidateIdentityDigest": { + "oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}] + }, + "observedPublicState": {"$ref": "#/$defs/publicState"}, + "attemptedTargets": { + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/publicationTarget"} + }, + "completedTargets": { + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/publicationTarget"} + }, + "sideEffectsMayHaveOccurred": {"type": "boolean"}, + "finalVerificationStatus": {"const": "fail"}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "publicationTarget": { + "enum": ["tag", "githubRelease", "assets", "artifactBase", "stableCatalog", "coreUpdate"] + }, + "observation": {"enum": ["absent", "matching", "conflict", "unavailable"]}, + "publicState": { + "type": "object", + "additionalProperties": false, + "required": ["tag", "githubRelease", "assets", "artifactBase", "stableCatalog", "coreUpdate"], + "properties": { + "tag": {"$ref": "#/$defs/observation"}, + "githubRelease": {"$ref": "#/$defs/observation"}, + "assets": {"$ref": "#/$defs/observation"}, + "artifactBase": {"$ref": "#/$defs/observation"}, + "stableCatalog": {"$ref": "#/$defs/observation"}, + "coreUpdate": {"$ref": "#/$defs/observation"} + } + }, + "redaction": { + "type": "object", + "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": { + "status": {"const": "pass"}, + "findingCount": {"const": 0}, + "findings": {"type": "array", "maxItems": 0} + } + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-publication-plan-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-publication-plan-v1.schema.json new file mode 100644 index 00000000000..ea14fa3f840 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-publication-plan-v1.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-publication-plan-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance publication plan v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "sourceBranch", "sourceCommit", "expectedTag", "githubReleasePageUri", "artifactBaseUri", + "deploymentServicePublicUri", "latestPointerPublicUri", "candidateIdentityDigest", + "productDigest", "checksumsDigest", "provenanceDigest", "authorizationDigest", + "releaseNotesDigest", "coreInfoDigest", "stableCatalogDigest", "knownLimitationsDeltaDigest", + "publicationTargetsDigest", "assets", "stableCatalogTarget", "coreUpdateTarget", + "sideEffectsPerformed", "publicationState", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-publication-plan"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "sourceBranch": {"type": "string", "pattern": "^(release|hotfix)/[1-9][0-9]*$"}, + "sourceCommit": {"$ref": "#/$defs/commit"}, + "expectedTag": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, + "githubReleasePageUri": {"type": "string", "format": "uri"}, + "artifactBaseUri": {"type": "string", "format": "uri"}, + "deploymentServicePublicUri": {"type": "string", "format": "uri"}, + "latestPointerPublicUri": {"type": "string", "format": "uri"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "checksumsDigest": {"$ref": "#/$defs/digest"}, + "provenanceDigest": {"$ref": "#/$defs/digest"}, + "authorizationDigest": {"$ref": "#/$defs/digest"}, + "releaseNotesDigest": {"$ref": "#/$defs/digest"}, + "coreInfoDigest": {"$ref": "#/$defs/digest"}, + "stableCatalogDigest": {"$ref": "#/$defs/digest"}, + "knownLimitationsDeltaDigest": {"$ref": "#/$defs/digest"}, + "publicationTargetsDigest": {"$ref": "#/$defs/digest"}, + "assets": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/asset"}}, + "stableCatalogTarget": {"$ref": "#/$defs/catalogTarget"}, + "coreUpdateTarget": {"$ref": "#/$defs/coreUpdateTarget"}, + "sideEffectsPerformed": {"const": false}, + "publicationState": {"enum": ["validated", "publication-authorized"]}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "asset": { + "type": "object", "additionalProperties": false, + "required": ["role", "fileName", "digest", "sizeBytes", "publicUri"], + "properties": { + "role": {"enum": ["product", "package", "release-notes", "known-limitations", "checksums", "provenance", "authorization", "core-info", "stable-catalog", "stable-catalog-signature"]}, + "fileName": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "digest": {"$ref": "#/$defs/digest"}, + "sizeBytes": {"type": "integer", "minimum": 1}, + "publicUri": {"type": "string", "format": "uri"} + } + }, + "catalogTarget": { + "type": "object", "additionalProperties": false, + "required": ["catalogId", "channel", "revision", "edition", "digest", "signatureDigest", "publicUri", "signaturePublicUri", "mirrorUris", "rollbackUri", "mirrorSetDigest", "rollbackStateDigest"], + "properties": { + "catalogId": {"$ref": "#/$defs/nonEmpty"}, "channel": {"const": "stable"}, + "revision": {"type": "integer", "minimum": 0}, "edition": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"}, "signatureDigest": {"$ref": "#/$defs/digest"}, + "publicUri": {"type": "string", "format": "uri"}, "signaturePublicUri": {"type": "string", "format": "uri"}, + "mirrorUris": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "format": "uri"}}, + "rollbackUri": {"type": "string", "format": "uri"}, + "mirrorSetDigest": {"$ref": "#/$defs/digest"}, "rollbackStateDigest": {"$ref": "#/$defs/digest"} + } + }, + "coreUpdateTarget": { + "type": "object", "additionalProperties": false, + "required": ["edition", "descriptorDigest", "publicUri", "protectedInsertInputName"], + "properties": { + "edition": {"type": "integer", "minimum": 1}, + "descriptorDigest": {"$ref": "#/$defs/digest"}, + "publicUri": {"type": "string", "format": "uri"}, + "protectedInsertInputName": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]*$"} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-publication-receipt-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-publication-receipt-v1.schema.json new file mode 100644 index 00000000000..1b34b5d60cf --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-publication-receipt-v1.schema.json @@ -0,0 +1,155 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-publication-receipt-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance publication receipt v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "sourceCommit", "githubReleasePageUri", "deploymentServicePublicUri", + "latestPointerPublicUri", "candidateIdentityDigest", "productDigest", "checksumsDigest", + "provenanceDigest", "authorizationDigest", "publicationPlanDigest", "releaseNotesDigest", "coreInfoDigest", "coreUpdateReceiptDigest", + "successorBaselineDigest", "releaseHistoryDigest", "tag", "githubRelease", "assets", + "stableCatalog", "coreUpdate", "workflow", "publicObservations", "publicationState", + "finalVerificationStatus", "failureCategory", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-publication-receipt"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "sourceCommit": {"$ref": "#/$defs/commit"}, + "githubReleasePageUri": {"type": "string", "format": "uri"}, + "deploymentServicePublicUri": {"type": "string", "format": "uri"}, + "latestPointerPublicUri": {"type": "string", "format": "uri"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "productDigest": {"$ref": "#/$defs/digest"}, + "checksumsDigest": {"$ref": "#/$defs/digest"}, + "provenanceDigest": {"$ref": "#/$defs/digest"}, + "authorizationDigest": {"$ref": "#/$defs/digest"}, + "publicationPlanDigest": {"$ref": "#/$defs/digest"}, + "releaseNotesDigest": {"$ref": "#/$defs/digest"}, + "coreInfoDigest": {"$ref": "#/$defs/digest"}, + "coreUpdateReceiptDigest": {"$ref": "#/$defs/digest"}, + "successorBaselineDigest": {"$ref": "#/$defs/digest"}, + "releaseHistoryDigest": {"$ref": "#/$defs/digest"}, + "tag": {"$ref": "#/$defs/tag"}, + "githubRelease": {"$ref": "#/$defs/githubRelease"}, + "assets": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/asset"}}, + "stableCatalog": {"$ref": "#/$defs/stableCatalog"}, + "coreUpdate": {"$ref": "#/$defs/coreUpdate"}, + "workflow": {"$ref": "#/$defs/workflow"}, + "publicObservations": {"$ref": "#/$defs/publicObservations"}, + "publicationState": {"enum": ["publication-complete", "publication-partial", "publication-verification-failed"]}, + "finalVerificationStatus": {"enum": ["pass", "fail"]}, + "failureCategory": {"oneOf": [{"enum": ["tag-conflict", "release-conflict", "asset-conflict", "artifact-base-conflict", "catalog-conflict", "core-update-conflict", "partial-publication", "verification-unavailable"]}, {"type": "null"}]}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "operation": {"enum": ["created", "verified-existing", "partial"]}, + "verification": {"enum": ["verified", "conflict", "unavailable"]}, + "tag": { + "type": "object", "additionalProperties": false, + "required": ["name", "objectType", "targetCommit", "tagObjectDigest", "operation", "verificationStatus"], + "properties": { + "name": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, + "objectType": {"const": "annotated"}, + "targetCommit": {"$ref": "#/$defs/commit"}, + "tagObjectDigest": {"$ref": "#/$defs/digest"}, + "operation": {"$ref": "#/$defs/operation"}, + "verificationStatus": {"$ref": "#/$defs/verification"} + } + }, + "githubRelease": { + "type": "object", "additionalProperties": false, + "required": ["releaseId", "tag", "pageUri", "notesDigest", "operation", "verificationStatus"], + "properties": { + "releaseId": {"$ref": "#/$defs/nonEmpty"}, + "tag": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, + "pageUri": {"type": "string", "format": "uri"}, + "notesDigest": {"$ref": "#/$defs/digest"}, + "operation": {"$ref": "#/$defs/operation"}, + "verificationStatus": {"$ref": "#/$defs/verification"} + } + }, + "asset": { + "type": "object", "additionalProperties": false, + "required": ["role", "fileName", "digest", "sizeBytes", "publicUri", "operation", "verificationStatus"], + "properties": { + "role": {"$ref": "#/$defs/nonEmpty"}, + "fileName": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "digest": {"$ref": "#/$defs/digest"}, + "sizeBytes": {"type": "integer", "minimum": 1}, + "publicUri": {"type": "string", "format": "uri"}, + "operation": {"$ref": "#/$defs/operation"}, + "verificationStatus": {"$ref": "#/$defs/verification"} + } + }, + "stableCatalog": { + "type": "object", "additionalProperties": false, + "required": ["catalogId", "revision", "edition", "digest", "signatureDigest", "publicUri", "signaturePublicUri", "mirrorSetDigest", "rollbackStateDigest", "operation", "verificationStatus"], + "properties": { + "catalogId": {"$ref": "#/$defs/nonEmpty"}, + "revision": {"type": "integer", "minimum": 0}, + "edition": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"}, + "signatureDigest": {"$ref": "#/$defs/digest"}, + "publicUri": {"type": "string", "format": "uri"}, + "signaturePublicUri": {"type": "string", "format": "uri"}, + "mirrorSetDigest": {"$ref": "#/$defs/digest"}, + "rollbackStateDigest": {"$ref": "#/$defs/digest"}, + "operation": {"$ref": "#/$defs/operation"}, + "verificationStatus": {"$ref": "#/$defs/verification"} + } + }, + "coreUpdate": { + "type": "object", "additionalProperties": false, + "required": ["edition", "descriptorDigest", "publicUri", "packageMapDigest", "operation", "verificationStatus"], + "properties": { + "edition": {"type": "integer", "minimum": 1}, + "descriptorDigest": {"$ref": "#/$defs/digest"}, + "publicUri": {"type": "string", "format": "uri"}, + "packageMapDigest": {"$ref": "#/$defs/digest"}, + "operation": {"$ref": "#/$defs/operation"}, + "verificationStatus": {"$ref": "#/$defs/verification"} + } + }, + "workflow": { + "type": "object", "additionalProperties": false, + "required": ["repository", "runId", "runAttempt", "environment", "actor", "attestationDigest"], + "properties": { + "repository": {"const": "crypta-network/cryptad"}, + "runId": {"type": "integer", "minimum": 1}, + "runAttempt": {"type": "integer", "minimum": 1}, + "environment": {"enum": ["stable-1.0-maintenance-publication", "stable-1.0-security-hotfix-publication"]}, + "actor": {"$ref": "#/$defs/nonEmpty"}, + "attestationDigest": {"$ref": "#/$defs/digest"} + } + }, + "publicObservations": { + "type": "object", "additionalProperties": false, + "required": ["tag", "githubRelease", "assets", "artifactBase", "stableCatalog", "coreUpdate"], + "properties": { + "tag": {"$ref": "#/$defs/verification"}, + "githubRelease": {"$ref": "#/$defs/verification"}, + "assets": {"$ref": "#/$defs/verification"}, + "artifactBase": {"$ref": "#/$defs/verification"}, + "stableCatalog": {"$ref": "#/$defs/verification"}, + "coreUpdate": {"$ref": "#/$defs/verification"} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-successor-baseline-v2.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-successor-baseline-v2.schema.json new file mode 100644 index 00000000000..f00111971b3 --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-successor-baseline-v2.schema.json @@ -0,0 +1,224 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-successor-baseline-v2.schema.json", + "title": "Cryptad Stable 1.0 maintenance successor baseline v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "stableMilestone", "status", "gaRoot", + "previousBaselineDigest", "chainDepth", "previousLineageDigest", "lineage", "publication", + "release", "platformApi", + "stableCatalog", "firstPartyApps", "contentFormatProfiles", "limitations", "security", + "support", "legacyBoundaries", "evidenceWindowPolicy", "hotfixFollowUp", + "releaseHistoryDigest", "redaction" + ], + "properties": { + "schemaVersion": {"const": 2}, + "kind": {"const": "stable-1.0-maintenance-successor-baseline"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "stableMilestone": {"const": "1.0"}, + "status": {"const": "published"}, + "gaRoot": {"$ref": "#/$defs/gaRoot"}, + "previousBaselineDigest": {"$ref": "#/$defs/digest"}, + "chainDepth": {"type": "integer", "minimum": 1}, + "previousLineageDigest": {"$ref": "#/$defs/digest"}, + "lineage": {"$ref": "#/$defs/lineage"}, + "publication": {"$ref": "#/$defs/publication"}, + "release": {"$ref": "#/$defs/release"}, + "platformApi": {"$ref": "#/$defs/platformApi"}, + "stableCatalog": {"$ref": "#/$defs/stableCatalog"}, + "firstPartyApps": {"type": "array", "minItems": 7, "maxItems": 7, "uniqueItems": true, "items": {"$ref": "#/$defs/firstPartyApp"}}, + "contentFormatProfiles": {"type": "array", "minItems": 5, "maxItems": 5, "uniqueItems": true, "items": {"$ref": "#/$defs/contentProfile"}}, + "limitations": {"$ref": "#/$defs/limitationsSection"}, + "security": {"$ref": "#/$defs/baselineSection"}, + "support": {"$ref": "#/$defs/baselineSection"}, + "legacyBoundaries": {"$ref": "#/$defs/legacyBoundaries"}, + "evidenceWindowPolicy": {"$ref": "#/$defs/evidenceWindowPolicy"}, + "hotfixFollowUp": {"$ref": "#/$defs/hotfixFollowUp"}, + "releaseHistoryDigest": {"$ref": "#/$defs/digest"}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "commit": {"type": "string", "pattern": "^[0-9a-f]{40,64}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "lineage": { + "type": "object", "additionalProperties": false, + "required": ["gaBaselineDigest", "gaPublicationReceiptDigest", "chainDepth", "lineageDigest", "history"], + "properties": { + "gaBaselineDigest": {"$ref": "#/$defs/digest"}, + "gaPublicationReceiptDigest": {"$ref": "#/$defs/digest"}, + "chainDepth": {"type": "integer", "minimum": 1}, + "lineageDigest": {"$ref": "#/$defs/digest"}, + "history": {"type": "array", "minItems": 2, "uniqueItems": true, "items": {"$ref": "#/$defs/historyEntry"}} + } + }, + "historyEntry": { + "type": "object", "additionalProperties": false, + "required": ["chainDepth", "releaseId", "buildVersion", "tag", "sourceCommit", "releaseClass", "productDigest", "baselineIdentityDigest", "publicationReceiptIdentityDigest", "previousLineageDigest"], + "properties": { + "chainDepth": {"type": "integer", "minimum": 0}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "tag": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, + "sourceCommit": {"$ref": "#/$defs/commit"}, + "releaseClass": {"oneOf": [{"$ref": "#/$defs/releaseClass"}, {"const": "stable-ga"}]}, + "productDigest": {"$ref": "#/$defs/digest"}, + "baselineIdentityDigest": {"$ref": "#/$defs/digest"}, + "publicationReceiptIdentityDigest": {"$ref": "#/$defs/digest"}, + "previousLineageDigest": {"$ref": "#/$defs/digest"} + } + }, + "publication": { + "type": "object", "additionalProperties": false, + "required": ["receiptIdentityDigest", "publicationState", "verificationStatus"], + "properties": { + "receiptIdentityDigest": {"$ref": "#/$defs/digest"}, + "publicationState": {"const": "publication-complete"}, + "verificationStatus": {"const": "pass"} + } + }, + "gaRoot": { + "type": "object", "additionalProperties": false, + "required": ["releaseId", "buildVersion", "tag", "sourceCommit", "productDigest", "maintenanceBaselineDigest", "publicationReceiptDigest"], + "properties": { + "releaseId": {"$ref": "#/$defs/releaseId"}, "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "tag": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, "sourceCommit": {"$ref": "#/$defs/commit"}, + "productDigest": {"$ref": "#/$defs/digest"}, "maintenanceBaselineDigest": {"$ref": "#/$defs/digest"}, + "publicationReceiptDigest": {"$ref": "#/$defs/digest"} + } + }, + "release": { + "type": "object", "additionalProperties": false, + "required": ["releaseId", "buildVersion", "tag", "sourceCommit", "releaseClass", "productDigest", "publicationReceiptIdentityDigest", "checksumsDigest", "provenanceDigest", "coreInfoDigest"], + "properties": { + "releaseId": {"$ref": "#/$defs/releaseId"}, "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "tag": {"type": "string", "pattern": "^v[1-9][0-9]*$"}, "sourceCommit": {"$ref": "#/$defs/commit"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, "productDigest": {"$ref": "#/$defs/digest"}, + "publicationReceiptIdentityDigest": {"$ref": "#/$defs/digest"}, "checksumsDigest": {"$ref": "#/$defs/digest"}, + "provenanceDigest": {"$ref": "#/$defs/digest"}, "coreInfoDigest": {"$ref": "#/$defs/digest"} + } + }, + "platformApi": { + "type": "object", "additionalProperties": false, + "required": ["baselineName", "baselineDigest", "baselineContractVersion", "currentContractVersion", "currentContractDigest", "stableSurfaceDigest", "compatibilityWindowPolicyDigest", "deprecationHistoryDigest", "deprecationHistory"], + "properties": { + "baselineName": {"const": "1.0"}, "baselineDigest": {"$ref": "#/$defs/digest"}, + "baselineContractVersion": {"type": "integer", "minimum": 1}, "currentContractVersion": {"type": "integer", "minimum": 1}, + "currentContractDigest": {"$ref": "#/$defs/digest"}, "stableSurfaceDigest": {"$ref": "#/$defs/digest"}, + "compatibilityWindowPolicyDigest": {"$ref": "#/$defs/digest"}, "deprecationHistoryDigest": {"$ref": "#/$defs/digest"}, + "deprecationHistory": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/deprecationHistoryEntry"}} + } + }, + "deprecationHistoryEntry": { + "type": "object", "additionalProperties": false, + "required": ["kind", "identity", "stability", "deprecatedSinceContractVersion", "removalContractVersion"], + "properties": { + "kind": {"enum": ["capability", "endpoint"]}, "identity": {"$ref": "#/$defs/nonEmpty"}, + "stability": {"enum": ["deprecated", "scheduled-for-removal"]}, + "deprecatedSinceContractVersion": {"type": "integer", "minimum": 1}, + "removalContractVersion": {"oneOf": [{"type": "integer", "minimum": 1}, {"type": "null"}]} + } + }, + "stableCatalog": { + "type": "object", "additionalProperties": false, + "required": ["catalogId", "channel", "revision", "edition", "digest", "signatureFileName", "signatureSizeBytes", "signatureDigest", "signingKeyId"], + "properties": { + "catalogId": {"$ref": "#/$defs/nonEmpty"}, "channel": {"const": "stable"}, + "revision": {"type": "integer", "minimum": 0}, "edition": {"type": "integer", "minimum": 0}, + "digest": {"$ref": "#/$defs/digest"}, + "signatureFileName": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "signatureSizeBytes": {"type": "integer", "minimum": 1}, "signatureDigest": {"$ref": "#/$defs/digest"}, + "signingKeyId": {"$ref": "#/$defs/nonEmpty"} + } + }, + "firstPartyApp": { + "type": "object", "additionalProperties": false, + "required": ["appId", "version", "channel", "supportLevel", "bundleDigest", "reviewReceiptDigest", "appSigningKeyId", "reviewerKeyId", "manifestDigest", "permissionSetDigest", "appDataSchemaVersion", "supportMetadataDigest"], + "properties": { + "appId": {"enum": ["queue-manager", "publisher", "site-publisher", "profile-publisher", "social-inbox", "feed-reader", "trust-graph"]}, + "version": {"$ref": "#/$defs/nonEmpty"}, "channel": {"const": "stable"}, + "supportLevel": {"enum": ["core", "maintained", "local-rc"]}, "bundleDigest": {"$ref": "#/$defs/digest"}, + "reviewReceiptDigest": {"$ref": "#/$defs/digest"}, "appSigningKeyId": {"$ref": "#/$defs/nonEmpty"}, + "reviewerKeyId": {"$ref": "#/$defs/nonEmpty"}, "manifestDigest": {"$ref": "#/$defs/digest"}, + "permissionSetDigest": {"$ref": "#/$defs/digest"}, + "appDataSchemaVersion": {"oneOf": [{"type": "integer", "minimum": 1}, {"const": "not-applicable"}]}, + "supportMetadataDigest": {"$ref": "#/$defs/digest"} + } + }, + "contentProfile": { + "type": "object", "additionalProperties": false, + "required": ["profileId", "version", "status", "descriptorDigest", "canonicalizationRulesDigest", "maximumSizePolicyDigest", "signaturePayloadRulesDigest"], + "properties": { + "profileId": {"enum": ["crypta.profile.v1", "crypta.feed.snapshot.v1", "crypta.trust.statement.v1", "crypta.social.message.v1", "crypta.social.outbox.v1"]}, + "version": {"const": 1}, "status": {"const": "stable"}, "descriptorDigest": {"$ref": "#/$defs/digest"}, + "canonicalizationRulesDigest": {"$ref": "#/$defs/digest"}, "maximumSizePolicyDigest": {"$ref": "#/$defs/digest"}, + "signaturePayloadRulesDigest": {"$ref": "#/$defs/digest"} + } + }, + "baselineSection": { + "type": "object", "additionalProperties": false, + "required": ["currentDigest", "gaBaselineDigest", "predecessorDigest"], + "properties": {"currentDigest": {"$ref": "#/$defs/digest"}, "gaBaselineDigest": {"$ref": "#/$defs/digest"}, "predecessorDigest": {"$ref": "#/$defs/digest"}} + }, + "limitationsSection": { + "type": "object", "additionalProperties": false, + "required": ["currentDigest", "currentIds", "gaBaselineDigest", "predecessorDigest"], + "properties": { + "currentDigest": {"$ref": "#/$defs/digest"}, + "currentIds": { + "type": "array", "uniqueItems": true, + "items": {"$ref": "#/$defs/nonEmpty"} + }, + "gaBaselineDigest": {"$ref": "#/$defs/digest"}, + "predecessorDigest": {"$ref": "#/$defs/digest"} + } + }, + "legacyBoundaries": { + "type": "object", "additionalProperties": false, + "required": ["pluginRuntime", "inCorePluginApi", "legacyAdminMutationRoutes", "fproxyBrowse", "contentFiltering", "emergencyFallbackRoutes"], + "properties": { + "pluginRuntime": {"const": "removed"}, "inCorePluginApi": {"const": "removed"}, + "legacyAdminMutationRoutes": {"const": "disabled"}, "fproxyBrowse": {"const": "retained"}, + "contentFiltering": {"const": "retained"}, "emergencyFallbackRoutes": {"const": "retained"} + } + }, + "evidenceWindowPolicy": { + "type": "object", "additionalProperties": false, + "required": ["windowClass", "policyDigest", "requiredEvidenceDigest", "completedEvidenceDigest"], + "properties": { + "windowClass": {"enum": ["normal", "shortened-security-hotfix"]}, "policyDigest": {"$ref": "#/$defs/digest"}, + "requiredEvidenceDigest": {"$ref": "#/$defs/digest"}, "completedEvidenceDigest": {"$ref": "#/$defs/digest"} + } + }, + "hotfixFollowUp": { + "type": "object", "additionalProperties": false, + "required": ["status", "generatedAt", "obligationDigest", "deadline", "closureEvidenceDigest", "blocksRoutineMaintenance"], + "properties": { + "status": {"enum": ["not-required", "open", "closed", "overdue"]}, + "generatedAt": {"type": "string", "format": "date-time"}, + "obligationDigest": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "deadline": {"oneOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]}, + "closureEvidenceDigest": {"oneOf": [{"$ref": "#/$defs/digest"}, {"type": "null"}]}, + "blocksRoutineMaintenance": {"type": "boolean"}, + "obligatedReleaseId": {"$ref": "#/$defs/releaseId"}, + "obligatedBuildVersion": {"$ref": "#/$defs/buildVersion"}, + "obligatedProductDigest": {"$ref": "#/$defs/digest"}, + "obligatedCandidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "obligatedCandidateFreezeDigest": {"$ref": "#/$defs/digest"}, + "obligatedCandidateFrozenAt": {"type": "string", "format": "date-time"}, + "obligatedPredecessorBuild": {"$ref": "#/$defs/buildVersion"}, + "obligatedPredecessorProductDigest": {"$ref": "#/$defs/digest"}, + "authorizationDigest": {"$ref": "#/$defs/digest"} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/schemas/stable-1.0-maintenance-validation-v1.schema.json b/tools/release-certification/schemas/stable-1.0-maintenance-validation-v1.schema.json new file mode 100644 index 00000000000..0d2e7050b9b --- /dev/null +++ b/tools/release-certification/schemas/stable-1.0-maintenance-validation-v1.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/crypta-network/cryptad/tools/release-certification/schemas/stable-1.0-maintenance-validation-v1.schema.json", + "title": "Cryptad Stable 1.0 maintenance validation v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "kind", "generatedAt", "releaseId", "buildVersion", "releaseClass", + "mode", "lineageDigest", "candidateIdentityDigest", "comparisonDigest", "evidenceDigest", + "coreInfoDigest", "checksumsDigest", "provenanceDigest", "evidenceResults", "blockers", + "warnings", "waivers", "decision", "status", "publicationState", "promotionReady", "redaction" + ], + "properties": { + "schemaVersion": {"const": 1}, + "kind": {"const": "stable-1.0-maintenance-validation"}, + "generatedAt": {"type": "string", "format": "date-time"}, + "releaseId": {"$ref": "#/$defs/releaseId"}, + "buildVersion": {"$ref": "#/$defs/buildVersion"}, + "releaseClass": {"$ref": "#/$defs/releaseClass"}, + "mode": {"enum": ["validate-only", "prepare-authorization"]}, + "lineageDigest": {"$ref": "#/$defs/digest"}, + "candidateIdentityDigest": {"$ref": "#/$defs/digest"}, + "comparisonDigest": {"$ref": "#/$defs/digest"}, + "evidenceDigest": {"$ref": "#/$defs/digest"}, + "coreInfoDigest": {"$ref": "#/$defs/digest"}, + "checksumsDigest": {"$ref": "#/$defs/digest"}, + "provenanceDigest": {"$ref": "#/$defs/digest"}, + "evidenceResults": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/evidenceResult"}}, + "blockers": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/issue"}}, + "warnings": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/issue"}}, + "waivers": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/waiver"}}, + "decision": {"$ref": "#/$defs/decision"}, + "status": {"enum": ["pass", "fail"]}, + "publicationState": {"enum": ["validated", "publication-authorized", "publication-complete", "publication-verification-failed"]}, + "promotionReady": {"type": "boolean"}, + "redaction": {"$ref": "#/$defs/redaction"} + }, + "$defs": { + "nonEmpty": {"type": "string", "minLength": 1}, + "releaseId": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"}, + "buildVersion": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "releaseClass": {"enum": ["maintenance", "security-hotfix"]}, + "decision": {"enum": ["go", "no-go", "go-with-waivers"]}, + "evidenceResult": { + "type": "object", "additionalProperties": false, + "required": ["evidenceId", "status", "evidenceDigest", "candidateBound", "predecessorBound", "fresh", "production"], + "properties": { + "evidenceId": {"$ref": "#/$defs/nonEmpty"}, + "status": {"enum": ["pass", "warn", "fail"]}, + "evidenceDigest": {"$ref": "#/$defs/digest"}, + "candidateBound": {"const": true}, + "predecessorBound": {"const": true}, + "fresh": {"const": true}, + "production": {"const": true} + } + }, + "issue": { + "type": "object", "additionalProperties": false, + "required": ["id", "category", "message", "waivable"], + "properties": { + "id": {"$ref": "#/$defs/nonEmpty"}, "category": {"$ref": "#/$defs/nonEmpty"}, + "message": {"$ref": "#/$defs/nonEmpty"}, "waivable": {"type": "boolean"} + } + }, + "waiver": { + "type": "object", "additionalProperties": false, + "required": ["warningId", "allowlisted", "authorizationDigest"], + "properties": { + "warningId": {"$ref": "#/$defs/nonEmpty"}, + "allowlisted": {"const": true}, + "authorizationDigest": {"$ref": "#/$defs/digest"} + } + }, + "redaction": { + "type": "object", "additionalProperties": false, + "required": ["status", "findingCount", "findings"], + "properties": {"status": {"const": "pass"}, "findingCount": {"const": 0}, "findings": {"type": "array", "maxItems": 0}} + } + } +} diff --git a/tools/release-certification/stable-1.0-maintenance-policy.json b/tools/release-certification/stable-1.0-maintenance-policy.json new file mode 100644 index 00000000000..00048c24460 --- /dev/null +++ b/tools/release-certification/stable-1.0-maintenance-policy.json @@ -0,0 +1,243 @@ +{ + "allowedMaintenanceCategories": [ + "compatible-bug-fixes", + "security-fixes-and-hotfixes", + "platform-api-compatible-additions-and-deprecations", + "stable-catalog-and-app-patch-updates", + "emergency-advisory-and-denylist-updates", + "maintenance-release-upgrade-and-rollback-verification" + ], + "authorization": { + "allowedDecisions": [ + "go", + "no-go", + "go-with-waivers" + ], + "allowedPublicationScope": [ + "tag:create-or-verify", + "github-release:create-or-verify", + "artifact-base:publish-or-verify", + "stable-catalog:publish-or-verify", + "core-update:insert-or-verify", + "successor-baseline:activate", + "release-history:append" + ], + "maintenanceRole": "stable-maintenance-release-manager", + "requiresExpiration": true, + "securityHotfixRole": "stable-security-release-manager", + "wildcardScopeAllowed": false + }, + "branchPolicy": { + "maintenance": { + "baseBranch": "develop", + "branchPattern": "^release/[1-9][0-9]*$" + }, + "security-hotfix": { + "baseBranch": "main", + "branchPattern": "^hotfix/[1-9][0-9]*$", + "requiresCurrentPublishedMainBase": true + } + }, + "catalogAndApps": { + "allowedCatalogChanges": [ + "compatible-app-patch", + "security-app-patch", + "approved-signing-key-rotation", + "emergency-advisory", + "emergency-denylist", + "non-reducing-support-metadata-correction" + ], + "allowedSupportLevels": [ + "core", + "maintained", + "local-rc" + ], + "permissionExpansionRequiresConsentAndRationale": true, + "requiresCatalogVersionAdvanceForIdentityChange": true, + "requiresAppDataMigrationAndBackup": true, + "requiresExactAppIdSet": true, + "requiresStableChannel": true, + "requiresTrustedBundleAndReviewSigners": true, + "supportLevelOrder": [ + "local-rc", + "maintained", + "core" + ] + }, + "component": "stable-maintenance", + "contentProfiles": { + "frozenProfileIds": [ + "crypta.profile.v1", + "crypta.feed.snapshot.v1", + "crypta.trust.statement.v1", + "crypta.social.message.v1", + "crypta.social.outbox.v1" + ], + "inPlaceCanonicalizationChangeAllowed": false, + "inPlaceFieldMeaningChangeAllowed": false, + "inPlaceSignaturePayloadChangeAllowed": false, + "inPlaceValidDocumentRejectionAllowed": false, + "incompatibleChangesRequireNewProfile": true + }, + "evidenceWindows": { + "maximumAgeDays": 14, + "minimumLiveNetworkDurationSeconds": 86400, + "minimumNodeCount": 2, + "minimumOperationCount": 500, + "requiresCandidateBinding": true, + "requiresPredecessorBinding": true, + "requiresProductionEvidence": true + }, + "hotfix": { + "allowedSeverities": [ + "critical" + ], + "allowedShortenedScenarios": [ + "stable-maintenance.live-network-interoperability", + "stable-maintenance.performance", + "stable-maintenance.support-redaction" + ], + "followUpDeadlineHours": 168, + "fullMatrixUnaffectedPackageProofStatus": "not-applicable", + "minimumPrepublicationDurationSeconds": 3600, + "narrowedMatrixUnaffectedPackageProofStatus": "pass", + "nextRoutineReleaseBlockedUntilClosure": true, + "requiresAffectedPlatformMatrix": true, + "requiresExactAffectedKeysForNarrowedMatrix": true, + "requiresIncidentOrAdvisoryId": true, + "requiresNonemptyAffectedPackageKeys": true, + "requiresPublicSafeChangeScopeAudit": true, + "supersedingHotfixMayProceed": true + }, + "kind": "stable-1.0-maintenance-policy", + "limitations": { + "candidateIdListsMustBeSortedAndUnique": true, + "currentDigestProjectionFields": [ + "limitationIds" + ], + "deltaDigestProjectionFields": [ + "predecessorIds", + "addedIds", + "resolvedIds", + "unchangedIds", + "currentIds" + ], + "gaMembershipField": "allowedLimitations[].id", + "requiresDisjointDeltaPartition": true, + "successorMembershipField": "limitations.currentIds" + }, + "nonWaivableBlockers": [ + "stable-maintenance.ga-baseline-authentication", + "stable-maintenance.predecessor-lineage", + "stable-maintenance.candidate-identity", + "stable-maintenance.archive-integrity", + "stable-maintenance.platform-api-compatibility", + "stable-maintenance.catalog-app-compatibility", + "stable-maintenance.content-profile-compatibility", + "stable-maintenance.installation-packaging", + "stable-maintenance.direct-ga-upgrade", + "stable-maintenance.upgrade-rollback-migration-backup", + "stable-maintenance.live-network-interoperability", + "stable-maintenance.performance", + "stable-maintenance.sandbox", + "stable-maintenance.security", + "stable-maintenance.support-redaction", + "stable-maintenance.core-update-descriptor", + "stable-maintenance.authorization", + "stable-maintenance.publication-conflict", + "stable-maintenance.publication-verification", + "stable-maintenance.successor-baseline" + ], + "packageMatrix": { + "assetSigning": { + "exactByteSubjectsRequired": true, + "hostedRunnerRequired": true, + "method": "sigstore-github-attestation", + "perAssetVerificationReceiptRequired": true, + "producerVerificationRequired": true, + "freezeBoundaryReverificationRequired": true, + "privateKeyInputAllowed": false + }, + "portableAssetRequired": true, + "requiredCoreUpdatePackageKeys": [ + "amd64.deb", + "amd64.dmg", + "amd64.exe", + "amd64.rpm" + ], + "supportedArchitectures": [ + "amd64", + "arm64" + ], + "supportedPackageExtensions": [ + "deb", + "rpm", + "dmg", + "exe", + "flatpak", + "snap" + ] + }, + "platformApi": { + "baselineName": "1.0", + "criticalStableRemovalWaiverAllowed": false, + "minimumDeprecationWindowContractVersions": 2, + "minimumRemovalWindowContractVersions": 2, + "originalClockResetAllowed": false, + "requiresCandidateBoundCompatibilityEvidence": true, + "requiresThirdPartyStableSamples": true + }, + "predecessor": { + "allowIntegerBuildGaps": true, + "gaIsFirstPredecessor": true, + "requiresLatestPublishedPointerAfterGa": true, + "requiresNoFork": true, + "requiresPublicationCompleteReceipt": true, + "strictlyIncreasingBuild": true + }, + "profile": "stable-review", + "publication": { + "allowedOperations": [ + "created", + "verified-existing" + ], + "conflictsAreNeverOverwritten": true, + "matchingStateIsIdempotent": true, + "protectedPublicationRequired": true, + "validationIsSideEffectFree": true + }, + "releaseClasses": [ + "maintenance", + "security-hotfix" + ], + "requiredEvidenceIds": [ + "stable-maintenance.ga-baseline-authentication", + "stable-maintenance.predecessor-lineage", + "stable-maintenance.candidate-identity", + "stable-maintenance.archive-integrity", + "stable-maintenance.platform-api-compatibility", + "stable-maintenance.catalog-app-compatibility", + "stable-maintenance.content-profile-compatibility", + "stable-maintenance.installation-packaging", + "stable-maintenance.upgrade-rollback-migration-backup", + "stable-maintenance.live-network-interoperability", + "stable-maintenance.performance", + "stable-maintenance.sandbox", + "stable-maintenance.security", + "stable-maintenance.support-redaction", + "stable-maintenance.core-update-descriptor", + "stable-maintenance.authorization", + "stable-maintenance.publication-conflict", + "stable-maintenance.publication-verification", + "stable-maintenance.successor-baseline", + "stable-maintenance.hotfix-follow-up" + ], + "schemaVersion": 1, + "stableMilestone": "1.0", + "successorBaseline": { + "activateOnlyAfterVerifiedPublication": true, + "gaV1IsImmutableRoot": true, + "latestPointerUpdatedLast": true, + "schemaVersion": 2 + } +}