diff --git a/.github/workflows/wasm-installed-org.yml b/.github/workflows/wasm-installed-org.yml index 5cbe60a4..b5354e22 100644 --- a/.github/workflows/wasm-installed-org.yml +++ b/.github/workflows/wasm-installed-org.yml @@ -16,7 +16,7 @@ on: required: true type: string accepted_executor: - description: Root accepted protected publisher/qualification executor E + description: Root accepted current installed qualifier executor Q required: true type: string promotion_binding_sha256: @@ -42,6 +42,7 @@ permissions: jobs: installed: strategy: + fail-fast: false max-parallel: 1 matrix: feed: [org, public] @@ -72,6 +73,7 @@ jobs: run: | python3 tests/release/wasm/test-consumer-source-mapping.py python3 tests/release/wasm/test-installed-org-workflow.py + python3 tests/release/wasm/test-installed-evidence.py - name: Bind qualification source and immutable published receipts env: GH_TOKEN: ${{ github.token }} @@ -91,6 +93,7 @@ jobs: rustup default 1.90.0 scripts/wasm-release/install-browser-tools.sh - name: Qualify fresh installed consumer + id: consumer shell: bash env: GH_TOKEN: ${{ github.token }} @@ -99,14 +102,57 @@ jobs: set -euo pipefail export NUGET_HTTP_CACHE_PATH="$RUNNER_TEMP/wasm-installed-${{ matrix.feed }}-http-cache" mkdir -p artifacts/wasm-installed-${{ matrix.feed }} - scripts/verify-wasm-package-consumer.sh --feed-only 2>&1 | tee artifacts/wasm-installed-${{ matrix.feed }}/consumer.log - scripts/wasm-release/qualify-supervisor-installed.sh --feed-only "$RUNNER_TEMP/selected-${{ matrix.feed }}" 2>&1 | tee artifacts/wasm-installed-${{ matrix.feed }}/selected.log - cp -a "$RUNNER_TEMP/selected-${{ matrix.feed }}" "$RUNNER_TEMP/selected-${{ matrix.feed }}-browser" "$RUNNER_TEMP/wasm-installed-receipts" artifacts/wasm-installed-${{ matrix.feed }}/ + export WASM_CONSUMER_EVIDENCE="$RUNNER_TEMP/consumer-${{ matrix.feed }}-evidence" + export WASM_CONSUMER_STATUS="$GITHUB_WORKSPACE/artifacts/wasm-installed-${{ matrix.feed }}/consumer-status.json" + phase=initial + command_exit=null + tee_exit=null + record_runtime() { + local original=$? recorded=0 + trap - EXIT + set +e + printf '{"phase":"%s","exitCode":%s,"commandExit":%s,"logExit":%s}\n' "$phase" "$original" "$command_exit" "$tee_exit" > artifacts/wasm-installed-${{ matrix.feed }}/runtime-status.json + recorded=$? + if (( original != 0 )); then exit "$original"; fi + exit "$recorded" + } + trap record_runtime EXIT + run_logged() { + phase=$1 + local log=$2 + shift 2 + set +e + "$@" 2>&1 | tee "$log" + local results=("${PIPESTATUS[@]}") + set -e + command_exit=${results[0]} + tee_exit=${results[1]} + if (( command_exit != 0 )); then return "$command_exit"; fi + return "$tee_exit" + } + run_logged consumer artifacts/wasm-installed-${{ matrix.feed }}/consumer.log scripts/verify-wasm-package-consumer.sh --feed-only + run_logged selected artifacts/wasm-installed-${{ matrix.feed }}/selected.log scripts/wasm-release/qualify-supervisor-installed.sh --feed-only "$RUNNER_TEMP/selected-${{ matrix.feed }}" + phase=complete + - name: Collect bounded installed evidence + if: always() + env: + RUNTIME_OUTCOME: ${{ steps.consumer.outcome }} + shell: bash + run: | + set -euo pipefail + logs="artifacts/wasm-installed-${{ matrix.feed }}" + mkdir -p "$logs" + runtime=1 + [[ "$RUNTIME_OUTCOME" != success ]] || runtime=0 + if [[ ! -e "$logs/runtime-status.json" ]]; then + printf '{"phase":"unreached","exitCode":null,"nativeOutcome":"%s"}\n' "$RUNTIME_OUTCOME" > "$logs/runtime-status.json" + fi + python3 scripts/wasm-release/installed-evidence.py assemble --work "$RUNNER_TEMP/selected-${{ matrix.feed }}" --consumer "$RUNNER_TEMP/consumer-${{ matrix.feed }}-evidence" --receipts "$RUNNER_TEMP/wasm-installed-receipts" --logs "$logs" --runtime "$runtime" --output "$RUNNER_TEMP/installed-${{ matrix.feed }}-evidence" - name: Retain installed qualification output if: always() uses: actions/upload-artifact@v7 with: name: wasm-installed-${{ matrix.feed }}-${{ github.sha }}-${{ inputs.version }} - path: artifacts/wasm-installed-${{ matrix.feed }}/ - if-no-files-found: ignore + path: ${{ runner.temp }}/installed-${{ matrix.feed }}-evidence/ + if-no-files-found: error retention-days: 30 diff --git a/docs/atlas/catalog.json b/docs/atlas/catalog.json index 1fa8c92b..3226337a 100644 --- a/docs/atlas/catalog.json +++ b/docs/atlas/catalog.json @@ -1 +1 @@ -{"records":["0863093e69a16355fa9b90f8e7e29156ea4ad88c4523bc4883568bde831bdf35","231df3792931ad2333d00970a5993b3e13fa9dc38f687899f9fa2c966ed9fbfa","24fdc3980640ba58bedcae6f16d5b467eb7383c31161fb9b6ff10fd2e667b0e5","2b7dfb3a2f76368cb603161cdb6f8fa8c2b8afc3e96659dd37c975921ba5ba66","33df463263533529cca24a0b09937573f836d197cc1a739e9960b1cdd4deb1fd","343077d02ddd266b21d8677f7c256854134f7e3e2d18137b55ebe3f32564c5c0","34391bda3fb56135a2bddf74ab790958c175bc6a69c38175e6f8db792cfd50a0","36a19fefd7024f54f3391ffed19019c10222a514719edd7a44f0cde2a1c48258","3d3a0a302c02cd0bfee86715d09b0211baa8dad3e47402c638fc95c7cfa02e87","3de8718fb3ca9b15212e8bd84fba205995d24636087090a61c63c28a96a745b1","3df4f31fda99ad137c651322ad29809e36380400516399274870b4a4332b4a92","3f09ee199e3c2e808e0f6e74f197f2fdd8158ae099a7195c498ce9466bb78b03","44bda82d6f9a9625a83283bf56a15a1831f287037214dc134eeeae87f1faa869","6c5b38ee260d1f15f8029d699ec1cebceafb5e1407a6341dc7a4b96f4d9d3521","72742cbaa561237f10251e94aa5c61153ab5c7f14fc5771b7a5ebdece1e1cb11","87d6de6f3730cecd75d9a4411bf4dbac4e058127a3ce9f46b07f6de110bc26de","8b1cf51c5f03c0ae19c46ca3ea4a1158acbb6e47bf109ca8c506c5417b51cf9f","95115e1052b5ea4fcd393f5c9b9c6f0298657f0028eee84e16215ede97576cfa","ab4502eff76606ac8e58b623878f3d76e1e0b18f30796aec7c446e2aab8b57c8","c01c2c98f13a65b49113916a2f295e46a03fc4c185ece880267dd86c3f3dee8b","d557525e3ebfad227c8d6db9bb6350d42506fb02b1de8058cb443f4f008c0f67"],"schema":"fsgg.atlas/1"} +{"records":["0863093e69a16355fa9b90f8e7e29156ea4ad88c4523bc4883568bde831bdf35","231df3792931ad2333d00970a5993b3e13fa9dc38f687899f9fa2c966ed9fbfa","24fdc3980640ba58bedcae6f16d5b467eb7383c31161fb9b6ff10fd2e667b0e5","264a2f7887a9b6473cbc24102e1bf9be70e57f6d7ba224138140efa094d541f4","2b7dfb3a2f76368cb603161cdb6f8fa8c2b8afc3e96659dd37c975921ba5ba66","33df463263533529cca24a0b09937573f836d197cc1a739e9960b1cdd4deb1fd","343077d02ddd266b21d8677f7c256854134f7e3e2d18137b55ebe3f32564c5c0","34391bda3fb56135a2bddf74ab790958c175bc6a69c38175e6f8db792cfd50a0","36a19fefd7024f54f3391ffed19019c10222a514719edd7a44f0cde2a1c48258","3d3a0a302c02cd0bfee86715d09b0211baa8dad3e47402c638fc95c7cfa02e87","3de8718fb3ca9b15212e8bd84fba205995d24636087090a61c63c28a96a745b1","3df4f31fda99ad137c651322ad29809e36380400516399274870b4a4332b4a92","3f09ee199e3c2e808e0f6e74f197f2fdd8158ae099a7195c498ce9466bb78b03","44bda82d6f9a9625a83283bf56a15a1831f287037214dc134eeeae87f1faa869","6c5b38ee260d1f15f8029d699ec1cebceafb5e1407a6341dc7a4b96f4d9d3521","72742cbaa561237f10251e94aa5c61153ab5c7f14fc5771b7a5ebdece1e1cb11","87d6de6f3730cecd75d9a4411bf4dbac4e058127a3ce9f46b07f6de110bc26de","8b1cf51c5f03c0ae19c46ca3ea4a1158acbb6e47bf109ca8c506c5417b51cf9f","95115e1052b5ea4fcd393f5c9b9c6f0298657f0028eee84e16215ede97576cfa","ab4502eff76606ac8e58b623878f3d76e1e0b18f30796aec7c446e2aab8b57c8","c01c2c98f13a65b49113916a2f295e46a03fc4c185ece880267dd86c3f3dee8b","d557525e3ebfad227c8d6db9bb6350d42506fb02b1de8058cb443f4f008c0f67"],"schema":"fsgg.atlas/1"} diff --git a/docs/atlas/records/264a2f7887a9b6473cbc24102e1bf9be70e57f6d7ba224138140efa094d541f4.json b/docs/atlas/records/264a2f7887a9b6473cbc24102e1bf9be70e57f6d7ba224138140efa094d541f4.json new file mode 100644 index 00000000..cdce782f --- /dev/null +++ b/docs/atlas/records/264a2f7887a9b6473cbc24102e1bf9be70e57f6d7ba224138140efa094d541f4.json @@ -0,0 +1 @@ +{"citations":[{"path":".github/workflows/wasm-installed-org.yml","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"5d9a89a860e6af539ce4c5e99add2a2d5cc50e217ae36b720a2b8f90a9434221"},{"path":"scripts/wasm-release/bind-installed-release.py","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"8b7b29fb557c4da4a9efb1c72019a7420d35f9b53d20a46ffa873e0d5e907ecc"},{"path":"scripts/wasm-release/promotion.py","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"04daa5cadd0e38c7356283bc69d6ebaefc6beca09689f59b0e9006579beee6cf"},{"path":"scripts/wasm-release/installed-evidence.py","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"7401ebe96e11b8d2b9042edf80b3457304f6d11ab8278a5944cc2b99a47b94d1"},{"path":"scripts/verify-wasm-package-consumer.sh","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"2b705552ff71e2e6bf71162748ec9b26751eb3e69b17d87d71aa5d1762fef3d1"},{"path":"tests/release/wasm/test-installed-evidence.py","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"58acaacfde28c90374562fafd60b218f4b595cbf7015bf48d71fe20b3a3c556f"},{"path":"tests/release/wasm/test-installed-org-workflow.py","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"6af07717fb185f0f86c369fabe25af7bbbe3acf459474e652bcea1c9edaa263a"},{"path":"tests/release/wasm/test-release-promotion.py","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"61e7cc8408712b0770016a5c6ba810dc8d13d5246337bddf56ed5e0cdce0cc08"},{"path":"docs/roadmaps/wasm-shared-01.md","repository":"FS-GG/FS.GG.Game","revision":"f0cc5d26bfb59aad08022c02e88c7b2ea09a0b5e","sha256":"e2266309d5b2eb233ebdd717f9ecac5143b84f53eff11522541d0e8d198d8b35"}],"conflictsWith":[],"id":"game/current-wasm-custody-source-boundary","kind":"boundary","recordedAt":"2026-10-17T08:46:10Z","repository":"FS-GG/FS.GG.Game","schema":"fsgg.atlas/1","status":"recorded","summary":"Current 0.4 publication is accepted at immutable producer/publisher P=E6732d2371d142d5ccc5d2d127f20e4f179800892, treecc71e7cfe73449919376acf775940ff9e8bdab56, original39015233527/attempt1. The first installed operation39016307973/attempt1 remains failed: org completed actual runtime tests, but recursive evidence upload rejected a colon in NuGet HTTP-cache paths and retained zero artifacts; public was cancelled before execution. This is not both-feed acceptance. The selected source correction introduces one clean current qualifier/executor Q separately from immutable publication P: current workflow/checkout/main/manual-event/first-attempt checks address Q, while original qualification, admission, transaction, tag, manifest, SDK, archive/member/hash/signature checks still address P. A complete P-to-Q path/blob/content-hash audit refuses changes outside the finite reviewed binding, evidence, controls and owning-documentation closure. Package/runtime/Worker sources, models, traces, projects, locks, semantic consumer assertions and tool/version inputs remain identical to P. Default publisher writer predicates retain same-current-source constraints; no global identity substitution, historical overlay, repack or package fallback is allowed. Functional citations identify executable f0cc5d26, the connected correction following initial candidate23d4ffb, before its final metadata append; independent final review remains required. The subsequent same-delivery amendment adds exactly the catalog and this content-addressed record path to that finite whitelist and requires exact review; neither citation revision nor source audit omits the final metadata delta. The general consumer projects actual archives, hidden NuGet metadata, raw lock/config, SDK, signature/member output and compiled runtime/Worker proof before temporary cleanup. Independent final collection retains selected proof, actual official/reference/runtime Core, P/Q/native-publication receipts and logs in a bounded portable archive, excluding HTTP/tool caches and node_modules. Original command exit, log-copy failure, collection, cleanup and native upload outcomes remain distinct; missing proof is ineligible. Both independently guarded feeds remain serial, with fail-fast disabled and unchanged45-minute per-job limit. Refusal caps are512MiB total,256MiB per file,32MiB logs,1MiB metadata and1024regular members including manifest; actual complete projection fit and both feed closures must be measured, not inferred. Source/inert checks and old org-only execution do not establish repaired runtime acceptance. Complete applicable coherent CI, a separately selected fresh installed operation and independent artifact/data readback remain owed before consumer adoption; publication stays accepted and original failures/unknown cleanup facts retain their identities.","supersedes":["8b1cf51c5f03c0ae19c46ca3ea4a1158acbb6e47bf109ca8c506c5417b51cf9f"],"tags":["wasm","installed","publication","qualification","source","evidence"],"title":"Installed qualification separates immutable publication from its current reviewed executor"} diff --git a/docs/roadmaps/wasm-shared-01.md b/docs/roadmaps/wasm-shared-01.md index c197aa6c..b0daa11f 100644 --- a/docs/roadmaps/wasm-shared-01.md +++ b/docs/roadmaps/wasm-shared-01.md @@ -1,6 +1,29 @@ # WASM-SHARED-01 — producer qualification successor -## Protected qualification and current checkout-reader correction +## Current 0.4 publication and installed follow-up + +PR714 delivered the current reader to protected producer/publisher +`6732d2371d142d5ccc5d2d127f20e4f179800892`, tree +`cc71e7cfe73449919376acf775940ff9e8bdab56`. Complete protected qualification +[39013804889](https://github.com/FS-GG/FS.GG.Game/actions/runs/39013804889), +attempt 1, passed all eleven maintained gates and retained the independently +accepted 39-file original custody. Original publication +[39015233527](https://github.com/FS-GG/FS.GG.Game/actions/runs/39015233527), +attempt 1, completed and was independently accepted: immutable tag, published +release, original org pair, genuinely signed public pair, SDK, manifest, checksums +and durable transaction retain that producer identity. + +Fresh installed operation +[39016307973](https://github.com/FS-GG/FS.GG.Game/actions/runs/39016307973), +attempt 1, remains failed. Org's actual consumer qualification passed, then evidence +upload failed on NuGet HTTP-cache filenames and produced zero artifacts. Public +was cancelled before execution. The current follow-up separates reviewed qualifier +Q from this immutable published P, repairs bounded evidence retention, and requires +complete new coherent qualification plus a separately selected both-feed installed +operation. Both-feed acceptance and consumer adoption remain open. Earlier source +and operation frontiers below retain their historical scopes. + +## Protected qualification and checkout-reader correction (completed source delivery) PR713 delivered the reviewed terminal correction and coherent 0.4 source to protected main `5e2ebd8d1df0412d4725b31bcb8d380b3c85e48a`, tree @@ -14,8 +37,9 @@ maintained qualification gates, froze the eligible originals and uploaded artifa original packages, SDK, manifest, hashes and bounded evidence. This includes actual source/installed CLR and Fable controls, current 0.3 API classification, fresh 53-default/16-selected trace checks and real browser suites; generation was skipped. -The earlier failures below remain failures. Publication, fresh both-feed installed -acceptance and product adoption have not occurred. +The earlier failures below remain failures. At that earlier frontier, publication, +fresh both-feed installed acceptance and product adoption had not occurred; the +current publication and installed outcomes are recorded above. A subsequent read-only publication preflight found a current native-check reader defect before any publication operation. The required lock-range job @@ -39,11 +63,13 @@ before writers; source delivery grants no publication authority. ## Coherent 0.4 candidate and current published-baseline classification -The terminal API selects stable coherent 0.4.0; it remains unpublished. +The terminal API selects stable coherent 0.4.0, now published at the immutable +producer recorded above. Both .NET packages, Contracts source-profile metadata, SDK version, Rust helper and both Rust examples move together. Browser's exact same-version Contracts dependency continues to derive from `WasmSharedVersion`; no new package identity is introduced. -This is qualified source, not a published version or a product consumer pin. +Source qualification, publication and product consumer activation remain distinct; +the accepted publication does not close the installed/adoption gate. Current release classification compares actual 0.3.0 from producer `16a401692f4c0dee7f6da1a86d0cd49e6ea3ec2c`, tree @@ -79,10 +105,10 @@ requires an explicit exact selected version and refuses absence, matching the ma custody and feed-only callers; its old 0.2 fallback is removed. The existing 0.3 publication and installed evidence retain their original identities. -The separately owned current publisher/installed correction must join this metadata -and independently reviewed genuine generated files before final coherent source CI. -Publication, fresh both-feed installed acceptance, registry inventory and consumer -adoption remain distinct later boundaries. Neither public 0.4 absence observed during +The current publisher correction joined this metadata and independently reviewed +genuine generated files before complete coherent qualification and publication. +Fresh both-feed installed acceptance, registry inventory and consumer adoption +remain distinct boundaries. Neither public 0.4 absence observed during planning nor source preparation establishes organization occupancy or authorization. ## Current source and installed compile boundary @@ -133,7 +159,8 @@ once, retains its real result and retires the session. All success/rejection/ada controls remain across current selected SC2, default SC2 and BAR settings. Failure messages include profile/facade/case. The cohort sweep covered exact ordered refusals, queue population, clocks/timers, callback retirement state, subsequent assertions and -shared CLR/Fable includes; actual execution of the repaired cohort remains owed. +shared CLR/Fable includes; the repaired cohort subsequently passed the complete +PR and protected qualifications recorded above. No runtime policy, model, generator or accepted trace changes are needed. The connected correction annotates shared identity, outcome, settings and boundary @@ -156,7 +183,9 @@ required; builds and in-memory suites are not duplicated. Known-stale phase evid is refused by the actual helper in inert preflight controls. Failure reporting now retains bounded first compiler errors plus the existing tail, preserving first status. The 35-minute job limit is unchanged. Earlier detection is the intended benefit; -actual cold fit, runtime success and cost savings await the next hosted qualification. +the complete current protected qualification subsequently passed in 983 seconds +within its 35-minute job limit. That observation establishes this run, without a +general cold-fit or cost-savings guarantee; changed Q still requires coherent CI. ## Third hosted attempt: complete generated trace preparation @@ -655,3 +684,46 @@ The distinct inspection [37176783775](https://github.com/FS-GG/FS.GG.Game/action The source repair scopes JSON media negotiation to exact GET Actions artifact ZIP and run-log API routes, keeps redirected archive bytes opaque and release-asset binary negotiation unchanged, and distinguishes HTTP Unknown from actual digest drift. The independent installed repair checks the whole restored archive SHA-512 against its cache sidecar, and computes NuGet's compressed unsigned-content SHA-512 against both `.nupkg.metadata` and the lock, with an exact metadata source. The algorithm follows [SDK 10.0.401's pinned NuGet source](https://github.com/dotnet/dotnet/blob/e34a38d2ae1fc26406a317517196e55c68ff83ab/src/nuget-client/src/NuGet.Core/NuGet.Packaging/Signing/Archive/SignedPackageArchiveUtility.cs): remove the signature span and central record, relocate surviving offsets, adjust EOCD fields, and hash the original compressed bytes. Unsupported ZIP64/multidisk/encrypted or ambiguous layouts refuse. Genuine public repository-signature verification, the original org archive, every payload/Worker member, locked versions and fresh exact-feed caches remain required. Existing accepted 0.2 receivers retain their scoped evidence; no signed-public 0.3 installed success is claimed. This changes only existing adapter, test and owning-plan paths within the closed successor allowlist. Source controls cover media roles, distinct HTTP/hash refusals, unsigned and signed hash roles, signature position and data descriptors, malformed archive layouts, and cache/source/member substitutions. Authentic retained UTEL signed archives independently match the pinned primary NuGet content-hash projections; those are offline algorithm counterexamples, not WASM installation or signature-trust evidence. Publication, installed acceptance and activation remain open. A new protected executor and separately admitted distinct inspection are required after source closure; no failed inspection is retried or relabeled, and the original preparation tuple/journal/packages stay unchanged. + + +### Installed qualifier and publication subject (current 0.4 correction) + +Publication of 0.4.0 is accepted at immutable producer/publisher +`6732d2371d142d5ccc5d2d127f20e4f179800892`: original promotion +39015233527/attempt1 published the same original coherent pair, SDK and manifest. +The first installed operation, 39016307973/attempt1, remains failed: org's actual +consumer qualification passed, but recursive evidence upload rejected a colon in +NuGet HTTP-cache paths and retained zero artifacts; public was cancelled before +execution. This is partial runtime evidence, not both-feed installed acceptance. + +The selected repair separates immutable published P=publisher E from the current +read-only qualifier/executor Q. One clean protected Q checkout records the entire +P→Q path/blob/content-hash closure and refuses changes outside the finite reviewed +installed binding, evidence projection, controls and owning documentation set. +Package/runtime/Worker sources, model, traces, semantic consumer assertions, +projects, locks, tool profiles and version inputs remain identical to P. Original +qualification, admission, publication transaction and tag still authenticate P; +current workflow/checkout/main/manual-event/first-attempt checks authenticate Q. +Publisher writer commands retain their same-current-source constraints. Historical +overlays, identity mutation, source DLL substitution and repacking originals are +not admitted. + +The general consumer projects its actual archives, hidden NuGet metadata, raw +lock/config, SDK and compiled browser/runtime outputs before temporary cleanup, +including on failure. A separate always-run collector retains selected logs, +locked packages, actual official/reference/runtime Core and restored Worker +outputs plus the original publication and P/Q receipts in a portable closed tar +archive. HTTP/tool caches and node_modules are excluded. The consumer's original +exit wins over collector/cleanup errors; command, log-copy, collection and native +upload outcomes remain distinct. Both independently guarded feeds run serially +with fail-fast disabled, preserving the existing 45-minute per-job limit. + +Projection refuses over 512 MiB total, 256 MiB per file, 32 MiB logs, 1 MiB total +metadata or 1024 regular members (including its own manifest). Actual archive, +Core, Worker and compiled-output inventories/hashes must be read back; the caps +are refusal limits, not measured cold-run guarantees or process RSS limits. +Inert controls exercise the actual source audit, collector and shell first-cause +paths. Complete coherent CI and a separately selected new both-feed installed run +remain required; neither local source controls nor the original org-only runtime +success authorize consumer adoption. Publication bytes and both failed/successful +original identities remain unchanged. diff --git a/scripts/verify-wasm-package-consumer.sh b/scripts/verify-wasm-package-consumer.sh index c51d5484..9a68818a 100755 --- a/scripts/verify-wasm-package-consumer.sh +++ b/scripts/verify-wasm-package-consumer.sh @@ -7,8 +7,41 @@ value="${2:-}" [[ "$mode" == --custody || "$mode" == --feed-only ]] || { echo "usage: verify-wasm-package-consumer.sh --custody | --feed-only" >&2; exit 2; } +if [[ "$mode" == --feed-only && "${MODE:-}" == installed ]]; then + evidence="$(realpath -m "${WASM_CONSUMER_EVIDENCE:?requires fresh explicit retained evidence}")" + case "$evidence/" in "$repo/"*) echo 'consumer evidence must be outside source' >&2; exit 2;; esac + [[ ! -e "$evidence" && ! -L "$evidence" && -n "${WASM_CONSUMER_STATUS:-}" ]] || { echo 'consumer evidence/status must be fresh and explicit' >&2; exit 2; } +fi work="$(mktemp -d "${TMPDIR:-/tmp}/wasm-package-consumer.XXXXXX")" -trap 'rm -rf "$work"' EXIT +finish_consumer() { + local runtime=$? collection=0 cleanup=0 projection=0 current_http= + trap - EXIT + set +e + if [[ "$mode" == --feed-only && -n "${WASM_CONSUMER_EVIDENCE:-}" ]]; then + if [[ "${consumer_http_started:-0}" == 1 ]]; then + current_http="$(stat -c '%d:%i:%s:%y' -- /tmp/fsgg-wasm-package-consumer-http.log 2>/dev/null)" + if [[ -n "$current_http" && "$current_http" != "$consumer_http_before" ]]; then + cp -P -- /tmp/fsgg-wasm-package-consumer-http.log "$work/consumer-http.log" + collection=$? + else + collection=1 + fi + fi + python3 "$repo/scripts/wasm-release/installed-evidence.py" consumer --work "$work" --output "$WASM_CONSUMER_EVIDENCE" --runtime "$runtime" + projection=$? + if (( collection == 0 )); then collection=$projection; fi + fi + rm -rf -- "$work" + cleanup=$? + if [[ -n "${WASM_CONSUMER_STATUS:-}" ]]; then + printf '{"runtime":%s,"collection":%s,"cleanup":%s,"descendants":"unproved"}\n' "$runtime" "$collection" "$cleanup" > "$WASM_CONSUMER_STATUS" + [[ $? == 0 ]] || collection=1 + fi + if (( runtime != 0 )); then exit "$runtime"; fi + if (( collection != 0 )); then exit "$collection"; fi + exit "$cleanup" +} +trap finish_consumer EXIT mkdir -p "$work/sdk-artifacts" "$work/modules" "$work/lock-source" "$work/consumer/browser" manifest="$work/release-manifest.json" @@ -112,11 +145,18 @@ mkdir -p "$output/modules" cp "$work/modules/"*.wasm "$output/modules/" printf '%s %s\n' 1557e76b7a97b8e301c31c1e082c9060d8b66258a23308dcf9d2d17dbfd9de7d "$work/consumer/browser/fixtures/bar-conformance.wasm" | sha256sum --check --strict cp "$work/consumer/browser/fixtures/bar-conformance.wasm" "$output/modules/" +consumer_http_before="$(stat -c '%d:%i:%s:%y' -- /tmp/fsgg-wasm-package-consumer-http.log 2>/dev/null || true)" +consumer_http_started=1 ( cd "$work/consumer/browser" npm ci ./run.sh "$work/consumer/public" ) +if [[ "$mode" == --feed-only && "${MODE:-}" == installed ]]; then + # Authenticate this consumer's actual fresh cache too, preserving its own + # signed public bytes/signature log before the owner trap removes the work. + python3 "$repo/scripts/wasm-release/promotion.py" check-installed --manifest "$RUNNER_TEMP/wasm-installed-receipts/promotion-binding.json" --cache "$work/consumer-packages" --feed "$WASM_PACKAGE_FEED" --lock "$work/consumer/packages.lock.json" > "$work/installed-member-receipt.log" +fi lock_sha="$(sha256sum "$work/consumer/packages.lock.json" | cut -d' ' -f1)" printf 'wasm-package-consumer: mode=%s version=%s lock-sha256=%s modules=rust2,c4 base=/sub/app publication=none\n' \ "${mode#--}" "$version" "$lock_sha" diff --git a/scripts/verify-wasm-workflow.sh b/scripts/verify-wasm-workflow.sh index 60fca2a4..2602c2ba 100755 --- a/scripts/verify-wasm-workflow.sh +++ b/scripts/verify-wasm-workflow.sh @@ -421,6 +421,7 @@ print('wasm-supervisor-phase-preflight: actual helper identity/status/assembly g PYPHASE "$repo/tests/release/wasm/test-release-wasm.sh" --source-only python3 "$repo/tests/release/wasm/test-installed-org-workflow.py" +python3 "$repo/tests/release/wasm/test-installed-evidence.py" python3 "$repo/tests/release/wasm/test-api-baseline-policy.py" for control in \ 'rust-bar.wasm' 'rust-sc2.wasm' 'c-bar.wasm' 'c-sc2.wasm' \ diff --git a/scripts/wasm-release/bind-installed-release.py b/scripts/wasm-release/bind-installed-release.py index c5851ca8..74ff49a6 100644 --- a/scripts/wasm-release/bind-installed-release.py +++ b/scripts/wasm-release/bind-installed-release.py @@ -13,6 +13,24 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] REPO = "FS-GG/FS.GG.Game" +PUBLISHED = "6732d2371d142d5ccc5d2d127f20e4f179800892" +PUBLISHED_TREE = "cc71e7cfe73449919376acf775940ff9e8bdab56" +# Closed repair closure. Root selection binds the exact Q; this is not authority +# for arbitrary descendants or semantic changes inside a broad scripts prefix. +INSTALLED_PATHS = frozenset({ + '.github/workflows/wasm-installed-org.yml', + 'scripts/wasm-release/bind-installed-release.py', + 'scripts/wasm-release/promotion.py', + 'scripts/wasm-release/installed-evidence.py', + 'scripts/verify-wasm-package-consumer.sh', + 'tests/release/wasm/test-installed-org-workflow.py', + 'tests/release/wasm/test-release-promotion.py', + 'tests/release/wasm/test-installed-evidence.py', + 'scripts/verify-wasm-workflow.sh', + 'docs/roadmaps/wasm-shared-01.md', + 'docs/atlas/catalog.json', + 'docs/atlas/records/264a2f7887a9b6473cbc24102e1bf9be70e57f6d7ba224138140efa094d541f4.json', +}) def checked_inputs(environment, tracked_version): @@ -27,7 +45,7 @@ def checked_inputs(environment, tracked_version): values[key] = environment[key] assert re.fullmatch(r"[0-9a-f]{%d}" % length, values[key]), "invalid explicit executor/transaction binding" assert values["RELEASE_VERSION"] == "0.4.0", "historical release is not the selected current route" - assert values["PUBLISHED_SOURCE"] == values["QUALIFIER_SOURCE"] == values["ACCEPTED_EXECUTOR"], "current producer must equal executor/qualifier" + assert values["PUBLISHED_SOURCE"] == PUBLISHED and values["QUALIFIER_SOURCE"] == values["ACCEPTED_EXECUTOR"], "immutable publication and selected current qualifier required" assert not environment.get("EXECUTION_BINDING") and not environment.get("CANONICAL_QUALIFICATION_AUDIT"), "historical overlays cannot authorize current qualification" return values @@ -56,13 +74,40 @@ def validate_release(release, values): def git(*args): - return subprocess.check_output(["git", "-C", str(ROOT), *args], text=True).strip() + return subprocess.check_output(["git", "-C", str(ROOT), *args], text=True, timeout=30).strip() + + +def source_audit(source, qualifier): + assert source == PUBLISHED and git('rev-parse', source+'^{tree}') == PUBLISHED_TREE, 'wrong immutable publication subject' + subprocess.run(['git','-C',str(ROOT),'merge-base','--is-ancestor',source,qualifier], check=True, timeout=30) + raw = subprocess.check_output(['git','-C',str(ROOT),'diff','--raw','--no-renames','-z',source,qualifier], timeout=30) + chunks = raw.decode().split('\0'); rows=[] + for i in range(0,len(chunks)-1,2): + mode_old,mode_new,old_blob,new_blob,status=chunks[i].split() + path=chunks[i+1] + assert path in INSTALLED_PATHS and mode_new in ('100644','100755') and mode_old in (':'+mode_new,':000000'), 'unreviewed installed source path/type' + def fingerprint(revision, blob): + if set(blob) == {'0'}: return None + body=subprocess.check_output(['git','-C',str(ROOT),'show',revision+':'+path], timeout=30) + actual=git('rev-parse',revision+':'+path) + return {'blob':actual,'sha256':hashlib.sha256(body).hexdigest(),'bytes':len(body)} + rows.append({'path':path,'status':status,'old':fingerprint(source,old_blob),'new':fingerprint(qualifier,new_blob)}) + assert rows, 'installed correction must be an explicit reviewed source change' + return {'schema':'fsgg.wasm.installed-inputs/v1','publishedSource':source,'publishedTree':PUBLISHED_TREE, + 'qualifierSource':qualifier,'qualifierTree':git('rev-parse',qualifier+'^{tree}'),'changes':rows, + 'consumerInputs':'all paths outside exact reviewed repair closure byte-identical'} def bind_executor(source, executor, version): - assert version == '0.4.0' and source == executor == os.environ['ACCEPTED_EXECUTOR'] == os.environ['GITHUB_SHA'] == git('rev-parse','HEAD'), 'current installed inputs require identical protected producer/executor' - assert not git('status','--porcelain'), 'dirty installed producer' - return {'consumerInputs':'identical-protected-current-source','producer':source,'acceptedExecutor':executor} + assert version == '0.4.0' and executor == os.environ['ACCEPTED_EXECUTOR'] == os.environ['QUALIFIER_SOURCE'] == os.environ['GITHUB_SHA'] == os.environ['GITHUB_WORKFLOW_SHA'] == git('rev-parse','HEAD'), 'wrong actual installed qualifier/executor' + assert os.environ['GITHUB_REPOSITORY'] == REPO and os.environ['GITHUB_REF'] == 'refs/heads/main' and os.environ['GITHUB_EVENT_NAME'] == 'workflow_dispatch' and os.environ['GITHUB_RUN_ATTEMPT'] == '1', 'foreign/rerun installed execution' + assert os.environ['GITHUB_WORKFLOW_REF'] == REPO+'/.github/workflows/wasm-installed-org.yml@refs/heads/main', 'foreign installed workflow' + assert os.environ['GITHUB_JOB'] == 'installed' and re.fullmatch('[1-9][0-9]*',os.environ['GITHUB_RUN_ID']), 'wrong installed native run/job' + assert os.environ['WASM_PACKAGE_FEED'] in ('https://api.nuget.org/v3/index.json','https://nuget.pkg.github.com/FS-GG/index.json'), 'foreign installed feed' + assert not git('status','--porcelain'), 'dirty installed qualifier' + audit=source_audit(source, executor) + audit['execution']={'run':int(os.environ['GITHUB_RUN_ID']),'attempt':1,'job':'installed','workflowRef':os.environ['GITHUB_WORKFLOW_REF'],'feed':os.environ['WASM_PACKAGE_FEED']} + return audit def bind_qualification_inputs(source, qualifier_source, version, release_binding=None): @@ -71,14 +116,30 @@ def bind_qualification_inputs(source, qualifier_source, version, release_binding def bind_promotion_execution(promotion, transaction, values, release, receipts): - reviewed=promotion.validate_tuple(transaction['preparation']) + subject=promotion.PublicationSubject(values['PUBLISHED_SOURCE'], PUBLISHED_TREE, values['RELEASE_VERSION']) + reviewed=promotion.validate_tuple(transaction['preparation'],subject=subject) assert promotion.execution_input() is None - assert reviewed['binding']['producer'] == reviewed['binding']['executor'] == values['PUBLISHED_SOURCE'] == values['ACCEPTED_EXECUTOR'] + assert reviewed['binding']['producer'] == reviewed['binding']['executor'] == values['PUBLISHED_SOURCE'] native=promotion.Native() - promotion.execution_checkout(native,reviewed,installed=True) - _,actual=promotion.release_state(native) + assert native.checked(f'repos/{REPO}/git/ref/heads/main')['object']['sha'] == values['QUALIFIER_SOURCE'], 'protected qualifier moved' + # Authenticate the original successful qualification, artifact and admission + # against P, not against this read-only installed executor Q. + run_id=reviewed['binding']['runId']; attempt=reviewed['binding']['runAttempt'] + run=native.checked(f'repos/{REPO}/actions/runs/{run_id}/attempts/{attempt}') + jobs=native.pages(f'repos/{REPO}/actions/runs/{run_id}/attempts/{attempt}/jobs','jobs') + artifact=native.checked(f'repos/{REPO}/actions/artifacts/{reviewed["artifactId"]}') + promotion.validate_run(reviewed,run,jobs,artifact) + (receipts / "published-native.json").write_text(json.dumps({"release":release,"qualificationRun":run,"qualificationJobs":jobs,"qualificationArtifact":artifact},sort_keys=True,indent=2)+"\n") + _,actual=promotion.release_state(native,subject=subject) assert actual==release, 'native tag/list/by-ID published release disagreement' - promotion.verify_transaction(native,release,reviewed) + original=promotion.verify_transaction(native,release,reviewed,subject=subject) + published=native.checked(f'repos/{REPO}/actions/runs/{original["firstPromotionRun"]}/attempts/{original["firstPromotionAttempt"]}') + assert published['status']=='completed' and published['conclusion']=='success' and published['run_attempt']==1, 'original publication incomplete/failed/rerun' + publication_jobs=native.pages(f'repos/{REPO}/actions/runs/{original["firstPromotionRun"]}/attempts/{original["firstPromotionAttempt"]}/jobs','jobs') + for name in ('preflight','admission','begin','org','public','assets','complete'): + matches=[row for row in publication_jobs if row['name']==name] + assert len(matches)==1 and matches[0]['conclusion']=='success', 'original publication stage absent/failed' + (receipts / 'published-native.json').write_text(json.dumps({'release':release,'qualificationRun':run,'qualificationJobs':jobs,'qualificationArtifact':artifact,'publicationRun':published,'publicationJobs':publication_jobs},sort_keys=True,indent=2)+'\n') return reviewed @@ -91,7 +152,7 @@ def main(): tag = "wasm/v" + values["RELEASE_VERSION"] assert git("rev-parse", f"refs/tags/{tag}^{{commit}}") == source, "tag does not name published source" subprocess.run(["git", "-C", str(ROOT), "merge-base", "--is-ancestor", source, "origin/main"], check=True) - # Current installed acceptance uses the identical protected producer inputs. + # Read-only qualification authenticates immutable published P separately from Q. spec=importlib.util.spec_from_file_location('promotion',ROOT/'scripts/wasm-release/promotion.py') promotion=importlib.util.module_from_spec(spec);spec.loader.exec_module(promotion) release=promotion.Native().checked(f"repos/{REPO}/releases/tags/wasm%2Fv{values['RELEASE_VERSION']}") @@ -116,7 +177,7 @@ def main(): spec = importlib.util.spec_from_file_location("promotion", ROOT / "scripts/wasm-release/promotion.py") promotion = importlib.util.module_from_spec(spec); spec.loader.exec_module(promotion) transaction = json.loads((receipts / "promotion-binding.json").read_text()) - reviewed = promotion.validate_tuple(transaction["preparation"]) + reviewed = promotion.validate_tuple(transaction["preparation"], subject=promotion.PublicationSubject(source, PUBLISHED_TREE, values["RELEASE_VERSION"])) bound = reviewed["binding"] assert transaction["schema"] == "fsgg.wasm.promotion/v1" and transaction["producer"] == source and transaction["tag"] == tag bind_promotion_execution(promotion,transaction,values,release,receipts) @@ -124,6 +185,7 @@ def main(): assert bound["archives"][sdk["file"]] == values["SDK_SHA256"] assert {row["file"]:row["sha256"] for row in manifest["artifacts"]} == bound["archives"] input_audit = bind_qualification_inputs(source, values["QUALIFIER_SOURCE"], values["RELEASE_VERSION"], release_binding) + (receipts / "installed-input-audit.json").write_text(json.dumps(input_audit, sort_keys=True, indent=2)+"\n") print("wasm-installed-input-audit: " + json.dumps(input_audit, sort_keys=True)) print(f"wasm-installed-binding: qualifier={values['QUALIFIER_SOURCE']} published={source} tag={tag} native-assets=verified manifest={values['MANIFEST_SHA256']} sdk={values['SDK_SHA256']}") diff --git a/scripts/wasm-release/installed-evidence.py b/scripts/wasm-release/installed-evidence.py new file mode 100644 index 00000000..d28f24bb --- /dev/null +++ b/scripts/wasm-release/installed-evidence.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Closed installed proof projection. Never copies HTTP/tool caches or follows links.""" +import argparse +import hashlib +import io +import json +import os +import pathlib +import stat +import tarfile + +ROOT = pathlib.Path(__file__).resolve().parents[2] +MIB = 1024 * 1024 +LIMITS = dict(total=512*MIB, file=256*MIB, logs=32*MIB, metadata=MIB, members=1024) +MANIFEST_RESERVE = 256 * 1024 +PAYLOAD_RESERVE = 4 * MIB +PACKAGES = ('fs.gg.wasm.contracts', 'fs.gg.wasm.browser') +SUFFIXES = {'.js', '.mjs', '.wasm', '.html', '.json', '.dll', '.pdb', '.xml', '.map'} +LOGS = ('installed-build.log','installed-locked-build.log','installed-dotnet.log', + 'installed-fable-build.log','installed-fable.log','installed-raw-numeric.log', + 'installed-lock-refusal.log','installed-member-receipt.log') + + +def safe_name(name): + p = pathlib.PurePosixPath(name) + if not name or len(name.encode('utf-8'))>240 or p.is_absolute() or any(x in ('', '.', '..') for x in name.split('/')) or '\\' in name: + raise ValueError('unsafe evidence member') + return name + + +def consumer_member(name): + safe_name(name) + fixed={'consumer/release-manifest.json','consumer/NuGet.Config','consumer/consumer/packages.lock.json', + 'consumer/fsgg-wasm-sdk-0.4.0.tar.gz','consumer/installed-member-receipt.log','consumer/browser-result.json','consumer/http.log','consumer/public/sub/app/Consumer'} + if name in fixed:return True + for ident in PACKAGES: + if name in {'consumer/packages/'+ident+'/'+leaf for leaf in (ident+'.0.4.0.nupkg',ident+'.0.4.0.nupkg.sha512','.nupkg.metadata')}:return True + if name in {'consumer/core/'+leaf for leaf in ('fsharp.core.10.1.302.nupkg','fsharp.core.10.1.302.nupkg.sha512','.nupkg.metadata','reference-netstandard2.0.dll','reference-netstandard2.1.dll')}:return True + return name.startswith('consumer/public/sub/app/') and pathlib.PurePosixPath(name).suffix in SUFFIXES and not set(pathlib.PurePosixPath(name).parts)&{'node_modules','http-cache','lock-http-cache','.git'} + + +def regular(path): + for parent in (path, *path.parents): + if parent.is_symlink(): raise ValueError('linked evidence path') + row=path.stat() + if not stat.S_ISREG(row.st_mode) or row.st_nlink != 1: raise ValueError('nonregular/linked evidence file') + return row + + +class Projection: + def __init__(self): + self.files={}; self.errors=[]; self.total=0; self.logs=0; self.metadata=0 + def add(self, path, name, required=True): + path=pathlib.Path(path) + try: + safe_name(name) + if name in self.files: raise ValueError('duplicate evidence member') + if not path.exists() and not path.is_symlink(): + if required: raise ValueError('absent required evidence') + return + row=regular(path) + if row.st_size > LIMITS['file']: raise ValueError('per-file evidence cap') + category='logs' if name.endswith('.log') else 'metadata' if name.endswith(('.json','.sha512','.Config')) or name.endswith('/.nupkg.metadata') else None + if self.total+row.st_size > LIMITS['total']-PAYLOAD_RESERVE or len(self.files)+3 > LIMITS['members']: raise ValueError('aggregate evidence cap') + if category and getattr(self,category)+row.st_size > LIMITS[category]-(2*MANIFEST_RESERVE if category=='metadata' else 0): raise ValueError(category+' evidence cap') + body=path.read_bytes() + if len(body)!=row.st_size or (path.stat().st_ino,path.stat().st_size,path.stat().st_mtime_ns)!=(row.st_ino,row.st_size,row.st_mtime_ns): raise ValueError('evidence changed during collection') + self.files[name]=body; self.total+=len(body) + if category: setattr(self,category,getattr(self,category)+len(body)) + except (OSError,ValueError) as error: + self.errors.append({'member':name,'status':'absent' if not path.exists() else 'refused','reason':str(error)}) + def tree(self, path, prefix): + path=pathlib.Path(path) + if not path.exists(): + self.errors.append({'member':prefix,'status':'absent','reason':'required compiled output absent'}); return + try: + if path.is_symlink(): raise ValueError('linked evidence directory') + # Enumerate only bounded compiled outputs; hidden/tool/cache paths are + # not inputs to this operation and cannot enter the archive. + count=0 + for directory, dirs, files in os.walk(path,followlinks=False): + for name in list(dirs): + if name in ('node_modules','http-cache','lock-http-cache','.git') or pathlib.Path(directory,name).is_symlink(): + self.errors.append({'member':prefix+'/'+str(pathlib.Path(directory,name).relative_to(path)),'status':'refused','reason':'linked/cache/tool output directory'}) + dirs.remove(name) + for name in files: + count+=1 + if count>LIMITS['members']: raise ValueError('compiled output census cap') + file=pathlib.Path(directory,name) + if file.suffix not in SUFFIXES and name!='http.log' and not (prefix=='consumer/public' and file.relative_to(path).as_posix()=='sub/app/Consumer'): + self.errors.append({'member':prefix+'/'+file.relative_to(path).as_posix(),'status':'refused','reason':'unreviewed compiled output suffix'}); continue + self.add(file,prefix+'/'+file.relative_to(path).as_posix()) + except (OSError,ValueError) as error: + self.errors.append({'member':prefix,'status':'refused','reason':str(error)}) + def packages(self, cache, prefix): + for ident in PACKAGES: + base=pathlib.Path(cache)/ident/'0.4.0' + for leaf in (ident+'.0.4.0.nupkg',ident+'.0.4.0.nupkg.sha512','.nupkg.metadata'): + self.add(base/leaf,prefix+'/'+ident+'/'+leaf) + def core(self, cache, prefix): + base=pathlib.Path(cache)/'fsharp.core/10.1.302' + for name in ('fsharp.core.10.1.302.nupkg','fsharp.core.10.1.302.nupkg.sha512','.nupkg.metadata'): + self.add(base/name,prefix+'/'+name) + for tfm in ('netstandard2.0','netstandard2.1'): + self.add(base/'lib'/tfm/'FSharp.Core.dll',prefix+'/reference-'+tfm+'.dll') + def require(self, names): + for name in names: + if name not in self.files: + self.errors.append({'member':name,'status':'absent','reason':'mandatory actual Core/Worker/runtime output absent'}) + def finish(self, output, runtime): + output=pathlib.Path(output) + if output.exists(): raise ValueError('evidence output must be fresh') + output.mkdir(mode=0o700,parents=True) + rows=[{'path':name,'bytes':len(body),'sha256':hashlib.sha256(body).hexdigest()} for name,body in sorted(self.files.items())] + manifest={'schema':'fsgg.wasm.installed-evidence/v1','runtimeStatus':runtime, + 'collectorStatus':'failed' if self.errors else 'passed','eligible':runtime==0 and not self.errors, + 'limits':LIMITS,'members':rows,'errors':self.errors, + 'cleanup':'recorded separately; descendant settlement unproved','upload':'pending native upload result'} + data=(json.dumps(manifest,sort_keys=True,indent=2)+'\n').encode() + if len(data)>MANIFEST_RESERVE or self.total+len(data)>LIMITS['total'] or self.metadata+2*len(data)>LIMITS['metadata']: raise ValueError('manifest cap') + # Only safe portable outer names reach upload-artifact; inner original + # hidden metadata is explicitly retained independent of provider defaults. + def write_archive(files, metadata): + with tarfile.open(output/'installed-evidence.tar.gz','w:gz') as archive: + for name,body in sorted(files.items()): + row=tarfile.TarInfo(safe_name(name)); row.size=len(body); row.mode=0o600 + archive.addfile(row,io.BytesIO(body)) + row=tarfile.TarInfo('evidence-manifest.json');row.size=len(metadata);row.mode=0o600;archive.addfile(row,io.BytesIO(metadata)) + write_archive(self.files,data) + if (output/'installed-evidence.tar.gz').stat().st_size+len(data)>LIMITS['total']: + # At most one fallback pass: retain independently bounded diagnostics, + # mark binary proof omitted/ineligible, preserve the original exit. + retained={name:body for name,body in self.files.items() if name.endswith(('.log','.json','.sha512','.Config','/.nupkg.metadata'))} + self.errors.append({'member':'archive','status':'truncated','reason':'archive plus sidecar upload cap','omittedMembers':len(self.files)-len(retained)}) + manifest.update(collectorStatus='failed',eligible=False, + members=[row for row in rows if row['path'] in retained]) + data=(json.dumps(manifest,sort_keys=True,indent=2)+'\n').encode() + if len(data)>MANIFEST_RESERVE or self.metadata+2*len(data)>LIMITS['metadata']: raise ValueError('fallback manifest cap') + write_archive(retained,data) + if (output/'installed-evidence.tar.gz').stat().st_size+len(data)>LIMITS['total']: + (output/'installed-evidence.tar.gz').unlink() + raise ValueError('diagnostic archive plus sidecar cap') + (output/'evidence-manifest.json').write_bytes(data) + return 1 if self.errors else 0 + + +def consumer(projection, work): + work=pathlib.Path(work) + for name in ('release-manifest.json','NuGet.Config','consumer/packages.lock.json'): + projection.add(work/name,'consumer/'+name) + projection.add(work/'sdk-artifacts/fsgg-wasm-sdk-0.4.0.tar.gz','consumer/fsgg-wasm-sdk-0.4.0.tar.gz') + projection.add(work/'installed-member-receipt.log','consumer/installed-member-receipt.log') + projection.add(work/'consumer-http.log','consumer/http.log') + projection.packages(work/'consumer-packages','consumer/packages') + projection.core(work/'consumer-packages','consumer/core') + projection.tree(work/'consumer/public','consumer/public') + projection.add(work/'consumer/browser/test-results/.last-run.json','consumer/browser-result.json') + projection.require(['consumer/public/sub/app/'+name for name in ( + 'FSharp.Core.dll','Consumer.dll','fable/Program.js', + '_content/FS.GG.Wasm.Browser/module-worker.mjs', + '_content/FS.GG.Wasm.Browser/worker-client.mjs', + '_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js')]) + # Consumer.fsproj intentionally places the actual DLL/Core/runtime outputs + # in consumer/public/sub/app, already included above. + + + +def selected(projection, work): + work=pathlib.Path(work) + for name in (*LOGS,'installed.packages.lock.json','installed.bad-hash.lock.json','official-core-joins.json','NuGet.Config','release-manifest.json','promotion-binding.json'): + original = pathlib.Path(str(work)+'-NuGet.Config') if name=='NuGet.Config' else work/name + if name in ('release-manifest.json','promotion-binding.json'): + original=pathlib.Path(os.environ.get('RUNNER_TEMP',''))/'wasm-installed-receipts'/name + projection.add(original,'selected/'+name) + projection.packages(work/'packages','selected/packages') + projection.core(work/'packages','selected/core') + baseline=pathlib.Path(str(work)+'-baseline') + for name in ('receipt.json','fsharp.core.10.1.302.nupkg','fsharp.core.10.1.401.nupkg','fsharp.core.10.1.203.nupkg', + 'FSharp.Core.10.1.302.netstandard2.0.dll','FSharp.Core.10.1.302.netstandard2.1.dll', + 'FSharp.Core.10.1.401.netstandard2.0.dll','FSharp.Core.10.1.401.netstandard2.1.dll','FSharp.Core.10.1.203.netstandard2.1.dll'): + projection.add(baseline/'official-core'/name,'official-core/'+name) + projection.add(baseline/'compiler/FSharp.Core.dll','official-core/actual-compiler-Core.dll') + projection.add(baseline/'tools/.store/fable/5.18.0/fable/5.18.0/tools/net10.0/any/FSharp.Core.dll','official-core/actual-fable-runtime-Core.dll') + projection.tree(work/'installed-fable','selected/installed-fable') + projection.tree(pathlib.Path(str(work)+'-browser'),'selected/browser') + projection.add(ROOT/'tests/Wasm.Supervisor.Compatibility/browser/test-results/.last-run.json','selected/browser-result.json') + projection.require(['selected/browser/'+name for name in ( + 'fable/Program.js','fable/BrowserBridge.js','limits-guest.wasm','http.log', + '_content/FS.GG.Wasm.Browser/module-worker.mjs', + '_content/FS.GG.Wasm.Browser/worker-client.mjs', + '_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js')]) + projection.require(['selected/installed-fable/Program.js','selected/installed-fable/BrowserBridge.js']) + runtime=ROOT/'tests/Wasm.Supervisor.Compatibility/Installed/bin/Debug/net10.0' + for name in ('InstalledCompatibility.dll','InstalledCompatibility.deps.json','InstalledCompatibility.runtimeconfig.json','FSharp.Core.dll'): + projection.add(runtime/name,'selected/runtime/'+name) + + +def merge(projection, source): + source=pathlib.Path(source) + try: + meta=source/'evidence-manifest.json' + if regular(meta).st_size>MIB: raise ValueError('consumer manifest cap') + manifest=json.loads(meta.read_text()) + if not isinstance(manifest,dict) or manifest.get('schema')!='fsgg.wasm.installed-evidence/v1': raise ValueError('consumer evidence schema') + if regular(source/'installed-evidence.tar.gz').st_size>LIMITS['total']: raise ValueError('consumer archive cap') + with tarfile.open(source/'installed-evidence.tar.gz','r:gz') as archive: + rows={row['path']:row for row in manifest['members']} + if len(rows)!=len(manifest['members']) or len(rows)+3>LIMITS['members']: raise ValueError('consumer manifest duplicate/census cap') + seen=set() + for member in archive: + safe_name(member.name) + if not member.isfile() or member.name in seen: raise ValueError('unsafe/duplicate consumer archive') + seen.add(member.name) + if member.name=='evidence-manifest.json': continue + if not consumer_member(member.name): raise ValueError('foreign consumer evidence member') + expected=rows[member.name] + if member.size!=expected['bytes'] or member.size>LIMITS['file']: raise ValueError('consumer evidence size drift') + body=archive.extractfile(member).read(member.size+1) + if len(body)!=member.size or hashlib.sha256(body).hexdigest()!=expected['sha256']: raise ValueError('consumer evidence hash drift') + if projection.total+len(body)>LIMITS['total']-PAYLOAD_RESERVE or len(projection.files)+3>LIMITS['members']: raise ValueError('merged evidence cap') + if member.name in projection.files: raise ValueError('duplicate merged member') + category='logs' if member.name.endswith('.log') else 'metadata' if member.name.endswith(('.json','.sha512','.Config')) or member.name.endswith('/.nupkg.metadata') else None + if category and getattr(projection,category)+len(body)>LIMITS[category]-(2*MANIFEST_RESERVE if category=='metadata' else 0): raise ValueError('merged '+category+' cap') + projection.files[member.name]=body;projection.total+=len(body) + if category: setattr(projection,category,getattr(projection,category)+len(body)) + if seen != set(rows)|{'evidence-manifest.json'}: raise ValueError('consumer archive roster drift') + projection.errors.extend(manifest['errors']) + if manifest.get('runtimeStatus')!=0 or manifest.get('collectorStatus')!='passed' or manifest.get('eligible') is not True: + projection.errors.append({'member':'consumer','status':'failed','reason':'consumer runtime/collection not eligible'}) + except (OSError,ValueError,KeyError,TypeError,EOFError,tarfile.TarError) as error: + projection.errors.append({'member':'consumer','status':'absent/refused','reason':str(error)}) + + +def main(): + parser=argparse.ArgumentParser();parser.add_argument('action',choices=('consumer','assemble'));parser.add_argument('--work',required=True);parser.add_argument('--output',required=True);parser.add_argument('--runtime',required=True,type=int);parser.add_argument('--consumer');parser.add_argument('--receipts');parser.add_argument('--logs');args=parser.parse_args() + if args.action=='assemble' and not all((args.consumer,args.receipts,args.logs)): + parser.error('assemble requires explicit consumer, receipts and logs inputs') + p=Projection() + if args.action=='consumer': consumer(p,args.work) + else: + merge(p,args.consumer); selected(p,args.work) + for name in ('release-manifest.json','promotion-binding.json','installed-input-audit.json','published-native.json','fsgg-wasm-sdk-0.4.0.tar.gz'): + p.add(pathlib.Path(args.receipts)/name,'receipts/'+name) + for name in ('consumer.log','selected.log','runtime-status.json','consumer-status.json'): + p.add(pathlib.Path(args.logs)/name,'execution/'+name) + for consumer_name,receipt_name in [('consumer/release-manifest.json','receipts/release-manifest.json'),('consumer/fsgg-wasm-sdk-0.4.0.tar.gz','receipts/fsgg-wasm-sdk-0.4.0.tar.gz')]: + if consumer_name not in p.files or receipt_name not in p.files or p.files[consumer_name]!=p.files[receipt_name]: + p.errors.append({'member':consumer_name,'status':'absent/refused','reason':'consumer original differs from authenticated publication receipt'}) + try: + status=p.files['execution/runtime-status.json'] + if len(status)>MIB: raise ValueError('runtime status cap') + actual=json.loads(status)['exitCode'] + if type(actual) is not int or not 0<=actual<=255: raise ValueError('exact runtime exit absent') + if (args.runtime==0)!=(actual==0): raise ValueError('native step/runtime status disagreement') + args.runtime=actual + except (KeyError,ValueError,TypeError) as error: + p.errors.append({'member':'execution/runtime-status.json','status':'absent/refused','reason':str(error)}) + args.runtime=None + raise SystemExit(p.finish(args.output,args.runtime)) + +if __name__=='__main__':main() diff --git a/scripts/wasm-release/promotion.py b/scripts/wasm-release/promotion.py index 3cb34991..b944fd23 100644 --- a/scripts/wasm-release/promotion.py +++ b/scripts/wasm-release/promotion.py @@ -5,6 +5,7 @@ transport into the same decisions; they do not establish live authorization. """ from __future__ import annotations +from collections import namedtuple import argparse import base64 import io @@ -87,6 +88,28 @@ def manifest_tool(): spec = importlib.util.spec_from_file_location('manifest', ROOT/'scripts/wasm-release/release_manifest.py') module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module); return module +# Immutable context used only by explicitly selected read-only validators. +PublicationSubject = namedtuple('PublicationSubject', 'producer tree version') + + +def publication_identity(subject): + need(type(subject) is PublicationSubject, 'explicit publication subject required') + sha(subject.producer, 40); sha(subject.tree, 40) + need(subject.version == VERSION == '0.4.0', 'wrong published subject version') + return subject.producer, subject.tree + + +def installed_subject(): + # The binder independently validates this exact whole-tree installed policy + # before any restore; package checking repeats the local source identity audit. + spec = importlib.util.spec_from_file_location('installed_binding', ROOT/'scripts/wasm-release/bind-installed-release.py') + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + module.bind_qualification_inputs(os.environ['PUBLISHED_SOURCE'], os.environ['QUALIFIER_SOURCE'], VERSION) + subject = PublicationSubject(os.environ['PUBLISHED_SOURCE'], git('rev-parse', os.environ['PUBLISHED_SOURCE']+'^{tree}'), VERSION) + publication_identity(subject) + return subject + + def verify_originals(custody, expected): custody = pathlib.Path(custody) manifest = load(custody/'release-manifest.json') @@ -103,13 +126,14 @@ def verify_originals(custody, expected): need(expected['inventories'] == inventories(custody), 'complete frozen member inventory drift') return manifest -def validate_binding(binding): +def validate_binding(binding, *, subject=None): + producer, tree = (P, TREE) if subject is None else publication_identity(subject) fields = {'schema','repository','workflow','mode','producer','producerTree','executor','executorTree', 'runId','runAttempt','version','manifestSha256','archives','inventories','gates', 'workflowSha','job','event','evidence'} need(set(binding) == fields, 'unreviewed preparation fields') need(binding['schema'] == SCHEMA and binding['repository'] == REPO and binding['workflow'] == '.github/workflows/wasm-shared.yml' and binding['mode'] == 'qualify', 'unrelated preparation route') - need(VERSION == '0.4.0' and binding['producer'] == binding['executor'] == binding['workflowSha'] == P and binding['producerTree'] == binding['executorTree'] == TREE and binding['version'] == VERSION, 'wrong selected producer/executor/tree/version') + need(VERSION == '0.4.0' and binding['producer'] == binding['executor'] == binding['workflowSha'] == producer and binding['producerTree'] == binding['executorTree'] == tree and binding['version'] == VERSION, 'wrong selected producer/executor/tree/version') need(binding['job'] == 'qualify' and binding['event'] in ('push','workflow_dispatch'), 'premerge/generation-only custody refuses') need(binding['runAttempt'] == 1, 'rerun custody refuses') need(set(binding['evidence']) == OUTER_FILES - {'preparation-binding.json'} - set(FILES), 'wrong closed evidence roster') @@ -126,9 +150,9 @@ def validate_binding(binding): need(binding['gates'] == {name:'success' for name in GATES}, 'missing/failed required preparation gate') return binding -def validate_tuple(reviewed): +def validate_tuple(reviewed, *, subject=None): need(set(reviewed) == {'binding','bindingSha256','artifactId','artifactSha256'}, 'unreviewed promotion inputs') - validate_binding(reviewed['binding']); sha(reviewed['bindingSha256']); integer(reviewed['artifactId']); sha(reviewed['artifactSha256']) + validate_binding(reviewed['binding'], subject=subject); sha(reviewed['bindingSha256']); integer(reviewed['artifactId']); sha(reviewed['artifactSha256']) return reviewed def validate_run(reviewed, run, jobs, artifact): @@ -413,11 +437,12 @@ def transaction(reviewed, first_run): integer(first_run) return {'schema':'fsgg.wasm.promotion/v1','preparation':reviewed,'firstPromotionRun':first_run,'firstPromotionAttempt':int(os.environ.get('FIRST_PROMOTION_ATTEMPT') or os.environ['GITHUB_RUN_ATTEMPT']),'producer':P,'tag':'wasm/v'+VERSION} -def release_state(native): +def release_state(native, *, subject=None): + producer = P if subject is None else publication_identity(subject)[0] ref_status,ref,_=native.api(f'repos/{REPO}/git/ref/tags/wasm%2Fv{VERSION}') release_status,published,_=native.api(f'repos/{REPO}/releases/tags/wasm%2Fv{VERSION}') need(ref_status in (200,404) and release_status in (200,404),'tag/release Unknown') - if ref_status==200: need(ref['object']['type']=='commit' and ref['object']['sha']==P,'foreign or nonliteral producer tag') + if ref_status==200: need(ref['object']['type']=='commit' and ref['object']['sha']==producer,'foreign or nonliteral producer tag') # Complete authenticated discovery is necessary even when by-tag returns404: # GitHub's by-tag API addresses published releases, not bound drafts. rows=native.pages(f'repos/{REPO}/releases') @@ -431,7 +456,7 @@ def release_state(native): need(actual==release, 'release list/by-ID disagreement') assets=native.pages(f'repos/{REPO}/releases/{release["id"]}/assets') need(assets==release['assets'], 'release asset enumeration disagreement') - need(ref_status==200 and release['tag_name']=='wasm/v'+VERSION and release['target_commitish']==P and not release['prerelease'] and type(release['draft']) is bool, 'foreign release') + need(ref_status==200 and release['tag_name']=='wasm/v'+VERSION and release['target_commitish']==producer and not release['prerelease'] and type(release['draft']) is bool, 'foreign release') need((release_status==404 and release['draft']) or (release_status==200 and not release['draft'] and published==release), 'published/list contradiction') else: need(release_status==404, 'published release hidden in listing') return ref_status,release @@ -444,13 +469,15 @@ def asset_bytes(native, row): need(status==200 and len(body)==row['size'] and row.get('digest')=='sha256:'+digest(body), 'asset bytes/digest Unknown') return body -def verify_transaction(native, release, reviewed): +def verify_transaction(native, release, reviewed, *, subject=None): + producer = P if subject is None else publication_identity(subject)[0] + validate_tuple(reviewed, subject=subject) assets=[x for x in release['assets'] if x['name']=='promotion-binding.json'] need(len(assets)==1,'missing/duplicate durable transaction binding') row=assets[0] body=asset_bytes(native,row); value=json.loads(body) execution_input() - need(set(value)=={'schema','preparation','firstPromotionRun','firstPromotionAttempt','producer','tag'} and value['schema']=='fsgg.wasm.promotion/v1' and value['preparation']==reviewed and value['producer']==P and value['tag']=='wasm/v'+VERSION,'foreign durable release transaction') + need(set(value)=={'schema','preparation','firstPromotionRun','firstPromotionAttempt','producer','tag'} and value['schema']=='fsgg.wasm.promotion/v1' and value['preparation']==reviewed and value['producer']==producer and value['tag']=='wasm/v'+VERSION,'foreign durable release transaction') integer(value['firstPromotionRun']); integer(value['firstPromotionAttempt']) verify_admission(native,reviewed,value['firstPromotionRun'],value['firstPromotionAttempt']) run=native.checked(f'repos/{REPO}/actions/runs/{value["firstPromotionRun"]}/attempts/{value["firstPromotionAttempt"]}') @@ -768,9 +795,11 @@ def extra_fields(extra): def check_installed(record_path,cache,feed,lock): - value=load(record_path); reviewed=validate_tuple(value['preparation']); b=reviewed['binding'] + value=load(record_path) + subject = installed_subject() if os.environ.get('MODE') == 'installed' else None + reviewed=validate_tuple(value['preparation'], subject=subject); b=reviewed['binding'] execution_input() - need(b['executor']==os.environ['ACCEPTED_EXECUTOR'],'installed transaction wrong executor') + need(b['executor']==(subject.producer if subject else os.environ['ACCEPTED_EXECUTOR']),'installed transaction wrong publisher') need(feed in ('https://api.nuget.org/v3/index.json','https://nuget.pkg.github.com/FS-GG/index.json'), 'foreign installed feed') dependencies=load(lock)['dependencies']['net10.0'] for ident in PACKAGES: @@ -806,7 +835,7 @@ def unique_fields(pairs): need(feed != 'https://api.nuget.org/v3/index.json' or '.signature.p7s' in names, 'public restored package lacks repository signature') if '.signature.p7s' in names: need(feed=='https://api.nuget.org/v3/index.json','org original was unexpectedly signed') - repository_signature(archive) + print(repository_signature(archive)) with archive.open('rb') as stream: need(stream.read(536870913) == raw, 'restored archive changed during signature verification') names.remove('.signature.p7s') need(set(names)==set(inventory),'restored package roster mismatch') diff --git a/tests/release/wasm/test-installed-evidence.py b/tests/release/wasm/test-installed-evidence.py new file mode 100644 index 00000000..9254bef4 --- /dev/null +++ b/tests/release/wasm/test-installed-evidence.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Inert actual collector controls: no compiler, restore, browser or network.""" +import importlib.util +import json +import os +import pathlib +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch +sys.dont_write_bytecode=True +ROOT=pathlib.Path(__file__).resolve().parents[3] +spec=importlib.util.spec_from_file_location('evidence',ROOT/'scripts/wasm-release/installed-evidence.py') +e=importlib.util.module_from_spec(spec);spec.loader.exec_module(e) +spec=importlib.util.spec_from_file_location('binder',ROOT/'scripts/wasm-release/bind-installed-release.py') +b=importlib.util.module_from_spec(spec);spec.loader.exec_module(b) + + +def put(path, body=b'actual control bytes'): + path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(body);return path + + +class Evidence(unittest.TestCase): + def test_hidden_metadata_and_colon_cache_exclusion(self): + with tempfile.TemporaryDirectory() as raw: + root=pathlib.Path(raw);work=root/'work' + for name in ('release-manifest.json','NuGet.Config','consumer/packages.lock.json'): + put(work/name,b'{}') + put(work/'sdk-artifacts/fsgg-wasm-sdk-0.4.0.tar.gz') + put(work/'installed-member-receipt.log') + put(work/'consumer-http.log') + for ident in e.PACKAGES: + for leaf in (ident+'.0.4.0.nupkg',ident+'.0.4.0.nupkg.sha512','.nupkg.metadata'): + put(work/'consumer-packages'/ident/'0.4.0'/leaf) + for name in ('fsharp.core.10.1.302.nupkg','fsharp.core.10.1.302.nupkg.sha512','.nupkg.metadata','lib/netstandard2.0/FSharp.Core.dll','lib/netstandard2.1/FSharp.Core.dll'): + put(work/'consumer-packages/fsharp.core/10.1.302'/name) + put(work/'consumer/browser/test-results/.last-run.json',b'{"status":"passed"}') + for name in ('FSharp.Core.dll','Consumer.dll','Consumer','fable/Program.js','_content/FS.GG.Wasm.Browser/module-worker.mjs','_content/FS.GG.Wasm.Browser/worker-client.mjs','_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js'): + put(work/'consumer/public/sub/app'/name) + put(work/'http-cache/cache:https.json/list.dat',b'discard HTTP cache') + p=e.Projection();e.consumer(p,work) + self.assertEqual(p.finish(root/'output',0),0) + with tarfile.open(root/'output/installed-evidence.tar.gz') as archive: + names=archive.getnames() + self.assertEqual(sum(x.endswith('/.nupkg.metadata') for x in names),3) + self.assertTrue(all('http-cache' not in x for x in names)) + self.assertTrue(json.load(archive.extractfile('evidence-manifest.json'))['eligible']) + joined=e.Projection();e.merge(joined,root/'output') + self.assertFalse(joined.errors);self.assertEqual(joined.files,p.files) + + def test_refusals_retain_independent_evidence_and_runtime_cause(self): + for defect in ('symlink','hardlink','traversal','duplicate','oversize','missing'): + with self.subTest(defect=defect),tempfile.TemporaryDirectory() as raw: + root=pathlib.Path(raw);good=put(root/'good',b'good');bad=put(root/'bad',b'x'*20) + p=e.Projection();p.add(good,'selected/good.log') + limits=dict(e.LIMITS,file=10) + with patch.dict(e.LIMITS,limits): + if defect=='symlink': bad.unlink();bad.symlink_to(good);p.add(bad,'selected/bad') + elif defect=='hardlink':bad.unlink();os.link(good,bad);p.add(bad,'selected/bad') + elif defect=='traversal':p.add(bad,'../escape') + elif defect=='duplicate':p.add(good,'selected/good.log') + elif defect=='oversize':p.add(bad,'selected/bad') + else:p.add(root/'absent','selected/lock.json') + self.assertEqual(p.finish(root/'output',37),1) + manifest=json.loads((root/'output/evidence-manifest.json').read_text()) + self.assertEqual(manifest['runtimeStatus'],37);self.assertFalse(manifest['eligible']) + with tarfile.open(root/'output/installed-evidence.tar.gz') as archive: + self.assertEqual(archive.extractfile('selected/good.log').read(),b'good') + + def test_missing_core_lock_worker_is_ineligible(self): + with tempfile.TemporaryDirectory() as raw: + p=e.Projection();e.selected(p,pathlib.Path(raw)/'selected') + self.assertTrue(any('installed.packages.lock' in x['member'] for x in p.errors)) + self.assertTrue(any('core/' in x['member'] for x in p.errors)) + self.assertTrue(any(x['member']=='selected/browser' for x in p.errors)) + self.assertTrue(any(x['member'].endswith('/policy/WorkerEntry.js') for x in p.errors)) + self.assertEqual(p.finish(pathlib.Path(raw)/'out',0),1) + + def test_combined_budget_is_enforced_on_merge(self): + with tempfile.TemporaryDirectory() as raw: + root=pathlib.Path(raw);file=put(root/'file',b'x'*8) + a=e.Projection();a.add(file,'consumer/installed-member-receipt.log');a.finish(root/'a',0) + c=e.Projection();c.add(file,'selected/data.log') + with patch.dict(e.LIMITS,logs=10):e.merge(c,root/'a') + self.assertTrue(any(x['reason']=='merged logs cap' for x in c.errors),c.errors) + + def test_owner_trap_retains_original_status_after_collection_failure(self): + source=(ROOT/'scripts/verify-wasm-package-consumer.sh').read_text() + trap=source.split('finish_consumer() {',1)[1].split('\ntrap finish_consumer EXIT',1)[0] + # Run only the maintained shell trap body with inert collection/cleanup + # functions. No consumer command or downloaded source is executed. + for runtime,collection,expected in ((37,19,37),(0,19,19),(0,0,0)): + with tempfile.TemporaryDirectory() as raw: + script='set -euo pipefail\nmode=--feed-only\nrepo=/inert\nwork=/inert\nWASM_CONSUMER_EVIDENCE=/inert\nWASM_CONSUMER_STATUS='+raw+'/status.json\npython3(){ return '+str(collection)+'; }\nrm(){ return 0; }\nfinish_consumer() {'+trap+'\ntrap finish_consumer EXIT\nexit '+str(runtime) + result=subprocess.run(['bash','-c',script],capture_output=True,text=True) + self.assertEqual(result.returncode,expected,result.stderr) + status=json.loads((pathlib.Path(raw)/'status.json').read_text()) + self.assertEqual(status['runtime'],runtime);self.assertEqual(status['collection'],collection) + + def test_workflow_preserves_command_first_cause_and_native_log_failure(self): + text=(ROOT/'.github/workflows/wasm-installed-org.yml').read_text() + block=text.split(' phase=initial\n',1)[1].split(' phase=complete',1)[0] + block='phase=initial\n'+block.split(' run_logged consumer',1)[0] + block='\n'.join(line[10:] if line.startswith(' ') else line for line in block.splitlines()) + for command_exit,log_exit,expected in ((37,19,37),(0,19,19),(0,0,0)): + with tempfile.TemporaryDirectory() as raw: + block_here=block.replace('artifacts/wasm-installed-${{ matrix.feed }}/runtime-status.json',raw+'/status.json') + script='set -euo pipefail\n'+block_here+'\ncmd(){ echo fixture; return '+str(command_exit)+'; }\ntee(){ cat >/dev/null; return '+str(log_exit)+'; }\nrun_logged consumer /inert cmd\nphase=complete\n' + result=subprocess.run(['bash','-c',script],capture_output=True,text=True) + self.assertEqual(result.returncode,expected,result.stderr) + status=json.loads((pathlib.Path(raw)/'status.json').read_text()) + self.assertEqual(status['exitCode'],expected) + self.assertEqual(status['commandExit'],command_exit);self.assertEqual(status['logExit'],log_exit) + + def test_archive_and_external_manifest_share_one_upload_cap(self): + import random + with tempfile.TemporaryDirectory() as raw: + root=pathlib.Path(raw);p=e.Projection() + p.add(put(root/'binary',random.Random(42).randbytes(60000)),'selected/runtime.dll') + p.add(put(root/'log',b'original log'),'selected/first.log') + with patch.dict(e.LIMITS,total=61200): + self.assertEqual(p.finish(root/'out',37),1) + manifest=json.loads((root/'out/evidence-manifest.json').read_text()) + self.assertEqual(manifest['runtimeStatus'],37);self.assertFalse(manifest['eligible']) + self.assertTrue(any(x['reason']=='archive plus sidecar upload cap' for x in manifest['errors'])) + self.assertLessEqual(sum(x.stat().st_size for x in (root/'out').iterdir()),61200) + with tarfile.open(root/'out/installed-evidence.tar.gz') as archive: + self.assertEqual(archive.extractfile('selected/first.log').read(),b'original log') + self.assertNotIn('selected/runtime.dll',archive.getnames()) + + def test_both_physical_manifest_copies_count_as_metadata(self): + with tempfile.TemporaryDirectory() as raw: + root=pathlib.Path(raw);p=e.Projection();p.add(put(root/'lock',b'{}'),'selected/lock.json') + p.finish(root/'reference',0) + metadata=len((root/'reference/evidence-manifest.json').read_bytes()) + # One manifest fits but two cannot. The actual finish predicate, + # not a second implementation of it, must refuse before upload. + with patch.dict(e.LIMITS,metadata=p.metadata+metadata+32): + with self.assertRaisesRegex(ValueError,'manifest cap'):p.finish(root/'refused',0) + + def test_fresh_http_capture_refuses_stale_log_and_preserves_runtime(self): + source=(ROOT/'scripts/verify-wasm-package-consumer.sh').read_text() + trap=source.split('finish_consumer() {',1)[1].split('\ntrap finish_consumer EXIT',1)[0] + for changed,copy_exit,runtime,expected in ((True,0,0,0),(False,0,0,1),(True,19,37,37)): + with self.subTest(changed=changed,copy_exit=copy_exit),tempfile.TemporaryDirectory() as raw: + script='set -euo pipefail\nmode=--feed-only\nrepo=/inert\nwork=/inert\nconsumer_http_started=1\nconsumer_http_before=old\nWASM_CONSUMER_EVIDENCE=/inert\nWASM_CONSUMER_STATUS='+raw+'/status.json\nstat(){ echo '+('fresh' if changed else 'old')+'; }\ncp(){ touch '+raw+'/copied; return '+str(copy_exit)+'; }\npython3(){ return 0; }\nrm(){ return 0; }\nfinish_consumer() {'+trap+'\ntrap finish_consumer EXIT\nexit '+str(runtime) + result=subprocess.run(['bash','-c',script],capture_output=True,text=True) + self.assertEqual(result.returncode,expected,result.stderr) + status=json.loads((pathlib.Path(raw)/'status.json').read_text()) + self.assertEqual(status['runtime'],runtime) + self.assertEqual((pathlib.Path(raw)/'copied').exists(),changed) + self.assertEqual(status['collection'],copy_exit if changed else 1) + + def test_only_named_apphost_is_admitted(self): + with tempfile.TemporaryDirectory() as raw: + root=pathlib.Path(raw);put(root/'sub/app/Consumer');put(root/'sub/app/UnexpectedHost') + p=e.Projection();p.tree(root,'consumer/public') + self.assertIn('consumer/public/sub/app/Consumer',p.files) + self.assertTrue(any(x['member'].endswith('UnexpectedHost') for x in p.errors)) + self.assertTrue(e.consumer_member('consumer/public/sub/app/Consumer')) + self.assertFalse(e.consumer_member('consumer/public/sub/app/UnexpectedHost')) + + def test_bad_consumer_retention_configuration_refuses_before_work(self): + import shlex + source=(ROOT/'scripts/verify-wasm-package-consumer.sh').read_text() + prelude=source.split('mode="${1:-}"',1)[1].split('work="$(mktemp',1)[0] + prelude='mode="${1:-}"'+prelude + with tempfile.TemporaryDirectory() as raw: + env=dict(os.environ,MODE='installed',WASM_CONSUMER_EVIDENCE=raw+'/fresh',WASM_CONSUMER_STATUS=raw+'/status') + script='set -euo pipefail\nrepo='+shlex.quote(str(ROOT))+'\n'+prelude+'\necho ADMITTED\n' + result=subprocess.run(['bash','-c',script,'inert','--feed-only'],env=env,capture_output=True,text=True) + self.assertEqual(result.returncode,0,result.stderr) + for bad in ({'WASM_CONSUMER_EVIDENCE':str(ROOT/'forbidden-evidence')},{'WASM_CONSUMER_EVIDENCE':raw},{'WASM_CONSUMER_EVIDENCE':''},{'WASM_CONSUMER_STATUS':''}): + result=subprocess.run(['bash','-c',script,'inert','--feed-only'],env=dict(env,**bad),capture_output=True,text=True) + self.assertNotEqual(result.returncode,0);self.assertNotIn('ADMITTED',result.stdout) + + def test_complete_diff_binds_exact_blobs_and_refuses_semantic_change(self): + with tempfile.TemporaryDirectory() as raw: + root=pathlib.Path(raw) + def git(*args):return subprocess.check_output(['git','-C',raw,*args],text=True,stderr=subprocess.DEVNULL).strip() + git('init','-q');git('config','user.name','inert control');git('config','user.email','control@example.invalid') + allowed='scripts/wasm-release/bind-installed-release.py' + put(root/allowed,b'old');git('add','.');git('commit','-qm','P');p=git('rev-parse','HEAD');tree=git('rev-parse','HEAD^{tree}') + put(root/allowed,b'new');git('add','.');git('commit','-qm','Q');q=git('rev-parse','HEAD') + with patch.object(b,'ROOT',root),patch.object(b,'PUBLISHED',p),patch.object(b,'PUBLISHED_TREE',tree): + audit=b.source_audit(p,q);self.assertEqual(len(audit['changes']),1) + row=audit['changes'][0];self.assertEqual(row['new']['sha256'],e.hashlib.sha256(b'new').hexdigest()) + self.assertEqual(row['old']['blob'],git('rev-parse',p+':'+allowed)) + put(root/'src/Wasm.Browser/Host.fs',b'semantic mutation');git('add','.');git('commit','-qm','bad') + with self.assertRaises(AssertionError):b.source_audit(p,git('rev-parse','HEAD')) + with self.assertRaises(AssertionError):b.source_audit('a'*40,q) + + +if __name__=='__main__':unittest.main() diff --git a/tests/release/wasm/test-installed-org-workflow.py b/tests/release/wasm/test-installed-org-workflow.py index 664fcb55..0453702e 100644 --- a/tests/release/wasm/test-installed-org-workflow.py +++ b/tests/release/wasm/test-installed-org-workflow.py @@ -23,7 +23,7 @@ def validate(candidate): assert all(value not in candidate for value in ("packages: write", "contents: write", "id-token:", "dotnet pack", "nuget push", "gh release", "git tag")) for name in ("version", "published_source", "qualifier_source", "manifest_sha256", "sdk_sha256", "accepted_executor", "promotion_binding_sha256"): assert f" {name}:" in candidate and f"${{{{ inputs.{name} }}}}" in candidate - assert "feed: [org, public]" in candidate and "max-parallel: 1" in candidate + assert "feed: [org, public]" in candidate and "max-parallel: 1" in candidate and "fail-fast: false" in candidate assert "https://nuget.pkg.github.com/FS-GG/index.json" in candidate and "https://api.nuget.org/v3/index.json" in candidate assert "run: python3 scripts/wasm-release/bind-installed-release.py" in candidate assert "scripts/verify-wasm-package-consumer.sh --feed-only" in candidate @@ -32,6 +32,11 @@ def validate(candidate): steps = ["Static installed qualification preflight", "Bind qualification source and immutable published receipts", "Install exact consumer tools", "Qualify fresh installed consumer"] positions = [candidate.index(value) for value in steps] assert positions == sorted(positions) + assert 'cp -a "$RUNNER_TEMP/selected-' not in candidate + assert 'installed-evidence.py assemble' in candidate and 'if-no-files-found: error' in candidate + assert 'WASM_CONSUMER_EVIDENCE=' in candidate and 'WASM_CONSUMER_STATUS=' in candidate + assert candidate.count('if: always()') == 2 + assert 'path: ${{ runner.temp }}/installed-${{ matrix.feed }}-evidence/' in candidate assert "fetch-depth: 0" in candidate and "ref:" not in candidate @@ -44,6 +49,9 @@ def validate(candidate): text.replace("--feed-only", "--custody artifacts/wasm-release"), text.replace("scripts/wasm-release/qualify-supervisor-installed.sh --feed-only", "true"), text.replace("max-parallel: 1", "max-parallel: 2"), + text.replace("fail-fast: false", "fail-fast: true"), + text.replace("installed-evidence.py assemble", "true"), + text.replace("if-no-files-found: error", "if-no-files-found: ignore"), text.replace("python3 scripts/wasm-release/bind-installed-release.py", "true"), text.replace(" fetch-depth: 0", " fetch-depth: 0\n ref: wasm/v0.1.1"), ): @@ -54,7 +62,7 @@ def validate(candidate): else: raise SystemExit("known-bad installed qualification route accepted") -values = {"RELEASE_VERSION": "0.4.0", "PUBLISHED_SOURCE": "a" * 40, "QUALIFIER_SOURCE": "a" * 40, "ACCEPTED_EXECUTOR": "a" * 40, "PROMOTION_BINDING_SHA256": "e" * 64, "MANIFEST_SHA256": "c" * 64, "SDK_SHA256": "d" * 64} +values = {"RELEASE_VERSION": "0.4.0", "PUBLISHED_SOURCE": binding.PUBLISHED, "QUALIFIER_SOURCE": "a" * 40, "ACCEPTED_EXECUTOR": "a" * 40, "PROMOTION_BINDING_SHA256": "e" * 64, "MANIFEST_SHA256": "c" * 64, "SDK_SHA256": "d" * 64} assert binding.checked_inputs(values, "0.4.0") == values for key, value in (("PUBLISHED_SOURCE", "main"), ("QUALIFIER_SOURCE", "wasm/v0.4.0"), ("SDK_SHA256", "unknown"), ("RELEASE_VERSION", "0.1.1"), ("MANIFEST_SHA256", "$(false)")): try: @@ -68,7 +76,7 @@ def validate(candidate): try:binding.checked_inputs(broken,'0.4.0') except (AssertionError,KeyError):pass else:raise SystemExit('missing current transaction/executor accepted') -for key, value in [('PUBLISHED_SOURCE','b'*40),('EXECUTION_BINDING','{}'),('CANONICAL_QUALIFICATION_AUDIT','historical')]: +for key, value in [('PUBLISHED_SOURCE','b'*40),('QUALIFIER_SOURCE','b'*40),('EXECUTION_BINDING','{}'),('CANONICAL_QUALIFICATION_AUDIT','historical')]: try:binding.checked_inputs(dict(values,**{key:value}),'0.4.0') except (AssertionError,KeyError):pass else:raise SystemExit('current source/overlay substitution accepted') @@ -103,6 +111,6 @@ def validate(candidate): assert [effect["kind"] for effect in state["effects"]] == kinds assert state["activeWorker"] == {"#bigint": "0"} and not state["ordinary"] assert len(set(order)) == 5 -print("wasm-installed-input-audit: current-producer=executor promotion=mandatory expiry-early-late=canonical") +print("wasm-installed-input-audit: immutable-publisher=current-qualifier-separated promotion=mandatory expiry-early-late=canonical") print("wasm-installed-preflight: manual=pass read-only=pass serial-two-feeds=pass qualifier=immutable native-receipts=bound negative-controls=pass") diff --git a/tests/release/wasm/test-release-promotion.py b/tests/release/wasm/test-release-promotion.py index 90d94d0c..a10dfcb9 100644 --- a/tests/release/wasm/test-release-promotion.py +++ b/tests/release/wasm/test-release-promotion.py @@ -724,12 +724,75 @@ def test_actual_clean_executor_and_finite_glue_delta(self): with patch.dict(os.environ,{'GITHUB_SHA':head,'ACCEPTED_EXECUTOR':'9'*40}),self.assertRaises(p.Refusal):p.executor_checkout(b) spec=importlib.util.spec_from_file_location('installed_binding',ROOT/'scripts/wasm-release/bind-installed-release.py') binder=importlib.util.module_from_spec(spec);spec.loader.exec_module(binder) - with patch.dict(os.environ,{'GITHUB_SHA':head,'ACCEPTED_EXECUTOR':head}): - binder.bind_executor(head,head,p.VERSION) - for source,executor,version in [('9'*40,head,p.VERSION),(head,'9'*40,p.VERSION),(head,head,'0.3.0')]: - with self.assertRaises(AssertionError):binder.bind_executor(source,executor,version) + env={'GITHUB_SHA':head,'GITHUB_WORKFLOW_SHA':head,'ACCEPTED_EXECUTOR':head,'QUALIFIER_SOURCE':head, + 'GITHUB_REPOSITORY':p.REPO,'GITHUB_REF':'refs/heads/main','GITHUB_EVENT_NAME':'workflow_dispatch','GITHUB_RUN_ATTEMPT':'1', + 'GITHUB_WORKFLOW_REF':p.REPO+'/.github/workflows/wasm-installed-org.yml@refs/heads/main','GITHUB_JOB':'installed','GITHUB_RUN_ID':'707','WASM_PACKAGE_FEED':'https://nuget.pkg.github.com/FS-GG/index.json'} + # Actual Git cleanliness/head checks remain live; the complete diff parser + # is exercised independently against real inert Git commits by collector controls. + with patch.dict(os.environ,env),patch.object(binder,'source_audit',return_value={'bound':True}) as audit: + audit_row=binder.bind_executor(binder.PUBLISHED,head,p.VERSION) + self.assertTrue(audit_row['bound']);self.assertEqual(audit_row['execution']['run'],707) + audit.assert_called_once_with(binder.PUBLISHED,head) + for key,bad in [('GITHUB_SHA','9'*40),('GITHUB_WORKFLOW_SHA','9'*40),('QUALIFIER_SOURCE','9'*40),('GITHUB_REF','refs/heads/other'),('GITHUB_EVENT_NAME','push'),('GITHUB_RUN_ATTEMPT','2'),('GITHUB_WORKFLOW_REF',p.REPO+'/.github/workflows/other.yml@refs/heads/main'),('GITHUB_JOB','other'),('GITHUB_RUN_ID','0'),('WASM_PACKAGE_FEED','https://other.invalid/index.json')]: + with self.subTest(key=key),patch.dict(os.environ,{key:bad}),self.assertRaises(AssertionError):binder.bind_executor(binder.PUBLISHED,head,p.VERSION) + for executor,version in [('9'*40,p.VERSION),(head,'0.3.0')]: + with self.assertRaises(AssertionError):binder.bind_executor(binder.PUBLISHED,executor,version) with patch.dict(os.environ,{'EXECUTION_BINDING':'{"schema":"historical"}'}),self.assertRaises(p.Refusal):p.execution_input() + def test_installed_binder_authenticates_original_publication_and_current_qualifier(self): + spec=importlib.util.spec_from_file_location('installed_binding',ROOT/'scripts/wasm-release/bind-installed-release.py') + binder=importlib.util.module_from_spec(spec);spec.loader.exec_module(binder) + producer=binder.PUBLISHED;tree=binder.PUBLISHED_TREE;qualifier='9'*40 + with patch.object(p,'P',producer),patch.object(p,'TREE',tree): + r=reviewed();run,jobs,artifact=native_data() + journal={'schema':'fsgg.wasm.promotion/v1','preparation':r,'firstPromotionRun':201,'firstPromotionAttempt':1,'producer':producer,'tag':'wasm/v0.4.0'} + values={'PUBLISHED_SOURCE':producer,'QUALIFIER_SOURCE':qualifier,'ACCEPTED_EXECUTOR':qualifier,'RELEASE_VERSION':'0.4.0'} + class Transport: + def __init__(self,mutation=None): + self.mutation=mutation;self.journal=copy.deepcopy(journal) + if mutation=='journal-producer':self.journal['producer']=qualifier + body=json.dumps(self.journal).encode();self.body=body + self.asset={'id':401,'name':'promotion-binding.json','url':f'https://api.github.com/repos/{p.REPO}/releases/assets/401','size':len(body),'digest':'sha256:'+p.digest(body)} + self.release={'id':300,'tag_name':'wasm/v0.4.0','target_commitish':qualifier if mutation=='release-source' else producer,'draft':False,'prerelease':False,'assets':[self.asset]} + def checked(self,path): + if path.endswith('/git/ref/heads/main'):return {'object':{'sha':'8'*40 if self.mutation=='main' else qualifier}} + if path.endswith('/releases/300'):return self.release + if path.endswith('/artifacts/200'):return artifact + if '/runs/100/' in path: + result=copy.deepcopy(run) + if self.mutation=='qualification-failed':result['conclusion']='failure' + return result + if '/runs/201/' in path:return {'id':201,'head_sha':producer,'repository':{'full_name':p.REPO},'event':'workflow_dispatch','path':'.github/workflows/release-wasm.yml','run_attempt':1,'status':'completed','conclusion':'failure' if self.mutation=='publication-failed' else 'success'} + raise AssertionError('unexpected native read '+path) + def pages(self,path,key=None): + if '/runs/100/' in path:return jobs + if '/runs/201/' in path:return [{'name':name,'conclusion':'success'} for name in ('preflight','admission','begin','org','public','assets','complete')] + if path.endswith('/assets'):return [self.asset] + if path.endswith('/releases'):return [self.release] + raise AssertionError('unexpected native page '+path) + def api(self,path):return (200,{'object':{'type':'commit','sha':producer}}, {}) if '/git/ref/tags/' in path else (200,self.release,{}) + def request(self,url,**kwargs):return 200,self.body,{} + for mutation in (None,'main','journal-producer','release-source','qualification-failed','publication-failed'): + native=Transport(mutation) + with self.subTest(mutation=mutation),tempfile.TemporaryDirectory() as raw,patch.object(p,'Native',return_value=native),patch.object(p,'verify_admission') as original_admission: + if mutation: + with self.assertRaises((AssertionError,p.Refusal)):binder.bind_promotion_execution(p,journal,values,native.release,pathlib.Path(raw)) + else: + self.assertEqual(binder.bind_promotion_execution(p,journal,values,native.release,pathlib.Path(raw)),r) + original_admission.assert_called_once_with(native,r,201,1) + self.assertTrue((pathlib.Path(raw)/'published-native.json').exists()) + + def test_explicit_read_only_publication_subject_does_not_relax_writer(self): + original=reviewed(); producer=p.P; tree=p.TREE + subject=p.PublicationSubject(producer,tree,p.VERSION) + with patch.object(p,'P','9'*40),patch.object(p,'TREE','8'*40): + self.assertEqual(p.validate_tuple(original,subject=subject),original) + with self.assertRaises(p.Refusal):p.validate_tuple(original) + for wrong in (p.PublicationSubject('7'*40,tree,p.VERSION),p.PublicationSubject(producer,'6'*40,p.VERSION),p.PublicationSubject(producer,tree,'0.3.0')): + with self.assertRaises(p.Refusal):p.validate_tuple(original,subject=wrong) + with self.assertRaises(AttributeError):subject.producer='9'*40 + with patch.dict(os.environ,{'EXECUTION_BINDING':'historical'}),self.assertRaises(p.Refusal):p.execution_input() + def test_native_cross_host_redirect_drops_token(self): import urllib.request req=urllib.request.Request('https://api.github.com/archive',headers={'Authorization':'Bearer offline'})