diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 897afeb87..96395ba6e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -43,7 +43,7 @@ jobs: echo 'meson=1.7.2' } > "${RUNNER_TEMP}/linux-runtime-toolchain.txt" TOOLCHAIN_SHA256="$(sha256sum "${RUNNER_TEMP}/linux-runtime-toolchain.txt" | cut -d ' ' -f 1)" - SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/stage-runtime.mjs', 'tools/packaging/prepare-linux-runtime-source-snapshot.cjs') }}" + SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/linux-source-archive-contract.cjs', 'tools/embedded-mpv/stage-runtime.mjs', 'tools/packaging/prepare-linux-runtime-source-snapshot.cjs') }}" echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}" echo "key=linux-frame-copy-runtime-v5-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}" @@ -162,6 +162,7 @@ jobs: tools/embedded-mpv/build-linux-runtime.mjs \ tools/embedded-mpv/generate-linux-runtime-notices.cjs \ tools/embedded-mpv/linux-runtime-manifest.cjs \ + tools/embedded-mpv/linux-source-archive-contract.cjs \ tools/embedded-mpv/stage-runtime.mjs \ tools/packaging/prepare-linux-runtime-source-snapshot.cjs \ "${SOURCE_BUNDLE_ROOT}/tooling/" @@ -173,6 +174,10 @@ jobs: const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); + const { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + validateLinuxRuntimeSourceSnapshot, + } = require('./tools/packaging/prepare-linux-runtime-source-snapshot.cjs'); const manifest = JSON.parse( fs.readFileSync(path.join(process.env.RUNTIME_ROOT, 'runtime-manifest.json'), 'utf8') @@ -211,6 +216,10 @@ jobs: ) { throw new Error('Prepared libplacebo source identity does not match the runtime manifest.'); } + validateLinuxRuntimeSourceSnapshot(libplacebo.sourceSnapshot, { + expectedSha256: + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + }); const notices = JSON.parse( fs.readFileSync( @@ -232,7 +241,7 @@ jobs: path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'), `${JSON.stringify( { - schemaVersion: 2, + schemaVersion: 3, repositoryRevision, sourcePackages: manifest.packages, archives, @@ -268,6 +277,12 @@ jobs: --file dist/compliance/linux-frame-copy-runtime-sources.tar.xz \ --directory "${SOURCE_BUNDLE_ROOT}" \ . + rm -f "${RUNTIME_ROOT}/source-archive-binding.json" + node tools/embedded-mpv/linux-source-archive-contract.cjs create \ + --archive dist/compliance/linux-frame-copy-runtime-sources.tar.xz \ + --repository-revision "$(git rev-parse HEAD)" \ + --output "${RUNTIME_ROOT}/source-archive-binding.json" + test -s "${RUNTIME_ROOT}/source-archive-binding.json" - name: Upload staged Linux runtime uses: actions/upload-artifact@v4 @@ -569,7 +584,7 @@ jobs: find dist/apps/electron-backend/native -maxdepth 1 \( -name 'mpv-2.dll' -o -name 'libmpv-2.dll' -o -name 'mpv.dll' -o -name 'libmpv.dll' \) -print -quit | grep -q . ;; linux) - node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true) { throw new Error('Linux embedded MPV build manifest must describe the validated source runtime.'); }" + node -e "const { execFileSync } = require('node:child_process'); const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); if (manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true || manifest.sourceArchive?.schemaVersion !== 1 || manifest.sourceArchive?.name !== 'linux-frame-copy-runtime-sources.tar.xz' || !/^[a-f0-9]{64}$/.test(manifest.sourceArchive?.sha256 ?? '') || manifest.sourceArchive?.repositoryRevision !== revision) { throw new Error('Linux embedded MPV build manifest must describe the validated source runtime and exact source archive.'); }" test -f dist/apps/electron-backend/native/lib/libmpv.so.2 test -f dist/apps/electron-backend/native/iptvnator_mpv_helper test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml index 5c16e6ef2..b07fe5469 100644 --- a/.github/workflows/publish-snap.yaml +++ b/.github/workflows/publish-snap.yaml @@ -9,23 +9,38 @@ permissions: contents: read jobs: - publish-snap: - name: Publish public-release Snap to edge + verify-snap: + name: Verify public-release Snap assets if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }} runs-on: ubuntu-latest + timeout-minutes: 45 env: - GH_TOKEN: ${{ github.token }} - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} SOURCE_ARCHIVE_NAME: linux-frame-copy-runtime-sources.tar.xz + outputs: + receipt-sha256: ${{ steps.bind-transfer.outputs.receipt-sha256 }} steps: - name: Checkout released tooling - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: ${{ github.event.release.tag_name }} + persist-credentials: false + + - name: Install release source verifier + shell: bash + run: | + set -euo pipefail + + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + binutils \ + squashfs-tools \ + xz-utils - name: Select exact public release assets shell: bash + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -40,6 +55,8 @@ jobs: - name: Download exact public release assets shell: bash + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -61,26 +78,192 @@ jobs: run: | set -euo pipefail + VERIFIED_ASSET_STAGING="${RUNNER_TEMP}/verified-snap-release-assets" + SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release" + SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets" test -s "${RUNNER_TEMP}/snap-release-downloads/${SOURCE_ARCHIVE_NAME}" + test ! -e "${VERIFIED_ASSET_STAGING}" + sudo test ! -e "${SEALED_ASSET_PARENT}" node tools/packaging/release-snap-assets.cjs verify \ --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \ - --directory "${RUNNER_TEMP}/snap-release-downloads" + --directory "${RUNNER_TEMP}/snap-release-downloads" \ + --repository-revision "$(git rev-parse HEAD)" \ + --verified-directory "${VERIFIED_ASSET_STAGING}" + sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}" + sudo mv "${VERIFIED_ASSET_STAGING}" "${SEALED_ASSET_DIRECTORY}" + sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}" + sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} + + sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} + + sudo chmod 0555 "${SEALED_ASSET_PARENT}" - - name: Setup Snapcraft - uses: samuelmeuli/action-snapcraft@v3 - - - name: Publish all public-release snaps to edge + - name: Reverify sealed public release assets shell: bash run: | set -euo pipefail - node -e \ - "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of selected.snapAssets) console.log(asset.name);" \ - "${RUNNER_TEMP}/selected-snap-release-assets.json" | - while IFS= read -r SNAP_NAME; do - SNAP_FILE="${RUNNER_TEMP}/snap-release-downloads/${SNAP_NAME}" - echo "Publishing public release asset: ${SNAP_NAME}" - # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke. - # GitHub Actions never promotes automatically. - snapcraft upload --release=edge "${SNAP_FILE}" - done + VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets" + node tools/packaging/release-snap-assets.cjs verify-sealed \ + --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \ + --directory "${VERIFIED_ASSET_DIRECTORY}" \ + --receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \ + --repository-revision "$(git rev-parse HEAD)" + + - name: Bind verified release transfer + id: bind-transfer + shell: bash + run: | + set -euo pipefail + + RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json" + RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")" + RECEIPT_SHA256="${RECEIPT_RECORD%% *}" + [[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]] + printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}" + + - name: Transfer verified release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: verified-snap-release-assets + path: /var/lib/iptvnator-snap-release/assets + if-no-files-found: error + retention-days: 1 + compression-level: 0 + include-hidden-files: true + + publish-snap: + name: Publish verified public-release Snap to edge + needs: verify-snap + if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }} + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Download verified release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: verified-snap-release-assets + path: ${{ runner.temp }}/verified-snap-release-assets + + - name: Seal transferred public release assets + shell: bash + env: + EXPECTED_RECEIPT_SHA256: ${{ needs.verify-snap.outputs.receipt-sha256 }} + run: | + set -euo pipefail + + TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets" + SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release" + SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets" + test -d "${TRANSFERRED_ASSET_DIRECTORY}" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}" + shopt -s nullglob dotglob + TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*) + TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap) + test "${#TRANSFERRED_SNAPS[@]}" -gt 0 + test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))" + test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz" + test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do + test -f "${ASSET_FILE}" + test ! -L "${ASSET_FILE}" + done + RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")" + ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}" + [[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]] + test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}" + /usr/bin/jq --exit-status ' + type == "object" and + (keys == ["assets", "repositoryRevision", "schemaVersion"]) and + (.schemaVersion == 1) and + (.repositoryRevision | + type == "string" and test("^[a-f0-9]{40,64}$")) and + (.assets | type == "array" and length >= 2) and + (.assets | all(.[]; + type == "object" and + (keys == ["id", "name", "sha256", "size"]) and + (.id | + type == "number" and . > 0 and + . <= 9007199254740991 and . == floor) and + (.name | + type == "string" and length > 0 and + . != "." and . != ".." and + (contains("/") | not) and + (contains("\\") | not) and + (explode | all(.[]; . > 31 and . != 127))) and + (.sha256 | + type == "string" and test("^[a-f0-9]{64}$")) and + (.size | + type == "number" and . > 0 and + . <= 9007199254740991 and . == floor))) and + ([.assets[].name] | length == (unique | length)) and + ([.assets[] | + select(.name == "linux-frame-copy-runtime-sources.tar.xz")] | + length == 1) and + ([.assets[] | select(.name | endswith(".snap"))] | + length >= 1) and + (.assets | all(.[]; + .name == "linux-frame-copy-runtime-sources.tar.xz" or + (.name | endswith(".snap")))) + ' "${RECEIPT_PATH}" > /dev/null + RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "${RECEIPT_PATH}")" + test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))" + SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv" + CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt" + umask 077 + /usr/bin/jq --raw-output \ + '.assets[] | [.name, (.size | tostring)] | @tsv' \ + "${RECEIPT_PATH}" > "${SIZE_MANIFEST}" + while IFS=$'\t' read -r ASSET_NAME EXPECTED_SIZE; do + ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}" + ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")" + test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}" + done < "${SIZE_MANIFEST}" + /usr/bin/jq --raw-output \ + '.assets[] | "\(.sha256) \(.name)"' \ + "${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}" + ( + cd "${TRANSFERRED_ASSET_DIRECTORY}" + /usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}" + ) + rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}" + shopt -u nullglob dotglob + sudo test ! -e "${SEALED_ASSET_PARENT}" + sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}" + sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}" + sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}" + sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} + + sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} + + sudo chmod 0555 "${SEALED_ASSET_PARENT}" + + - name: Install Snapcraft + shell: bash + run: | + set -euo pipefail + + sudo snap install snapcraft --classic --channel=stable + + - name: Publish all public-release snaps to edge + shell: bash + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} + run: | + set -euo pipefail + + VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets" + STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}" + unset SNAPCRAFT_STORE_CREDENTIALS + shopt -s nullglob dotglob + SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap) + test "${#SNAP_FILES[@]}" -gt 0 + for SNAP_FILE in "${SNAP_FILES[@]}"; do + SNAP_NAME="${SNAP_FILE##*/}" + echo "Publishing public release asset: ${SNAP_NAME}" + # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke. + # GitHub Actions never promotes automatically. + SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}" + done + unset STORE_CREDENTIALS + shopt -u nullglob dotglob diff --git a/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md b/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md new file mode 100644 index 000000000..dd616baea --- /dev/null +++ b/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md @@ -0,0 +1,90 @@ +# Linux Embedded MPV Frame-Copy Packaging Plan + +## Audited baseline + +- Linux frame-copy already has an isolated `iptvnator_mpv_helper`, a frame + reader addon, shared controls, native-view fallback, and runtime capability + probes. +- Existing packaged Linux builds intentionally remove the helper/runtime and + retain only system `mpv --wid` native-view. +- Electron, Electron libraries, `embedded_mpv.node`, and + `embedded_mpv_frame_reader.node` must never load or link libmpv. Only the + helper may link it. +- Electron Builder produces AppImage, DEB, RPM, Pacman, Snap, and Flatpak + Linux targets. The available reproducible native/runtime toolchain is x64. + +## Decisions + +1. Support official frame-copy artifacts on Linux x64 only. Keep every non-x64 + artifact marker-only and fail closed to native-view; never accept an + architecture override that injects x64 native files. +2. Use three isolated packaging profiles: + - `system`: DEB/RPM/Pacman use declared distribution libmpv/GL dependencies + and contain no private `native/lib`. + - `portable`: AppImage/Snap contain a pinned LGPL-compatible shared-library + closure with `$ORIGIN`-relative helper loading. + - `flatpak`: Flatpak contains the same pinned closure, validated in the + exact `/app` runtime context. +3. Treat the package manifest as necessary but insufficient. Frame-copy is + available only after exact manifest/schema/profile checks, executable-mode + checks, artifact hashes, dependency-closure/process-isolation checks, and a + bounded helper runtime probe. No environment flag bypasses this gate. +4. Publish exact source archives, recursive source identities, build flags, + licenses, notices, patches/tooling, and pinned display data for bundled + runtimes. Bind every bundled x64 package manifest to the final compliance + archive bytes and released repository revision. +5. Keep Snap Store credentials isolated from release-tag code on a fresh + runner. Store publication is edge-only; candidate/stable promotion remains + manual after installed-package smoke. + +## TDD implementation phases + +1. Add failing tests for target/profile partitioning, x64 and marker-only + layouts, exact dependency declarations, RPATH/SONAME rules, executable + modes, and Electron/libmpv isolation. +2. Implement profile-aware build and packaging hooks that stage the helper, + frame reader, runtime manifest, private closure where applicable, and legal + payload without weakening native-view. +3. Add failing runtime-policy tests for missing/tampered files, wrong + architecture/profile, malformed manifests, loader failures, hostile + environments, probe timeout/output bounds, and stable fallback reasons. +4. Implement one sanitized helper environment shared by probe and playback, + including Snap graphics-provider handling and Flatpak runtime paths. +5. Add failing compliance/release tests for exact recursive submodule records, + VCS-free source inventory, archive member/type layout, source checksums, + license/notices completeness, package-to-source byte binding, sealed asset + receipts, and credential boundaries. +6. Implement deterministic source generation, package bindings, static Snap + inspection, fresh-runner artifact transfer, and minimal direct Store + upload. +7. Add packaged x64 smoke for actual frame-copy playback plus missing-runtime + native-view fallback. Run fixture-contract tests before the smoke and allow + CI llvmpipe through Chromium's GPU blocklist without bypassing the runtime + gate. +8. Update canonical architecture/maintenance documentation and mirrored + `AGENTS.md`/`CLAUDE.md` contracts. + +## Acceptance and verification matrix + +- Local/macOS: + - Nx discovery + - packaging and Electron backend unit/integration tests + - packaged-smoke fixture tests + - affected lint targets + - production backend build + - formatting, syntax, and `git diff --check` +- Linux x64 CI: + - build the pinned runtime/helper/frame reader + - verify helper links/resolves libmpv and Electron/addons do not + - extract and statically validate all six package families + - run system, portable, Snap-installed, and Flatpak application probes + - run packaged frame-copy playback and missing-runtime native-view fallback + - regenerate and bind the exact compliance source archive +- Non-x64 CI: + - build selected ARM package targets independently + - require marker-only layout and absence of every x64 native/runtime artifact +- Merge gate: + - exact-head CI green + - no unresolved review findings + - fresh code review clean + - no automatic Snap promotion beyond edge diff --git a/AGENTS.md b/AGENTS.md index 5397757d9..7458ddce0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,7 +283,7 @@ Key files: for an exact protocol-v1 line carrying a fixed allowlisted reason, and its optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid detail suppresses both helper fields. Every probe uses an explicit 16 MiB - per-stream child-capture ceiling independent of tracing. With + aggregate captured-output ceiling independent of tracing. With `IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted separately as one JSON-escaped stderr line whose `stderr` field is limited to 16,384 characters and whose `truncated` field is always explicit; @@ -295,6 +295,10 @@ Key files: extension loader path comes from the sandbox cache. Flatpak CI must invoke the application-level `--embedded-mpv-runtime-probe`, not a direct helper probe that bypasses capability detection. +- The packaged x64 Playwright smoke runs its fixture-contract target first and + passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2. + This launch-only flag does not bypass the manifest, hash, loader, or helper + capability gate; `--no-sandbox` remains root-only. - Bundled Linux releases must publish the exact source archives/git records, checksums, licenses, flags, patches, build scripts, and the pinned hwdata `pnp.ids` input. Each bundled package carries @@ -302,12 +306,42 @@ Key files: `licenses/**` files. CI may cache immutable source inputs, but regenerates notices and a VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every - run while retaining exact commit/submodule records. Automated Snap Store - publication is allowed only after a public `v*` GitHub release contains - both the Snap assets and exactly one matching source archive. The workflow - uploads only to the Store's edge channel. Candidate/stable promotion is - manual after installed-Snap frame-copy and missing-runtime fallback smoke; - GitHub Actions never promotes automatically. Canonical maintenance docs: + run while retaining the exact pinned six recursive libplacebo submodule + records. Its source index carries the globally sorted libplacebo + directory/file/symlink inventory; file hashes, sizes, executable bits, link + targets, aggregates, and canonical tree digest must match the trusted pinned + checkout. The archive has an exact member/type layout and its + `metadata/archive-sha256.txt` records must match the actual source archives. + Concatenated tar/xz streams are inspected past every end marker. The final + archive's SHA-256 and repository revision are copied into every bundled x64 + package manifest; system and marker-only packages carry no source-archive + binding. + Automated Snap Store publication is allowed only after a public `v*` GitHub + release contains both the Snap assets and exactly one matching source + archive. Before any upload, the workflow hashes and inspects that archive, + verifies its exact member/type set and size bounds, clean tag revision, + pinned sources including the six recursive submodule records and exact + libplacebo tree digest, legal files, and exact released tooling, then + performs bounded extraction and static package validation for every Snap. + Exactly one x64 Snap must have matching + `sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain + marker-only. Checkout and the artifact-transfer actions are pinned to full + commits; checkout does not persist credentials, and repository credentials + are limited to download steps. A secretless verification job copies assets + through no-follow descriptors, checks pre/post hashes, writes an exact + receipt, repeats the complete source/package verification on a root-owned + read-only snapshot, and transfers only that data through the pinned artifact + service while its receipt digest travels separately through a job output. + The dependent publish job runs on a bounded `ubuntu-latest` runner with no + checkout or release-tag code, verifies that digest plus the exact receipt, + asset hashes, and file-only layout, root-seals the data again, and installs + Snapcraft directly. Store credentials exist only in its final fixed shell + step, which resolves no PATH command, executes no released code, and exposes + the credential only to each exact + `/snap/bin/snapcraft upload --release=edge` process. + Candidate/stable promotion is manual after installed-Snap frame-copy and + missing-runtime fallback smoke; GitHub Actions never promotes automatically. + Canonical maintenance docs: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. diff --git a/CLAUDE.md b/CLAUDE.md index a7691ada6..6845a72bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -674,7 +674,7 @@ engine` (restart required) or present only for an exact protocol-v1 line carrying a fixed allowlisted reason, and its optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid detail suppresses both helper fields. Every probe uses - an explicit 16 MiB per-stream child-capture ceiling independent of tracing. + an explicit 16 MiB aggregate captured-output ceiling independent of tracing. With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an explicit `truncated` field; trace-write failure cannot change availability. @@ -682,19 +682,51 @@ engine` (restart required) or channel. The exact packaged Flatpak `/app` context reconstructs only Freedesktop Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that - application-level probe instead of the helper directly. Bundled - Linux packages carry hash-validated + application-level probe instead of the helper directly. The packaged x64 + Playwright smoke runs its fixture-contract target first and passes Chromium + `--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass + the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux + packages carry hash-validated `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`. CI caches the staged runtime plus immutable source inputs, never finished notices or the compliance tarball; it regenerates those notices and the VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current - checkout while preserving exact commit/submodule records. Snap Store + checkout while preserving the exact pinned six recursive libplacebo + submodule records. Its source index carries the globally sorted libplacebo + directory/file/symlink inventory; file hashes, sizes, executable bits, link + targets, aggregates, and canonical tree digest must match the trusted pinned + checkout. The archive has an exact member/type layout and its + `metadata/archive-sha256.txt` records must match the actual source archives. + Concatenated tar/xz streams are inspected past every end marker. Every + bundled x64 package manifest binds the final archive's SHA-256 and repository + revision; system and marker-only packages do not carry that binding. Snap + Store publication runs only from a public `v*` GitHub release that already - contains the Snap assets and exactly one source archive. The workflow uploads - only to the Store's edge channel. Candidate/stable promotion is manual after - installed-Snap frame-copy and missing-runtime fallback smoke; GitHub Actions - never promotes automatically. On Windows, package validation requires the - exact MPV DLL named by the helper's PE import table beside the executable. + contains the Snap assets and exactly one source archive. Before any upload, + the workflow hashes and checks the archive's exact member/type set and size + bounds, verifies its clean tag revision, pinned sources including the six + recursive submodule records and exact libplacebo tree digest, legal payload, + and exact released tooling, then performs bounded extraction and static + validation for every Snap. Exactly one x64 Snap must have matching + `sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only. + Checkout and artifact-transfer actions are pinned to full commits; checkout + does not persist credentials, and repository credentials are scoped to + download steps. A secretless verification job copies assets through + no-follow descriptors, checks them before and after inspection, writes an + exact receipt, fully reverifies a root-owned read-only snapshot, and + transfers only that data through the pinned artifact service while passing + the receipt digest separately through a job output. The dependent publish + job uses a bounded `ubuntu-latest` runner with no checkout or release-tag + code, verifies that digest plus the exact receipt, asset hashes, and + file-only layout, root-seals the data again, and installs Snapcraft directly. + Its final fixed shell step alone receives the Store credential, resolves no + PATH command, executes no released code, and exposes that credential only to + each exact + `/snap/bin/snapcraft upload --release=edge` process. Candidate/stable + promotion is manual after installed-Snap frame-copy and missing-runtime + fallback smoke; GitHub Actions never promotes automatically. On Windows, + package validation requires the exact MPV DLL named by the helper's PE import + table beside the executable. Backend adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 9be0f5864..cdaa4ec01 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -13,6 +13,7 @@ "dependsOn": ["electron-backend:build-e2e"] }, "packaged-frame-copy-smoke": { + "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", "cache": false, "outputs": [ diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 2c128e25c..a7c2f6c66 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -245,16 +245,19 @@ export function getPackagedLinuxNativeDir(executablePath: string): string { export function resolvePackagedElectronLaunchArgs( getuid: (() => number) | undefined ): string[] { - return typeof getuid === 'function' && getuid() === 0 - ? ['--no-sandbox'] - : []; + const args = ['--ignore-gpu-blocklist']; + if (typeof getuid === 'function' && getuid() === 0) { + args.push('--no-sandbox'); + } + return args; } /** * Launch a real packaged Linux executable. Unlike the regular source E2E - * launcher, this deliberately keeps Chromium's GPU path enabled: the - * frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1 and needs WebGL2 to prove - * that the shared-memory frame reaches the renderer canvas. + * launcher, this deliberately keeps Chromium's GPU path enabled and ignores + * its GPU blocklist: the frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1, and + * CI's llvmpipe WebGL2 context must prove that the shared-memory frame reaches + * the renderer canvas. */ export async function launchPackagedElectronApp( executablePath: string, diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts index 55e419e5f..7e9130f5e 100644 --- a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts @@ -10,27 +10,18 @@ import packagedPlaywrightConfig from '../playwright.packaged.config'; const projectRoot = resolve(__dirname, '..'); describe('packaged Electron launch arguments', () => { - it('disables the Chromium sandbox only when running as root', () => { - const originalCi = process.env['CI']; - process.env['CI'] = 'true'; - - try { - assert.deepEqual( - resolvePackagedElectronLaunchArgs(() => 1000), - [] - ); - assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), []); - assert.deepEqual( - resolvePackagedElectronLaunchArgs(() => 0), - ['--no-sandbox'] - ); - } finally { - if (originalCi === undefined) { - delete process.env['CI']; - } else { - process.env['CI'] = originalCi; - } - } + it('keeps WebGL enabled for software rendering while disabling the sandbox only as root', () => { + assert.deepEqual( + resolvePackagedElectronLaunchArgs(() => 1000), + ['--ignore-gpu-blocklist'] + ); + assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), [ + '--ignore-gpu-blocklist', + ]); + assert.deepEqual( + resolvePackagedElectronLaunchArgs(() => 0), + ['--ignore-gpu-blocklist', '--no-sandbox'] + ); }); }); @@ -182,7 +173,9 @@ describe('dedicated packaged smoke target', () => { throw new Error('The packaged frame-copy smoke target is missing.'); } assert.equal(target.cache, false); - assert.equal(target.dependsOn, undefined); + assert.deepEqual(target.dependsOn, [ + 'test-packaged-frame-copy-fixtures', + ]); assert.match( target.options?.command ?? '', /playwright\.packaged\.config\.ts/ diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 163629735..eac33083a 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -16,6 +16,10 @@ const { validateLinuxRuntimeManifest, validateLinuxSystemBuildInputManifest, } = require('../../tools/embedded-mpv/linux-runtime-manifest.cjs'); +const { + SOURCE_ARCHIVE_BINDING_NAME, + validateLinuxSourceArchiveBinding, +} = require('../../tools/embedded-mpv/linux-source-archive-contract.cjs'); const { resolveLinuxFrameCopyLinkageInputs, resolveVerifiedLinuxLibMpvSoname, @@ -125,6 +129,34 @@ function readRuntimeManifest(runtimeRoot) { return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } +function readLinuxSourceArchiveBinding(runtimeRoot, errors) { + const bindingPath = path.join(runtimeRoot, SOURCE_ARCHIVE_BINDING_NAME); + if (!fs.existsSync(bindingPath)) { + return null; + } + let binding; + try { + const stat = fs.lstatSync(bindingPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error('binding must be a regular file'); + } + binding = JSON.parse(fs.readFileSync(bindingPath, 'utf8')); + } catch (error) { + errors.push( + `source archive binding is invalid: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } + errors.push( + ...validateLinuxSourceArchiveBinding(binding).map( + (error) => `source archive binding is invalid: ${error}` + ) + ); + return binding; +} + function validatedLinuxSourceRuntime(runtimeRoot) { const stagedManifest = readRuntimeManifest(runtimeRoot); if ( @@ -162,6 +194,7 @@ function validatedLinuxSourceRuntime(runtimeRoot) { const systemBuildInputs = isLinuxSystemBuildInputManifest(stagedManifest); let buildInputMode; + let sourceArchive = null; let sourceRuntimeManifest; if (systemBuildInputs) { buildInputMode = 'system-build-inputs'; @@ -185,6 +218,10 @@ function validatedLinuxSourceRuntime(runtimeRoot) { } } else { buildInputMode = 'bundled-runtime'; + sourceArchive = readLinuxSourceArchiveBinding( + runtimeRoot, + envelopeErrors + ); const sourceBuildOrigin = stagedManifest.sourceBuildOrigin; const sourceMetadata = { ...stagedManifest }; delete sourceMetadata.sourceBuildOrigin; @@ -238,6 +275,7 @@ function validatedLinuxSourceRuntime(runtimeRoot) { return { buildInputMode, + sourceArchive, sourceRuntimeManifest, sourceRuntimeValidated: buildInputMode === 'bundled-runtime', }; @@ -449,6 +487,7 @@ function assertRequiredLinuxFrameCopyRuntime(runtime) { if ( runtime.buildInputMode !== 'bundled-runtime' || runtime.sourceRuntimeValidated !== true || + !runtime.sourceArchive || !hasStagedLinuxLibMpvLinkerInput(runtime) ) { cleanOutput(); @@ -564,6 +603,7 @@ function writeLinuxFrameCopyBuildManifest(runtime) { (total, runtimeFile) => total + runtimeFile.size, 0 ), + sourceArchive: runtime.sourceArchive ?? null, sourceRuntime: runtime.sourceRuntimeManifest, }; diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 617fd098d..8e6b9febb 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -37,6 +37,8 @@ "production", "^production", "{workspaceRoot}/apps/electron-backend/native/build/Release/**", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], @@ -101,6 +103,8 @@ "production", "^production", "{workspaceRoot}/apps/electron-backend/native/build/Release/**", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], @@ -199,6 +203,8 @@ "lint": { "inputs": [ "default", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], @@ -211,6 +217,8 @@ "default", "^production", "{workspaceRoot}/jest.preset.js", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts index d9eaac5da..750d93b55 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts @@ -1,4 +1,5 @@ import runtimeProbeContract = require('../../../../../../tools/embedded-mpv/runtime-probe-contract.cjs'); +import sourceArchiveContract = require('../../../../../../tools/embedded-mpv/linux-source-archive-contract.cjs'); import type { EmbeddedMpvFrameCopyRuntimeMode, RuntimeProfile } from './types'; interface RuntimeProfileContract { @@ -14,9 +15,22 @@ export const FRAME_COPY_HELPER_NAME = 'iptvnator_mpv_helper'; export const RUNTIME_PROBE_PROTOCOL = 1; export const { RUNTIME_PROBE_MAX_BUFFER_BYTES, RUNTIME_PROBE_TIMEOUT_MS } = runtimeProbeContract; +export const { + SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + SOURCE_ARCHIVE_NAME, + validateLinuxSourceArchiveBinding, +} = sourceArchiveContract; export const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; export const SAFE_RUNTIME_NAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; export const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; +export const PINNED_LIBPLACEBO_SOURCE_SUBMODULES = [ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers (v1.4.337)', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float (v6.1.0-275-g97b54ca)', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad (v0.1.11a-302-g73db193)', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja (3.1.6)', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe (3.0.3)', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear (4.12.8)', +] as const; export const SHA256_PATTERN = /^[a-f0-9]{64}$/; export const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; export const SUBMODULE_RECORD_PATTERN = @@ -167,6 +181,7 @@ export const PINNED_SOURCE_PACKAGE_IDENTITIES = { sourceUrl: 'https://github.com/haasn/libplacebo.git', sourceTag: 'v7.360.1', sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4', + sourceSubmodules: PINNED_LIBPLACEBO_SOURCE_SUBMODULES, license: 'LGPL-2.1-or-later', }, hwdata: { @@ -254,6 +269,7 @@ export const DEVELOPMENT_MANIFEST_FIELDS = [ 'runtimeFiles', 'runtimeTotalBytes', 'schemaVersion', + 'sourceArchive', 'sourceRuntime', 'sourceRuntimeValidated', ] as const; @@ -309,5 +325,6 @@ export const BUNDLED_MANIFEST_FIELDS = [ ...BASE_MANIFEST_FIELDS, 'externalSystemLibraries', 'runtimeDependencyClosure', + 'sourceArchive', 'sourceRuntime', ] as const; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts index ed776ec80..57fd6bc06 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts @@ -78,6 +78,26 @@ describe('embedded-mpv frame-copy development manifest', () => { expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); }); + it('accepts a local bundled runtime before a release source archive is assembled', () => { + const fixture = createDevelopmentFixture( + context.rootDir, + 'bundled-runtime' + ); + const manifest = cloneManifest(fixture.manifest); + manifest.sourceArchive = null; + writeManifest(fixture.manifestPath, manifest); + + expect( + probeDevelopmentRuntime(context.createProbe(), fixture.helperPath) + ).toEqual( + expect.objectContaining({ + usable: true, + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalled(); + }); + it.each([ { label: 'origin', diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts index c374668d1..036253afb 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts @@ -43,6 +43,7 @@ describe('embedded-mpv frame-copy packaged manifest policy', () => { ['arch', 'arm64'], ['runtimeMode', 'system'], ['targets', ['deb']], + ['sourceArchive', null], ['unexpectedField', true], ])('rejects a bundled profile mismatch in %s', (field, value) => { const fixture = createFixture(context.rootDir, 'portable'); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts index efee26946..23e736193 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts @@ -8,6 +8,7 @@ import { EXPECTED_DEVELOPMENT_PROCESS_ISOLATION, EXPECTED_PROCESS_ISOLATION, PROFILE_CONTRACTS, + validateLinuxSourceArchiveBinding, SYSTEM_PACKAGE_DEPENDENCIES, VERSIONED_LIBMPV_PATTERN, } from './contracts'; @@ -102,6 +103,8 @@ export function validatePackagedManifest( manifest.libmpvSoname, manifest.externalSystemLibraries ) || + validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !== + 0 || !validateSourceRuntimePolicy( manifest.sourceRuntime, runtimeFiles, @@ -198,6 +201,7 @@ export function validateDevelopmentManifest( manifest.libmpvSoname !== null || !isDeepStrictEqual(manifest.runtimeFiles, []) || manifest.runtimeTotalBytes !== 0 || + manifest.sourceArchive !== null || !validateSystemDevelopmentSource( manifest.sourceRuntime, buildInputMode @@ -234,6 +238,9 @@ export function validateDevelopmentManifest( 0 ) || !isObject(sourceRuntime) || + (manifest.sourceArchive !== null && + validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !== + 0) || !validateRuntimeClosure( sourceRuntime.runtimeDependencyClosure, runtimeFiles, diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts index 5e5e1a8a8..84f4c6523 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts @@ -30,11 +30,6 @@ function createSourceRuntime( runtimeDependencyClosure: Record ): Record { const packages = cloneManifest(PINNED_SOURCE_PACKAGE_IDENTITIES); - ( - packages.libplacebo as typeof packages.libplacebo & { - sourceSubmodules: string[]; - } - ).sourceSubmodules = [`${'a'.repeat(40)} 3rdparty/example`]; return { schemaVersion: 1, origin: 'vendored-lgpl-source-build', @@ -114,6 +109,12 @@ export function createFixture( runtimeFiles, runtimeDependencyClosure ); + const sourceArchive = { + schemaVersion: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + sha256: '7'.repeat(64), + repositoryRevision: '8'.repeat(40), + }; const manifest: Record = { schemaVersion: 1, origin: bundled @@ -178,6 +179,7 @@ export function createFixture( ? { runtimeDependencyClosure, externalSystemLibraries, + sourceArchive, sourceRuntime, } : {}), @@ -253,6 +255,9 @@ export function createDevelopmentFixture( libmpvSoname: bundled ? 'libmpv.so.2' : null, runtimeFiles: fixture.manifest.runtimeFiles, runtimeTotalBytes: fixture.manifest.runtimeTotalBytes, + sourceArchive: bundled + ? cloneManifest(fixture.manifest.sourceArchive) + : null, sourceRuntime, }; fixture.manifest = manifest; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts index dc1df90a5..6faae8ff7 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts @@ -123,6 +123,14 @@ export const PINNED_SOURCE_PACKAGE_IDENTITIES = { sourceUrl: 'https://github.com/haasn/libplacebo.git', sourceTag: 'v7.360.1', sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4', + sourceSubmodules: [ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers (v1.4.337)', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float (v6.1.0-275-g97b54ca)', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad (v0.1.11a-302-g73db193)', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja (3.1.6)', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe (3.0.3)', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear (4.12.8)', + ], license: 'LGPL-2.1-or-later', }, hwdata: { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts index 3a80a5c9e..7ff3e1b35 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts @@ -89,6 +89,19 @@ describe('embedded-mpv frame-copy source runtime policy', () => { packages.libplacebo.sourceSubmodules = [record, record]; }, }, + { + label: 'unpinned git submodule commit', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.libplacebo.sourceSubmodules = [ + `${'f'.repeat(40)} 3rdparty/Vulkan-Headers (v1.4.337)`, + `${'e'.repeat(40)} 3rdparty/fast_float (v6.1.0-275-g97b54ca)`, + ]; + }, + }, { label: 'portable ABI baseline', mutate(sourceRuntime: Record) { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts index 824198446..ef5ad6c5f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts @@ -57,10 +57,12 @@ function validatesPinnedSourceIdentity( ); } return ( + 'sourceSubmodules' in expected && candidate.sourceGitCommit === expected.sourceGitCommit && GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) && candidate.sourceSha256 === undefined && - validatesGitSubmoduleRecords(candidate.sourceSubmodules) + validatesGitSubmoduleRecords(candidate.sourceSubmodules) && + isDeepStrictEqual(candidate.sourceSubmodules, expected.sourceSubmodules) ); } diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 40b380854..2359ec17a 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -282,7 +282,7 @@ allowlisted helper reason as `helperReason`. An optional `helperDetail` is copied only from the same exact line when it contains 1–1024 printable ASCII characters; an invalid detail rejects both helper fields. Malformed, multi-line, wrong-protocol, or unknown failure output never reaches either -field. Every probe uses the same explicit 16 MiB per-stream child-capture +field. Every probe uses the same explicit 16 MiB aggregate captured-output ceiling, independent of tracing, so verbose diagnostics do not fall back to Node's smaller implicit buffer. When `IPTVNATOR_TRACE_PLAYER=1`, the probe also emits non-empty captured helper stderr separately as one JSON line. JSON @@ -885,22 +885,73 @@ packaging passes with `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`, `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1`, and one exact `IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Each produced artifact is extracted and verified, and the x64 helper probe runs in the intended runtime environment. +The packaged x64 Playwright smoke first runs its fixture-contract target and +passes Chromium `--ignore-gpu-blocklist` so Mesa llvmpipe can expose WebGL2 in +CI. That launch-only flag does not bypass any manifest, hash, loader, or helper +capability check; `--no-sandbox` is added only when the runner is root. Bundled package layouts must include the generated notices and exact license tree. The separately uploaded `linux-frame-copy-runtime-sources.tar.xz` contains the exact archive set, -the VCS-metadata-free libplacebo working tree plus exact commit/submodule -records, notice/license inputs, runtime metadata, current revision/diff, and -build tooling. Its tar metadata is normalized. ARM artifacts are independently -verified as marker-only and never run the x64 helper. +the VCS-metadata-free libplacebo working tree plus the exact pinned commit and +six recursive submodule records, notice/license inputs, runtime metadata, +current revision/diff, and build tooling. The source index also records a +globally sorted exact inventory +of every libplacebo directory, regular file, and symlink. File hashes, sizes, +normalized executable bits, link targets, aggregate counts/bytes, and the +canonical inventory digest must match the trusted pinned v7.360.1 checkout; +an arbitrary or incomplete self-declared tree is rejected. Its tar metadata is +normalized, its member/type layout is exact, and +`metadata/archive-sha256.txt` must describe the actual source archive bytes. +Tar listing continues past every end marker, so concatenated xz/tar streams +cannot hide undeclared members. ARM artifacts are independently verified as +marker-only and never run the x64 helper. + +After constructing the final +`linux-frame-copy-runtime-sources.tar.xz`, CI hashes its exact bytes and stages +`source-archive-binding.json` beside the x64 runtime. AppImage, Snap, and +Flatpak manifests copy that binding unchanged as `sourceArchive`, including the +SHA-256 and repository revision. System-package manifests and marker-only +non-x64 packages must not carry it, so a portable package cannot advertise +source correspondence inherited from another profile or architecture. The build workflow creates a draft GitHub release but never publishes Snap in parallel with that draft. The separate Snap workflow runs only for a public `release.published` event whose tag starts with `v`; before any Store upload it requires at least one exact `.snap` asset and exactly one non-empty -`linux-frame-copy-runtime-sources.tar.xz` in that public release. The workflow -uploads only to the Store's edge channel. Candidate/stable promotion is manual -after installed-Snap frame-copy and missing-runtime fallback smoke; GitHub -Actions never promotes automatically. +`linux-frame-copy-runtime-sources.tar.xz` in that public release. It hashes and +safely inspects the bounded downloaded archive, requires regular metadata, +archive, legal, and tooling member/type set, validates link targets and the +archive checksum metadata, and requires the clean checkout and source index to +match the released tag. It verifies the actual pinned source-member hashes, +the six recursive libplacebo submodule records, license-input and notice +hashes, the exact VCS-free libplacebo tree inventory/digest, and byte-identical +tooling from the released tag. Checkout and both artifact-transfer actions use +full pinned commits, and checkout sets `persist-credentials: false`. +The bounded SquashFS preflight and extraction then require the canonical +`/usr/lib/iptvnator` layout and reuse the static package validator for every +selected Snap. Exactly one x64 Snap must contain a bundled portable manifest +whose exact `sourceArchive` and `sourceRuntime` match the archive; non-x64 +Snaps must remain marker-only. Repository credentials are scoped to the two +GitHub asset steps. The secretless verification job copies downloaded files +through no-follow descriptors into a private snapshot, hashes them before and +after inspection, writes an exact receipt, root-seals the snapshot, and reruns +the complete source/package verifier against those bytes. It then transfers +only the sealed data through the pinned artifact service, publishes the exact +receipt digest separately as a job output, and terminates. + +The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest` +runner with no checkout or release-tag code. It requires the separately +transmitted receipt digest, validates the exact receipt schema and every asset +size/hash, accepts only the expected regular `.snap`, source archive, and +receipt layout, rejects links and extra entries, and root-seals the transferred +files again before installing the official stable Snapcraft snap. +Only its final fixed shell step receives the Store credential. That step uses a +bounded Bash glob, resolves no PATH command, executes no released code, and +passes the credential only to each exact +`/snap/bin/snapcraft upload --release=edge` process. Any verification or +transfer mismatch aborts before Store credentials are available. +Candidate/stable promotion is manual after installed-Snap frame-copy and +missing-runtime fallback smoke; GitHub Actions never promotes automatically. During temporary artifact tests, CI may also set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for PR and `master` push jobs where a runtime is known to exist. After the artifacts are manually validated, remove temporary conditions so ordinary development builds leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. This keeps the native feature in-tree without making every non-release build depend on runtime artifacts. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 9038d0c68..39508ee53 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -240,7 +240,7 @@ for nonzero helper exits and adds `helperReason` only when the helper emitted one exact protocol-v1 line with a fixed allowlisted reason. Its optional `helperDetail` is restricted to 1–1024 printable ASCII characters; invalid detail suppresses both helper fields. Every probe has the same explicit 16 MiB -per-stream child-capture ceiling, regardless of tracing. With +aggregate captured-output ceiling, regardless of tracing. With `IPTVNATOR_TRACE_PLAYER=1`, non-empty captured helper stderr is written separately as one JSON-escaped stderr line: its `stderr` field contains at most the first 16,384 characters and its `truncated` boolean is always explicit. @@ -290,19 +290,65 @@ entry removed; the validated main/submodule commits remain in the source index. That source-compliance archive uses normalized tar metadata and contains the -exact unique archive hash set, VCS-free libplacebo sources and recursive -submodules, license inputs, generated notices, runtime/source index metadata, -and the builder, stager, manifest, notice-generator, and source-snapshot code. +exact unique archive hash set, VCS-free libplacebo sources and the exact pinned +six recursive submodule records, license inputs, generated notices, +runtime/source index metadata, and the builder, stager, manifest, +notice-generator, and source-snapshot code. +The source index carries a globally sorted inventory of every libplacebo +directory, file, and symlink. Regular-file hashes, sizes, normalized executable +bits, exact safe link targets, aggregate counts/bytes, and the canonical +inventory digest are checked against the trusted pinned v7.360.1 checkout. The +tar has an exact member/type layout, and `metadata/archive-sha256.txt` is +checked against the actual source archive bytes. Listing continues past every +tar end marker so concatenated xz streams cannot hide undeclared members. The notice generator rejects missing, undeclared, symlinked, size-mismatched, or hash-mismatched license files. +Once CI creates the final `linux-frame-copy-runtime-sources.tar.xz`, it writes +`source-archive-binding.json` beside the staged runtime with the archive's +SHA-256 and repository revision. Bundled x64 AppImage, Snap, and Flatpak +manifests copy that exact object as `sourceArchive`; system packages and +marker-only non-x64 packages omit it. + +The packaged x64 Playwright smoke depends on its fixture-contract target and +passes Chromium `--ignore-gpu-blocklist` so Mesa llvmpipe can provide WebGL2 in +CI. This affects only Chromium's software-renderer admission; the manifest, +hash, loader, and helper probes still fail closed, and `--no-sandbox` remains +root-only. + Snap publication is a separate `release.published` workflow for public `v*` GitHub releases. It verifies that the public release already contains at least one Snap and exactly one non-empty `linux-frame-copy-runtime-sources.tar.xz` before uploading anything. The -workflow uploads only to the Store's edge channel. -Candidate/stable promotion is manual after installed-Snap frame-copy and -missing-runtime fallback smoke; GitHub Actions never promotes automatically. +release verifier hashes the downloaded archive, checks its clean released +revision, exact member/type layout and safe link targets, source checksum +metadata, source index, actual pinned source-member hashes, six recursive +libplacebo submodule records, legal payload, exact trusted libplacebo tree +inventory/digest, released tooling, and runtime manifest. Checkout and both +artifact-transfer actions use full pinned commits, and checkout does not +persist its repository credential. The verifier bounds +source members, the archive, SquashFS listing, extracted size, entry count, +command time, and job time; every Snap must use the canonical +`/usr/lib/iptvnator` layout and pass the existing static package validator. +Exactly one x64 Snap is accepted, and only when its exact `sourceArchive` and +`sourceRuntime` match the downloaded archive; any non-x64 Snap must be +marker-only. A secretless job copies each asset through a no-follow descriptor, +checks hashes before and after inspection, writes an exact receipt, fully +reverifies a root-owned read-only snapshot, and transfers only that data +through the pinned artifact service while publishing the exact receipt digest +separately as a job output. + +The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest` +runner with no checkout or release-tag code. It verifies that separate digest, +the exact receipt schema, every asset size/hash, and the expected regular-file +layout, rejects links and extras, root-seals the transferred data again, and +installs the official stable Snapcraft snap. Only its final fixed shell step +receives the Store credential; it executes no released code, resolves no PATH +command, and passes the credential only to each exact +`/snap/bin/snapcraft upload --release=edge` process. GitHub credentials remain +scoped to asset selection/download. Candidate/stable +promotion is manual after installed-Snap frame-copy and missing-runtime +fallback smoke; GitHub Actions never promotes automatically. Windows CI stages a checksum-pinned x64 LGPL archive. The DLL basename encoded in its import library is preserved and must be present beside diff --git a/tools/embedded-mpv/build-linux-runtime.cjs b/tools/embedded-mpv/build-linux-runtime.cjs index 860a9a874..f46e8f711 100644 --- a/tools/embedded-mpv/build-linux-runtime.cjs +++ b/tools/embedded-mpv/build-linux-runtime.cjs @@ -3,12 +3,21 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); const HWDATA_BUILD_INPUT = Object.freeze({ consumer: 'libdisplay-info', relativePath: 'pnp.ids', purpose: 'PNP vendor lookup table compiled into libdisplay-info.', }); +const EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES = Object.freeze([ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers (v1.4.337)', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float (v6.1.0-275-g97b54ca)', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad (v0.1.11a-302-g73db193)', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja (3.1.6)', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe (3.0.3)', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear (4.12.8)', +]); const SOURCE_PACKAGES = Object.freeze( [ @@ -98,6 +107,7 @@ const SOURCE_PACKAGES = Object.freeze( sourceUrl: 'https://github.com/haasn/libplacebo.git', sourceTag: 'v7.360.1', expectedGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4', + expectedSubmodules: EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES, license: 'LGPL-2.1-or-later', }, { @@ -593,6 +603,16 @@ function assertGitCommitMatchesPin(sourcePackage, actualGitCommit) { } } +function assertGitSubmodulesMatchPin(sourcePackage, actualSubmodules) { + if ( + !isDeepStrictEqual(actualSubmodules, sourcePackage.expectedSubmodules) + ) { + throw new Error( + `${sourcePackage.id} git submodules do not match the exact pinned recursive records.` + ); + } +} + function parseVersion(value) { if (typeof value !== 'string') { return null; @@ -1508,6 +1528,10 @@ function createLinuxRuntimeManifest({ sourcePackage, sourceRecord.sourceGitCommit ); + assertGitSubmodulesMatchPin( + sourcePackage, + sourceRecord.sourceSubmodules + ); } packages[sourcePackage.id] = sourceManifestMetadata(sourceRecord); } @@ -1570,6 +1594,7 @@ module.exports = { BUILD_ORDER, DEFAULT_SYSTEM_PKG_CONFIG_DIRS, EXTERNAL_SYSTEM_LIBRARIES, + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES, EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, FFMPEG_CONFIGURE_FLAGS, GLIBC_TOOLCHAIN_ALLOWLIST, @@ -1582,6 +1607,7 @@ module.exports = { SOURCE_PACKAGES, assertArchiveMatchesPin, assertGitCommitMatchesPin, + assertGitSubmodulesMatchPin, assertMinimumToolVersions, assertOwnedOutputDestination, assertPortableAbiRecords, diff --git a/tools/embedded-mpv/build-linux-runtime.test.mjs b/tools/embedded-mpv/build-linux-runtime.test.mjs index ff3fd8ae0..3e7b0dbee 100644 --- a/tools/embedded-mpv/build-linux-runtime.test.mjs +++ b/tools/embedded-mpv/build-linux-runtime.test.mjs @@ -64,7 +64,7 @@ function createPinnedSourceRecords() { ? { sourceGitCommit: sourcePackage.expectedGitCommit, sourceSubmodules: [ - `${'a'.repeat(40)} 3rdparty/example`, + ...sourcePackage.expectedSubmodules, ], } : { diff --git a/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs b/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs index 42c11966e..7e9942342 100644 --- a/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs +++ b/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs @@ -39,7 +39,7 @@ function runtimeManifest() { : { sourceGitCommit: sourcePackage.expectedGitCommit, sourceSubmodules: [ - `${'a'.repeat(40)} 3rdparty/example`, + ...sourcePackage.expectedSubmodules, ], }), license: sourcePackage.license, diff --git a/tools/embedded-mpv/linux-runtime-manifest.cjs b/tools/embedded-mpv/linux-runtime-manifest.cjs index fed9a20ab..973c3afdb 100644 --- a/tools/embedded-mpv/linux-runtime-manifest.cjs +++ b/tools/embedded-mpv/linux-runtime-manifest.cjs @@ -259,6 +259,15 @@ function validatePackages(errors, packages) { errors.push( `Linux runtime manifest ${label}.sourceSubmodules must be a non-empty array of commit-and-path records.` ); + } else if ( + !isDeepStrictEqual( + packageMetadata.sourceSubmodules, + pinnedPackage.expectedSubmodules + ) + ) { + errors.push( + `Linux runtime manifest ${label}.sourceSubmodules must equal the pinned records.` + ); } } } diff --git a/tools/embedded-mpv/linux-runtime-manifest.test.mjs b/tools/embedded-mpv/linux-runtime-manifest.test.mjs index 517dd10e1..e39323576 100644 --- a/tools/embedded-mpv/linux-runtime-manifest.test.mjs +++ b/tools/embedded-mpv/linux-runtime-manifest.test.mjs @@ -34,6 +34,14 @@ const stageRuntimeScript = path.join( 'stage-runtime.mjs' ); const stageRuntimeSource = fs.readFileSync(stageRuntimeScript, 'utf8'); +const EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES = Object.freeze([ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers (v1.4.337)', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float (v6.1.0-275-g97b54ca)', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad (v0.1.11a-302-g73db193)', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja (3.1.6)', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe (3.0.3)', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear (4.12.8)', +]); function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); @@ -61,7 +69,7 @@ function sourcePackageRecord(sourcePackage) { ? { sourceSha256: sourcePackage.expectedSha256 } : { sourceGitCommit: sourcePackage.expectedGitCommit, - sourceSubmodules: [`${'a'.repeat(40)} 3rdparty/example`], + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], }), license: sourcePackage.license, }; @@ -325,6 +333,13 @@ test('requires pinned archive hashes and exact libplacebo git provenance', () => /packages\.libplacebo\.sourceGitCommit must equal the pinned commit/ ); + const submoduleCommitMismatch = createValidManifest(); + submoduleCommitMismatch.packages.libplacebo.sourceSubmodules[0] = `${'f'.repeat(40)} 3rdparty/Vulkan-Headers (v1.4.337)`; + assert.match( + validateLinuxRuntimeManifest(submoduleCommitMismatch).join('\n'), + /packages\.libplacebo\.sourceSubmodules must equal the pinned records/ + ); + const hostHwdataInput = createValidManifest(); hostHwdataInput.packages.hwdata.buildInput.relativePath = '/usr/share/hwdata/pnp.ids'; diff --git a/tools/embedded-mpv/linux-source-archive-contract.cjs b/tools/embedded-mpv/linux-source-archive-contract.cjs new file mode 100644 index 000000000..e6e892bfd --- /dev/null +++ b/tools/embedded-mpv/linux-source-archive-contract.cjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const { isDeepStrictEqual } = require('node:util'); + +const SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION = 1; +const SOURCE_ARCHIVE_NAME = 'linux-frame-copy-runtime-sources.tar.xz'; +const SOURCE_ARCHIVE_BINDING_NAME = 'source-archive-binding.json'; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +function validateLinuxSourceArchiveBinding( + binding, + { expectedRepositoryRevision, expectedSha256 } = {} +) { + const errors = []; + if ( + binding === null || + typeof binding !== 'object' || + Array.isArray(binding) + ) { + return ['Linux source archive binding must be an object.']; + } + if ( + !isDeepStrictEqual(Object.keys(binding).sort(), [ + 'name', + 'repositoryRevision', + 'schemaVersion', + 'sha256', + ]) + ) { + errors.push( + 'Linux source archive binding must contain only schemaVersion, name, sha256, and repositoryRevision.' + ); + } + if (binding.schemaVersion !== SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION) { + errors.push( + `Linux source archive binding schemaVersion must equal ${SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION}.` + ); + } + if (binding.name !== SOURCE_ARCHIVE_NAME) { + errors.push( + `Linux source archive binding name must equal ${SOURCE_ARCHIVE_NAME}.` + ); + } + if ( + typeof binding.sha256 !== 'string' || + !SHA256_PATTERN.test(binding.sha256) + ) { + errors.push( + 'Linux source archive binding sha256 must be a lowercase SHA-256 digest.' + ); + } + if ( + typeof binding.repositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(binding.repositoryRevision) + ) { + errors.push( + 'Linux source archive binding repositoryRevision must be a full Git commit.' + ); + } + if ( + expectedRepositoryRevision !== undefined && + binding.repositoryRevision !== expectedRepositoryRevision + ) { + errors.push( + 'Linux source archive binding repositoryRevision does not match the expected release commit.' + ); + } + if (expectedSha256 !== undefined && binding.sha256 !== expectedSha256) { + errors.push( + 'Linux source archive binding sha256 does not match the source archive bytes.' + ); + } + return errors; +} + +function sha256File(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + const hash = crypto.createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + try { + let bytesRead; + do { + bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (bytesRead > 0) { + hash.update(buffer.subarray(0, bytesRead)); + } + } while (bytesRead > 0); + } finally { + fs.closeSync(descriptor); + } + return hash.digest('hex'); +} + +function createLinuxSourceArchiveBinding({ archivePath, repositoryRevision }) { + const stat = fs.lstatSync(archivePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) { + throw new Error( + 'Linux source archive must be a non-empty regular file.' + ); + } + const binding = { + schemaVersion: SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + name: SOURCE_ARCHIVE_NAME, + sha256: sha256File(archivePath), + repositoryRevision, + }; + const errors = validateLinuxSourceArchiveBinding(binding); + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } + return binding; +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + if (tokens.length % 2 !== 0) { + throw new Error('Linux source archive binding arguments are invalid.'); + } + const options = {}; + for (let index = 0; index < tokens.length; index += 2) { + const token = tokens[index]; + const value = tokens[index + 1]; + if ( + !token.startsWith('--') || + value === undefined || + Object.hasOwn(options, token.slice(2)) + ) { + throw new Error( + 'Linux source archive binding arguments are invalid.' + ); + } + options[token.slice(2)] = value; + } + return { command, options }; +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command !== 'create' || + !options.archive || + !options['repository-revision'] || + !options.output + ) { + throw new Error( + 'Usage: linux-source-archive-contract.cjs create --archive --repository-revision --output ' + ); + } + const binding = createLinuxSourceArchiveBinding({ + archivePath: options.archive, + repositoryRevision: options['repository-revision'], + }); + fs.writeFileSync(options.output, `${JSON.stringify(binding, null, 2)}\n`, { + mode: 0o644, + }); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} + +module.exports = { + SOURCE_ARCHIVE_BINDING_NAME, + SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + SOURCE_ARCHIVE_NAME, + createLinuxSourceArchiveBinding, + sha256File, + validateLinuxSourceArchiveBinding, +}; diff --git a/tools/embedded-mpv/linux-source-archive-contract.d.cts b/tools/embedded-mpv/linux-source-archive-contract.d.cts new file mode 100644 index 000000000..0ecb0bce8 --- /dev/null +++ b/tools/embedded-mpv/linux-source-archive-contract.d.cts @@ -0,0 +1,26 @@ +interface LinuxSourceArchiveBinding { + schemaVersion: 1; + name: 'linux-frame-copy-runtime-sources.tar.xz'; + sha256: string; + repositoryRevision: string; +} + +declare const LINUX_SOURCE_ARCHIVE_CONTRACT: { + readonly SOURCE_ARCHIVE_BINDING_NAME: 'source-archive-binding.json'; + readonly SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION: 1; + readonly SOURCE_ARCHIVE_NAME: 'linux-frame-copy-runtime-sources.tar.xz'; + createLinuxSourceArchiveBinding(options: { + archivePath: string; + repositoryRevision: string; + }): LinuxSourceArchiveBinding; + sha256File(filePath: string): string; + validateLinuxSourceArchiveBinding( + binding: unknown, + options?: { + expectedRepositoryRevision?: string; + expectedSha256?: string; + } + ): string[]; +}; + +export = LINUX_SOURCE_ARCHIVE_CONTRACT; diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index 7dfc4f9eb..4f3ec6b81 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -344,6 +344,15 @@ test('Linux CI builds one cached source runtime and packages three isolated prof complianceStep, /tar[\s\S]*linux-frame-copy-runtime-sources\.tar\.xz/ ); + assert.match( + complianceStep, + /linux-source-archive-contract\.cjs create[\s\S]*source-archive-binding\.json/ + ); + assert.ok( + complianceStep.indexOf( + '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' + ) < complianceStep.indexOf('linux-source-archive-contract.cjs create') + ); assert.match(complianceStep, /source-index\.json/); assert.match(complianceStep, /THIRD_PARTY_NOTICES\.txt/); assert.match(complianceStep, /embedded-mpv-notices\.json/); @@ -386,6 +395,10 @@ test('Linux CI builds one cached source runtime and packages three isolated prof buildWorkflow, /hashFiles\([^\n]*prepare-linux-runtime-source-snapshot\.cjs/ ); + assert.match( + buildWorkflow, + /hashFiles\([^\n]*linux-source-archive-contract\.cjs/ + ); const buildStep = workflowStep('Build and stage pinned LGPL Linux runtime'); assert.match(buildStep, /generate-linux-runtime-notices\.cjs collect/); @@ -412,6 +425,13 @@ test('Linux CI builds one cached source runtime and packages three isolated prof assert.match(buildWorkflow, /linux-frame-copy-runtime-sources\.tar\.xz/); assert.match(buildWorkflow, /name: linux-frame-copy-runtime-sources/); assert.match(buildWorkflow, /sourceSha256[\s\S]*source-index\.json/); + assert.match( + buildWorkflow, + /sourceArchive\?\.name[\s\S]*sourceArchive\?\.sha256/ + ); + assert.match(buildWorkflow, /sourceArchive\?\.schemaVersion/); + assert.match(buildWorkflow, /sourceArchive\?\.repositoryRevision/); + assert.match(buildWorkflow, /execFileSync\('git', \['rev-parse', 'HEAD'\]/); }); test('keeps non-Linux builds independent from the Linux runtime prerequisite', () => { @@ -747,6 +767,21 @@ test('Snap verifier preserves fail-closed status while exposing captured diagnos }); test('dedicated packaged x64 smoke cannot silently skip', () => { + const e2eProject = JSON.parse( + fs.readFileSync( + path.join( + workspaceRoot, + 'apps', + 'electron-backend-e2e', + 'project.json' + ), + 'utf8' + ) + ); + assert.deepEqual( + e2eProject.targets?.['packaged-frame-copy-smoke']?.dependsOn, + ['test-packaged-frame-copy-fixtures'] + ); const linuxDependencies = workflowStep('Install Linux system dependencies'); assert.match(linuxDependencies, /--no-install-recommends/); assert.match(linuxDependencies, /^\s+libgl-dev\s*\\?$/m); diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index 67c72016a..ca37b0d02 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -62,7 +62,7 @@ function sourcePackageRecord(sourcePackage) { ? { sourceSha256: sourcePackage.expectedSha256 } : { sourceGitCommit: sourcePackage.expectedGitCommit, - sourceSubmodules: [`${'a'.repeat(40)} 3rdparty/example`], + sourceSubmodules: [...sourcePackage.expectedSubmodules], }), license: sourcePackage.license, }; @@ -172,6 +172,12 @@ function createBuildManifest(runtimeContents) { libmpvSoname: 'libmpv.so.2', runtimeFiles: sourceRuntime.runtimeFiles.map((entry) => ({ ...entry })), runtimeTotalBytes: sourceRuntime.runtimeTotalBytes, + sourceArchive: { + schemaVersion: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + sha256: '7'.repeat(64), + repositoryRevision: '8'.repeat(40), + }, sourceRuntime, }; } @@ -245,6 +251,7 @@ function createNativeFixture({ fs.cpSync(noticeSourceDir, nativeDir, { recursive: true }); fs.writeFileSync(join(appOutDir, 'iptvnator.bin'), 'electron'); return { + buildManifest, fixtureRoot, resourceDir, appOutDir, @@ -549,6 +556,7 @@ test('prepares a normalized system profile with no private runtime', (t) => { assert.equal(manifest.libmpvSoname, 'libmpv.so.2'); assert.deepEqual(manifest.runtimeFiles, []); assert.equal(manifest.runtimeTotalBytes, 0); + assert.equal(Object.hasOwn(manifest, 'sourceArchive'), false); assert.equal(fs.existsSync(join(fixture.nativeDir, 'lib')), false); assert.equal( fs.statSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper)).mode & @@ -645,6 +653,10 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', ( 'vendored-lgpl-source-build' ); assert.ok(portableManifest.sourceRuntime.packages.ffmpeg.sourceSha256); + assert.deepEqual( + portableManifest.sourceArchive, + portable.buildManifest.sourceArchive + ); assert.ok( portableManifest.sourceRuntime.ffmpeg.configureFlags.includes( '--disable-gpl' @@ -667,6 +679,10 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', ( assert.equal(flatpakManifest.profile, 'flatpak'); assert.equal(flatpakManifest.runtimeMode, 'bundled'); assert.equal(flatpakManifest.origin, 'bundled-lgpl-frame-copy'); + assert.deepEqual( + flatpakManifest.sourceArchive, + flatpak.buildManifest.sourceArchive + ); assert.deepEqual( validatePackagedEmbeddedMpv( portable.resourceDir, @@ -781,6 +797,15 @@ test('rejects invalid build provenance and incomplete bundled runtime input', (t const wrongSoname = createNativeFixture({ buildManifest: wrongSonameManifest, }); + const wrongSourceArchiveManifest = createBuildManifest({ + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }); + wrongSourceArchiveManifest.sourceArchive.sha256 = 'not-a-digest'; + const wrongSourceArchive = createNativeFixture({ + buildManifest: wrongSourceArchiveManifest, + }); t.after(() => { for (const fixture of [ extra, @@ -788,6 +813,7 @@ test('rejects invalid build provenance and incomplete bundled runtime input', (t invalid, unexpectedMode, wrongSoname, + wrongSourceArchive, ]) { fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }); } @@ -839,6 +865,18 @@ test('rejects invalid build provenance and incomplete bundled runtime input', (t }), /derived from.*SONAME/i ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts( + wrongSourceArchive.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['snap'], + } + ), + /source archive binding.*sha256/i + ); }); test('rejects profiled preparation without a concrete package target', (t) => { diff --git a/tools/packaging/embedded-mpv-frame-copy-files.cjs b/tools/packaging/embedded-mpv-frame-copy-files.cjs index 2cbe77109..eedffeccc 100644 --- a/tools/packaging/embedded-mpv-frame-copy-files.cjs +++ b/tools/packaging/embedded-mpv-frame-copy-files.cjs @@ -7,6 +7,9 @@ const { isDeepStrictEqual } = require('node:util'); const { validateLinuxRuntimeManifest, } = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + validateLinuxSourceArchiveBinding, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); const { NOTICE_MANIFEST, THIRD_PARTY_NOTICES, @@ -194,6 +197,11 @@ function validateLinuxFrameCopyBuildManifest(manifest) { (error) => `Invalid bundled source runtime: ${error}` ) ); + errors.push( + ...validateLinuxSourceArchiveBinding(manifest.sourceArchive).map( + (error) => `Invalid Linux source archive binding: ${error}` + ) + ); if ( Array.isArray(manifest.runtimeFiles) && Array.isArray(manifest.sourceRuntime?.runtimeFiles) && @@ -444,6 +452,7 @@ function createPackagedManifest(buildManifest, profile, targetNames) { buildManifest.sourceRuntime.externalSystemLibraries.map( (entry) => ({ ...entry }) ), + sourceArchive: structuredClone(buildManifest.sourceArchive), sourceRuntime: structuredClone(buildManifest.sourceRuntime), } : {}), diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index 3ae2fa784..02e2a0711 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -12,6 +12,9 @@ const { const { validateLinuxRuntimeManifest, } = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + validateLinuxSourceArchiveBinding, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); const { NOTICE_MANIFEST, THIRD_PARTY_NOTICES, @@ -1088,6 +1091,7 @@ function validateSystemLinuxRuntime(nativeDir, manifest, errors) { for (const forbiddenField of [ 'runtimeDependencyClosure', 'externalSystemLibraries', + 'sourceArchive', 'sourceRuntime', ]) { if (Object.hasOwn(manifest, forbiddenField)) { @@ -1127,6 +1131,11 @@ function validateBundledLinuxRuntime(nativeDir, manifest, errors) { (error) => `Invalid packaged Linux source runtime: ${error}` ) ); + errors.push( + ...validateLinuxSourceArchiveBinding(manifest.sourceArchive).map( + (error) => `Invalid packaged Linux source archive binding: ${error}` + ) + ); errors.push( ...validateLinuxRuntimeNotices(nativeDir, manifest.sourceRuntime, { allowUnrelatedFiles: true, diff --git a/tools/packaging/prepare-linux-runtime-source-snapshot.cjs b/tools/packaging/prepare-linux-runtime-source-snapshot.cjs index 5fcb54634..828e76533 100644 --- a/tools/packaging/prepare-linux-runtime-source-snapshot.cjs +++ b/tools/packaging/prepare-linux-runtime-source-snapshot.cjs @@ -3,11 +3,28 @@ 'use strict'; const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { isDeepStrictEqual } = require('node:util'); const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT = Object.freeze({ + schemaVersion: 1, + hashAlgorithm: 'sha256', + canonicalEncoding: 'utf8-json-line-v1', +}); + +// Derived from a clean recursive checkout of libplacebo v7.360.1 at +// cee9b076f2c63104ccfd497fa79c39a867293ec4 with every recorded submodule at +// its pinned commit. The inventory contract above excludes all .git entries. +// Derivation: git clone --branch v7.360.1 --single-branch --recurse-submodules +// https://github.com/haasn/libplacebo.git, then globally sort and inventory the +// VCS-free copy under LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT. +// The trusted snapshot has 1,456 entries and 54,312,340 regular-file bytes. +const EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256 = + '0db67c1523411255244186af437e9fbfe7ccac04a5ac1b3dc9275dd0806f6f0c'; function gitOutput(checkoutPath, ...args) { try { @@ -158,6 +175,379 @@ function assertNoGitMetadata(rootPath) { } } +function compareCanonicalPaths(left, right) { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +function sha256File(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + try { + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) { + throw new Error( + `Unsupported source snapshot entry (not a regular file): ${filePath}` + ); + } + if (!Number.isSafeInteger(stat.size) || stat.size < 0) { + throw new Error( + `Source snapshot file has an unsupported size: ${filePath}` + ); + } + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + let offset = 0; + while (offset < stat.size) { + const bytesRead = fs.readSync( + descriptor, + buffer, + 0, + Math.min(buffer.length, stat.size - offset), + offset + ); + if (bytesRead === 0) { + throw new Error( + `Source snapshot file changed while hashing: ${filePath}` + ); + } + hash.update(buffer.subarray(0, bytesRead)); + offset += bytesRead; + } + const finalStat = fs.fstatSync(descriptor); + if ( + !finalStat.isFile() || + finalStat.size !== stat.size || + finalStat.mtimeMs !== stat.mtimeMs + ) { + throw new Error( + `Source snapshot file changed while hashing: ${filePath}` + ); + } + return { + size: stat.size, + executable: (stat.mode & 0o111) !== 0, + sha256: hash.digest('hex'), + }; + } finally { + fs.closeSync(descriptor); + } +} + +function assertSafeSnapshotPath(relativePath) { + if ( + typeof relativePath !== 'string' || + relativePath.length === 0 || + relativePath.includes('\\') || + path.posix.isAbsolute(relativePath) || + path.win32.isAbsolute(relativePath) || + [...relativePath].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) || + relativePath + .split('/') + .some( + (part) => + part === '' || + part === '.' || + part === '..' || + part === '.git' + ) + ) { + throw new Error(`Unsafe source snapshot path: ${relativePath}`); + } +} + +function assertSafeSymlinkTarget(relativePath, target) { + const targetSegments = target.split('/'); + const resolvedTarget = path.posix.normalize( + path.posix.join(path.posix.dirname(relativePath), target) + ); + if ( + target.length === 0 || + target.includes('\\') || + path.posix.isAbsolute(target) || + path.win32.isAbsolute(target) || + [...target].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) || + resolvedTarget === '..' || + resolvedTarget.startsWith('../') || + path.posix.isAbsolute(resolvedTarget) || + targetSegments.includes('.git') + ) { + throw new Error( + `Unsafe source snapshot symlink ${relativePath}: ${target}` + ); + } +} + +function hasExactFields(value, expectedFields) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + isDeepStrictEqual( + Object.keys(value).sort(compareCanonicalPaths), + [...expectedFields].sort(compareCanonicalPaths) + ) + ); +} + +function canonicalSourceSnapshotSha256({ + schemaVersion, + entryCount, + totalBytes, + entries, +}) { + return crypto + .createHash('sha256') + .update( + `${JSON.stringify({ + schemaVersion, + entryCount, + totalBytes, + entries, + })}\n`, + 'utf8' + ) + .digest('hex'); +} + +function invalidSourceSnapshot(detail) { + throw new Error(`Invalid source snapshot: ${detail}`); +} + +function validateLinuxRuntimeSourceSnapshot(snapshot, { expectedSha256 } = {}) { + if ( + !hasExactFields(snapshot, [ + 'schemaVersion', + 'sha256', + 'entryCount', + 'totalBytes', + 'entries', + ]) || + snapshot.schemaVersion !== + LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion || + typeof snapshot.sha256 !== 'string' || + !SHA256_PATTERN.test(snapshot.sha256) || + !Number.isSafeInteger(snapshot.entryCount) || + snapshot.entryCount < 0 || + !Number.isSafeInteger(snapshot.totalBytes) || + snapshot.totalBytes < 0 || + !Array.isArray(snapshot.entries) + ) { + invalidSourceSnapshot('top-level fields do not match the contract.'); + } + + const entries = []; + const entryTypes = new Map(); + let previousPath = null; + let totalBytes = 0; + for (const entry of snapshot.entries) { + if ( + entry === null || + typeof entry !== 'object' || + Array.isArray(entry) || + typeof entry.path !== 'string' + ) { + invalidSourceSnapshot('an entry is not an exact object.'); + } + assertSafeSnapshotPath(entry.path); + if ( + previousPath !== null && + compareCanonicalPaths(previousPath, entry.path) >= 0 + ) { + throw new Error( + 'Invalid source snapshot: entry paths must be sorted and unique.' + ); + } + previousPath = entry.path; + + let normalizedEntry; + if (entry.type === 'directory') { + if (!hasExactFields(entry, ['path', 'type'])) { + invalidSourceSnapshot( + `directory entry ${entry.path} has invalid fields.` + ); + } + normalizedEntry = { + path: entry.path, + type: 'directory', + }; + } else if (entry.type === 'file') { + if ( + !hasExactFields(entry, [ + 'path', + 'type', + 'size', + 'executable', + 'sha256', + ]) || + !Number.isSafeInteger(entry.size) || + entry.size < 0 || + typeof entry.executable !== 'boolean' || + typeof entry.sha256 !== 'string' || + !SHA256_PATTERN.test(entry.sha256) + ) { + invalidSourceSnapshot( + `file entry ${entry.path} has invalid fields.` + ); + } + totalBytes += entry.size; + if (!Number.isSafeInteger(totalBytes)) { + invalidSourceSnapshot( + 'regular-file byte total exceeds the supported range.' + ); + } + normalizedEntry = { + path: entry.path, + type: 'file', + size: entry.size, + executable: entry.executable, + sha256: entry.sha256, + }; + } else if (entry.type === 'symlink') { + if ( + !hasExactFields(entry, ['path', 'type', 'target']) || + typeof entry.target !== 'string' + ) { + invalidSourceSnapshot( + `symlink entry ${entry.path} has invalid fields.` + ); + } + assertSafeSymlinkTarget(entry.path, entry.target); + normalizedEntry = { + path: entry.path, + type: 'symlink', + target: entry.target, + }; + } else { + invalidSourceSnapshot( + `entry ${entry.path} has an unsupported type.` + ); + } + + const parentPath = path.posix.dirname(entry.path); + if (parentPath !== '.' && entryTypes.get(parentPath) !== 'directory') { + throw new Error( + `Invalid source snapshot: parent ${parentPath} of ${entry.path} must be a directory entry.` + ); + } + entryTypes.set(entry.path, entry.type); + entries.push(normalizedEntry); + } + + if ( + snapshot.entryCount !== entries.length || + snapshot.totalBytes !== totalBytes + ) { + invalidSourceSnapshot( + 'entryCount or totalBytes does not match the entries.' + ); + } + const normalizedSnapshot = { + schemaVersion: LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion, + sha256: snapshot.sha256, + entryCount: entries.length, + totalBytes, + entries, + }; + const canonicalSha256 = canonicalSourceSnapshotSha256(normalizedSnapshot); + if (snapshot.sha256 !== canonicalSha256) { + invalidSourceSnapshot('canonical SHA-256 does not match the entries.'); + } + assertExpectedSourceSnapshot(normalizedSnapshot, expectedSha256); + return normalizedSnapshot; +} + +function inventoryLinuxRuntimeSourceSnapshot(rootPath) { + const rootStat = fs.lstatSync(rootPath); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + throw new Error('Source snapshot root must be a real directory.'); + } + assertNoGitMetadata(rootPath); + const entries = []; + let totalBytes = 0; + + function visit(directoryPath, relativeDirectory = '') { + const childNames = fs + .readdirSync(directoryPath) + .sort(compareCanonicalPaths); + for (const childName of childNames) { + const relativePath = relativeDirectory + ? `${relativeDirectory}/${childName}` + : childName; + assertSafeSnapshotPath(relativePath); + const absolutePath = path.join(directoryPath, childName); + const stat = fs.lstatSync(absolutePath); + if (stat.isDirectory()) { + entries.push({ + path: relativePath, + type: 'directory', + }); + visit(absolutePath, relativePath); + continue; + } + if (stat.isFile()) { + const file = sha256File(absolutePath); + totalBytes += file.size; + if (!Number.isSafeInteger(totalBytes)) { + throw new Error( + 'Source snapshot total byte count exceeds the supported range.' + ); + } + entries.push({ + path: relativePath, + type: 'file', + size: file.size, + executable: file.executable, + sha256: file.sha256, + }); + continue; + } + if (stat.isSymbolicLink()) { + const target = fs.readlinkSync(absolutePath); + assertSafeSymlinkTarget(relativePath, target); + entries.push({ + path: relativePath, + type: 'symlink', + target, + }); + continue; + } + throw new Error( + `Unsupported source snapshot entry: ${relativePath}` + ); + } + } + + visit(rootPath); + entries.sort(({ path: left }, { path: right }) => + compareCanonicalPaths(left, right) + ); + const canonicalInventory = { + schemaVersion: LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion, + entryCount: entries.length, + totalBytes, + entries, + }; + const sha256 = canonicalSourceSnapshotSha256(canonicalInventory); + return { + schemaVersion: canonicalInventory.schemaVersion, + sha256, + entryCount: canonicalInventory.entryCount, + totalBytes: canonicalInventory.totalBytes, + entries, + }; +} + function lstatIfExists(candidatePath) { try { return fs.lstatSync(candidatePath); @@ -174,7 +564,33 @@ function lstatIfExists(candidatePath) { } } -function copyWorkingTreeWithoutGitMetadata(checkoutPath, outputPath) { +function assertExpectedSourceSnapshot( + sourceSnapshot, + expectedSourceSnapshotSha256 +) { + if (expectedSourceSnapshotSha256 === undefined) { + return; + } + if ( + typeof expectedSourceSnapshotSha256 !== 'string' || + !SHA256_PATTERN.test(expectedSourceSnapshotSha256) + ) { + throw new Error( + 'Expected source snapshot digest must be a lowercase SHA-256 digest.' + ); + } + if (sourceSnapshot.sha256 !== expectedSourceSnapshotSha256) { + throw new Error( + `Source snapshot digest mismatch: expected ${expectedSourceSnapshotSha256}, received ${sourceSnapshot.sha256}.` + ); + } +} + +function copyWorkingTreeWithoutGitMetadata( + checkoutPath, + outputPath, + expectedSourceSnapshotSha256 +) { const outputParent = path.dirname(outputPath); const temporaryPath = fs.mkdtempSync( path.join(outputParent, `.${path.basename(outputPath)}-`) @@ -187,12 +603,19 @@ function copyWorkingTreeWithoutGitMetadata(checkoutPath, outputPath) { filter: (sourcePath) => path.basename(sourcePath) !== '.git', }); assertNoGitMetadata(temporaryPath); + const sourceSnapshot = + inventoryLinuxRuntimeSourceSnapshot(temporaryPath); + assertExpectedSourceSnapshot( + sourceSnapshot, + expectedSourceSnapshotSha256 + ); if (lstatIfExists(outputPath)) { throw new Error( `Prepared source snapshot output must not already exist: ${outputPath}` ); } fs.renameSync(temporaryPath, outputPath); + return sourceSnapshot; } catch (error) { fs.rmSync(temporaryPath, { recursive: true, force: true }); throw error; @@ -203,6 +626,7 @@ function prepareLinuxRuntimeSourceSnapshot({ checkoutPath, outputPath, expected, + expectedSourceSnapshotSha256, }) { const checkoutStat = fs.lstatSync(checkoutPath); if (!checkoutStat.isDirectory() || checkoutStat.isSymbolicLink()) { @@ -244,9 +668,16 @@ function prepareLinuxRuntimeSourceSnapshot({ ); } const record = inspectCleanGitSource(checkoutRoot, expected); - copyWorkingTreeWithoutGitMetadata(checkoutRoot, realOutputRoot); + const sourceSnapshot = copyWorkingTreeWithoutGitMetadata( + checkoutRoot, + realOutputRoot, + expectedSourceSnapshotSha256 + ); assertNoGitMetadata(realOutputRoot); - return record; + return { + ...record, + sourceSnapshot, + }; } function parseArguments(argv) { @@ -289,6 +720,8 @@ function main(argv = process.argv.slice(2)) { sourceGitCommit: sourcePackage?.sourceGitCommit, sourceSubmodules: sourcePackage?.sourceSubmodules, }, + expectedSourceSnapshotSha256: + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, }); writeJson(options['record-output'], record); return; @@ -312,7 +745,11 @@ if (require.main === module) { } module.exports = { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT, assertNoGitMetadata, inspectCleanGitSource, + inventoryLinuxRuntimeSourceSnapshot, prepareLinuxRuntimeSourceSnapshot, + validateLinuxRuntimeSourceSnapshot, }; diff --git a/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs b/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs index c767efa5e..19d87b5bf 100644 --- a/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs +++ b/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs @@ -174,6 +174,203 @@ function snapshotRecords(root) { return records; } +const FIXTURE_SOURCE_SNAPSHOT = Object.freeze({ + schemaVersion: 1, + sha256: '445a78c08a661d68f8ab15a790ba9c3462766d23e31b00eeca7429544c21a497', + entryCount: 4, + totalBytes: 22, + entries: [ + { + path: 'LICENSE', + type: 'file', + size: 8, + executable: false, + sha256: 'c0c56958ef8be5c1979366896b7e0c7206949a5aa2b23f51429c7f56b10990d3', + }, + { + path: 'src', + type: 'directory', + }, + { + path: 'src/license-link', + type: 'symlink', + target: '../LICENSE', + }, + { + path: 'src/renderer.c', + type: 'file', + size: 14, + executable: false, + sha256: 'bc8bb8e433bf65214540115414c821c904b2a30d60a3ac0424bf9b77a00024b7', + }, + ], +}); + +function cloneFixtureSourceSnapshot() { + return JSON.parse(JSON.stringify(FIXTURE_SOURCE_SNAPSHOT)); +} + +test('purely validates the exact source snapshot contract independent of object key order', async () => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const shuffled = { + entries: FIXTURE_SOURCE_SNAPSHOT.entries.map((entry) => { + if (entry.type === 'file') { + return { + sha256: entry.sha256, + executable: entry.executable, + size: entry.size, + type: entry.type, + path: entry.path, + }; + } + if (entry.type === 'symlink') { + return { + target: entry.target, + type: entry.type, + path: entry.path, + }; + } + return { + type: entry.type, + path: entry.path, + }; + }), + totalBytes: FIXTURE_SOURCE_SNAPSHOT.totalBytes, + entryCount: FIXTURE_SOURCE_SNAPSHOT.entryCount, + sha256: FIXTURE_SOURCE_SNAPSHOT.sha256, + schemaVersion: FIXTURE_SOURCE_SNAPSHOT.schemaVersion, + }; + + assert.deepEqual( + helper.validateLinuxRuntimeSourceSnapshot(shuffled), + FIXTURE_SOURCE_SNAPSHOT + ); + assert.deepEqual( + helper.validateLinuxRuntimeSourceSnapshot(shuffled, { + expectedSha256: FIXTURE_SOURCE_SNAPSHOT.sha256, + }), + FIXTURE_SOURCE_SNAPSHOT + ); + assert.deepEqual(shuffled.entries[0], { + sha256: FIXTURE_SOURCE_SNAPSHOT.entries[0].sha256, + executable: false, + size: 8, + type: 'file', + path: 'LICENSE', + }); +}); + +test('rejects non-exact source snapshot fields and malformed entry shapes', async () => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const invalidSnapshots = []; + + const extraTopLevelField = cloneFixtureSourceSnapshot(); + extraTopLevelField.extra = true; + invalidSnapshots.push(extraTopLevelField); + + const missingTopLevelField = cloneFixtureSourceSnapshot(); + delete missingTopLevelField.totalBytes; + invalidSnapshots.push(missingTopLevelField); + + const extraEntryField = cloneFixtureSourceSnapshot(); + extraEntryField.entries[0].mode = 0o644; + invalidSnapshots.push(extraEntryField); + + const missingEntryField = cloneFixtureSourceSnapshot(); + delete missingEntryField.entries[0].executable; + invalidSnapshots.push(missingEntryField); + + const invalidFileSize = cloneFixtureSourceSnapshot(); + invalidFileSize.entries[0].size = -1; + invalidSnapshots.push(invalidFileSize); + + const invalidExecutable = cloneFixtureSourceSnapshot(); + invalidExecutable.entries[0].executable = 1; + invalidSnapshots.push(invalidExecutable); + + const invalidFileHash = cloneFixtureSourceSnapshot(); + invalidFileHash.entries[0].sha256 = 'A'.repeat(64); + invalidSnapshots.push(invalidFileHash); + + const invalidType = cloneFixtureSourceSnapshot(); + invalidType.entries[1].type = 'socket'; + invalidSnapshots.push(invalidType); + + for (const snapshot of invalidSnapshots) { + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(snapshot), + /invalid source snapshot/i + ); + } +}); + +test('rejects unsafe or unsorted paths, bad links, aggregates, and digests', async () => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + + const unsorted = cloneFixtureSourceSnapshot(); + [unsorted.entries[0], unsorted.entries[1]] = [ + unsorted.entries[1], + unsorted.entries[0], + ]; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(unsorted), + /sorted and unique/i + ); + + const duplicate = cloneFixtureSourceSnapshot(); + duplicate.entries[1].path = duplicate.entries[0].path; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(duplicate), + /sorted and unique/i + ); + + const unsafePath = cloneFixtureSourceSnapshot(); + unsafePath.entries[0].path = '../LICENSE'; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(unsafePath), + /unsafe source snapshot path/i + ); + + const unsafeLink = cloneFixtureSourceSnapshot(); + unsafeLink.entries[2].target = '../../outside'; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(unsafeLink), + /unsafe source snapshot symlink/i + ); + + const missingParentDirectory = cloneFixtureSourceSnapshot(); + missingParentDirectory.entries.splice(1, 1); + missingParentDirectory.entryCount -= 1; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(missingParentDirectory), + /parent.*directory/i + ); + + for (const [field, value] of [ + ['entryCount', FIXTURE_SOURCE_SNAPSHOT.entryCount + 1], + ['totalBytes', FIXTURE_SOURCE_SNAPSHOT.totalBytes + 1], + ['sha256', '0'.repeat(64)], + ]) { + const invalidAggregate = cloneFixtureSourceSnapshot(); + invalidAggregate[field] = value; + assert.throws( + () => helper.validateLinuxRuntimeSourceSnapshot(invalidAggregate), + /invalid source snapshot/i + ); + } + + assert.throws( + () => + helper.validateLinuxRuntimeSourceSnapshot(FIXTURE_SOURCE_SNAPSHOT, { + expectedSha256: '0'.repeat(64), + }), + /source snapshot digest mismatch/i + ); +}); + test('prepares identical VCS-free snapshots from equivalent clean checkouts', async (t) => { const helper = await loadHelper(); assert.ok(helper, 'the deterministic source snapshot helper must exist'); @@ -200,8 +397,26 @@ test('prepares identical VCS-free snapshots from equivalent clean checkouts', as expected, }); - assert.deepEqual(firstRecord, expected); - assert.deepEqual(secondRecord, expected); + assert.deepEqual(firstRecord, { + ...expected, + sourceSnapshot: FIXTURE_SOURCE_SNAPSHOT, + }); + assert.deepEqual(secondRecord, { + ...expected, + sourceSnapshot: FIXTURE_SOURCE_SNAPSHOT, + }); + assert.deepEqual( + helper.LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT, + Object.freeze({ + schemaVersion: 1, + hashAlgorithm: 'sha256', + canonicalEncoding: 'utf8-json-line-v1', + }) + ); + assert.deepEqual( + helper.inventoryLinuxRuntimeSourceSnapshot(firstOutput), + FIXTURE_SOURCE_SNAPSHOT + ); assert.deepEqual( snapshotRecords(firstOutput), snapshotRecords(secondOutput) @@ -218,6 +433,205 @@ test('prepares identical VCS-free snapshots from equivalent clean checkouts', as ); }); +test('normalizes regular-file executable permissions in the canonical inventory', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-modes-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const first = path.join(root, 'first'); + const second = path.join(root, 'second'); + fs.mkdirSync(path.join(first, 'bin'), { recursive: true, mode: 0o700 }); + fs.mkdirSync(path.join(second, 'bin'), { recursive: true, mode: 0o755 }); + fs.writeFileSync(path.join(first, 'bin', 'tool'), '#!/bin/sh\n', { + mode: 0o700, + }); + fs.writeFileSync(path.join(second, 'bin', 'tool'), '#!/bin/sh\n', { + mode: 0o755, + }); + fs.writeFileSync(path.join(first, 'README'), 'same\n', { mode: 0o600 }); + fs.writeFileSync(path.join(second, 'README'), 'same\n', { mode: 0o644 }); + + const firstInventory = helper.inventoryLinuxRuntimeSourceSnapshot(first); + const secondInventory = helper.inventoryLinuxRuntimeSourceSnapshot(second); + + assert.deepEqual(firstInventory, secondInventory); + assert.deepEqual( + firstInventory.entries.map((entry) => ({ + path: entry.path, + executable: entry.executable, + })), + [ + { path: 'README', executable: false }, + { path: 'bin', executable: undefined }, + { path: 'bin/tool', executable: true }, + ] + ); +}); + +test('globally sorts prefix-colliding sibling paths before hashing', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-global-order-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'c')); + fs.mkdirSync(path.join(root, 'c++')); + fs.writeFileSync(path.join(root, 'c', 'tool'), 'c\n'); + fs.writeFileSync(path.join(root, 'c++', 'tool'), 'c++\n'); + + const inventory = helper.inventoryLinuxRuntimeSourceSnapshot(root); + + assert.deepEqual( + inventory.entries.map(({ path: entryPath }) => entryPath), + ['c', 'c++', 'c++/tool', 'c/tool'] + ); + assert.doesNotThrow(() => + helper.validateLinuxRuntimeSourceSnapshot(inventory) + ); +}); + +test('fails closed on VCS entries, unsafe symlinks, and special files', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-unsafe-entry-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const vcsRoot = path.join(root, 'vcs'); + fs.mkdirSync(vcsRoot); + fs.writeFileSync(path.join(vcsRoot, '.git'), 'gitdir: elsewhere\n'); + assert.throws( + () => helper.inventoryLinuxRuntimeSourceSnapshot(vcsRoot), + /must not contain VCS metadata.*\.git/i + ); + + const escapingLinkRoot = path.join(root, 'escaping-link'); + fs.mkdirSync(path.join(escapingLinkRoot, 'nested'), { recursive: true }); + fs.symlinkSync( + '../../outside', + path.join(escapingLinkRoot, 'nested', 'link') + ); + assert.throws( + () => helper.inventoryLinuxRuntimeSourceSnapshot(escapingLinkRoot), + /unsafe source snapshot symlink.*nested\/link.*\.\.\/\.\.\/outside/i + ); + + const absoluteLinkRoot = path.join(root, 'absolute-link'); + fs.mkdirSync(absoluteLinkRoot); + fs.symlinkSync('/outside', path.join(absoluteLinkRoot, 'link')); + assert.throws( + () => helper.inventoryLinuxRuntimeSourceSnapshot(absoluteLinkRoot), + /unsafe source snapshot symlink.*\/outside/i + ); + + if (process.platform !== 'win32') { + const specialRoot = path.join(root, 'special'); + fs.mkdirSync(specialRoot); + const fifoPath = path.join(specialRoot, 'pipe'); + const mkfifo = spawnSync('mkfifo', [fifoPath], { encoding: 'utf8' }); + assert.equal(mkfifo.status, 0, mkfifo.stderr); + assert.throws( + () => helper.inventoryLinuxRuntimeSourceSnapshot(specialRoot), + /unsupported source snapshot entry.*pipe/i + ); + } +}); + +test('checks an optional expected digest atomically for direct callers', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-expected-digest-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first } = createEquivalentCheckouts(root); + const expected = { + sourceGitCommit: commit, + sourceSubmodules: [], + }; + const matchingOutput = path.join(root, 'matching'); + + assert.deepEqual( + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: matchingOutput, + expected, + expectedSourceSnapshotSha256: FIXTURE_SOURCE_SNAPSHOT.sha256, + }).sourceSnapshot, + FIXTURE_SOURCE_SNAPSHOT + ); + + const mismatchingOutput = path.join(root, 'mismatching'); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: mismatchingOutput, + expected, + expectedSourceSnapshotSha256: '0'.repeat(64), + }), + /source snapshot digest mismatch.*expected 000000.*received 445a78/i + ); + assert.equal(fs.existsSync(mismatchingOutput), false); +}); + +test('production CLI always enforces the trusted pinned libplacebo digest', async (t) => { + const helper = await loadHelper(); + assert.ok(helper, 'the deterministic source snapshot helper must exist'); + assert.match( + helper.EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + /^[a-f0-9]{64}$/ + ); + assert.equal( + helper.EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + '0db67c1523411255244186af437e9fbfe7ccac04a5ac1b3dc9275dd0806f6f0c' + ); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-cli-digest-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first } = createEquivalentCheckouts(root); + const runtimeManifestPath = path.join(root, 'runtime-manifest.json'); + fs.writeFileSync( + runtimeManifestPath, + JSON.stringify({ + packages: { + libplacebo: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }, + }) + ); + const outputPath = path.join(root, 'output'); + const recordOutputPath = path.join(root, 'record.json'); + const result = spawnSync( + process.execPath, + [ + helperPath, + 'prepare', + '--runtime-manifest', + runtimeManifestPath, + '--checkout', + first, + '--output', + outputPath, + '--record-output', + recordOutputPath, + ], + { encoding: 'utf8' } + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /source snapshot digest mismatch/i); + assert.equal(fs.existsSync(outputPath), false); + assert.equal(fs.existsSync(recordOutputPath), false); +}); + test('validates checkout cleanliness and exact commit/submodule identities before copying', async (t) => { const helper = await loadHelper(); assert.ok(helper, 'the deterministic source snapshot helper must exist'); @@ -319,14 +733,22 @@ test('preserves recursive submodule sources while stripping every nested .git li const { checkout, expected } = createCheckoutWithSubmodule(root); const output = path.join(root, 'submodule-snapshot'); + const record = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: checkout, + outputPath: output, + expected, + }); assert.deepEqual( - helper.prepareLinuxRuntimeSourceSnapshot({ - checkoutPath: checkout, - outputPath: output, - expected, - }), + { + sourceGitCommit: record.sourceGitCommit, + sourceSubmodules: record.sourceSubmodules, + }, expected ); + assert.deepEqual( + record.sourceSnapshot, + helper.inventoryLinuxRuntimeSourceSnapshot(output) + ); assert.equal( fs.readFileSync( path.join(output, '3rdparty', 'example', 'submodule-source.c'), diff --git a/tools/packaging/project.json b/tools/packaging/project.json index fb4b75ee2..1ac68583b 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -30,6 +30,8 @@ "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs", "{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs", "{workspaceRoot}/tools/packaging/release-snap-assets.cjs", + "{workspaceRoot}/tools/packaging/release-snap-assets.test.mjs", + "{workspaceRoot}/tools/packaging/release-snap-source-binding.cjs", "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.mjs", "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.test.mjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs", @@ -39,13 +41,15 @@ "{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs", "{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.cjs", "{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/stage-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } }, @@ -58,12 +62,16 @@ "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs", "{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs", "{workspaceRoot}/tools/packaging/release-snap-assets.cjs", + "{workspaceRoot}/tools/packaging/release-snap-assets.test.mjs", + "{workspaceRoot}/tools/packaging/release-snap-source-binding.cjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], - "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/build-linux-runtime.{mjs,test.mjs}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\" \"tools/embedded-mpv/runtime-probe-contract.cjs\"" + "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/build-linux-runtime.{mjs,test.mjs}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\" \"tools/embedded-mpv/linux-source-archive-contract.cjs\" \"tools/embedded-mpv/runtime-probe-contract.cjs\"" } }, "tags": ["scope:tools", "domain:packaging", "type:tool"] diff --git a/tools/packaging/publish-snap-workflow.test.mjs b/tools/packaging/publish-snap-workflow.test.mjs index cfac2535c..33085595f 100644 --- a/tools/packaging/publish-snap-workflow.test.mjs +++ b/tools/packaging/publish-snap-workflow.test.mjs @@ -156,6 +156,113 @@ test('publishes Snap only after a public v-tag release contains binary and sourc assertBuildSnapWorkflowPolicy(buildWorkflow); }); +test('isolates released verification from the fresh credentialed upload runner', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + const workflow = parse(workflowText); + assert.deepEqual(Object.keys(workflow.jobs), [ + 'verify-snap', + 'publish-snap', + ]); + + const verifyJob = workflow.jobs['verify-snap']; + const publishJob = workflow.jobs['publish-snap']; + assert.equal(publishJob.needs, 'verify-snap'); + assert.deepEqual(verifyJob.outputs, { + 'receipt-sha256': '${{ steps.bind-transfer.outputs.receipt-sha256 }}', + }); + assert.equal(JSON.stringify(verifyJob).includes('snapcraft_token'), false); + assert.deepEqual( + verifyJob.steps.filter((step) => step.uses).map((step) => step.uses), + [ + 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5', + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02', + ] + ); + assert.deepEqual( + publishJob.steps.filter((step) => step.uses).map((step) => step.uses), + ['actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'] + ); + + const bindingStep = verifyJob.steps.find( + (step) => step.name === 'Bind verified release transfer' + ); + assert.equal(bindingStep.id, 'bind-transfer'); + assert.match( + bindingStep.run, + /\/usr\/bin\/sha256sum --binary[\s\S]*receipt-sha256/ + ); + const transferredSealStep = publishJob.steps.find( + (step) => step.name === 'Seal transferred public release assets' + ); + assert.deepEqual(transferredSealStep.env, { + EXPECTED_RECEIPT_SHA256: + '${{ needs.verify-snap.outputs.receipt-sha256 }}', + }); + assert.match( + transferredSealStep.run, + /\/usr\/bin\/sha256sum --binary[\s\S]*EXPECTED_RECEIPT_SHA256/ + ); + assert.match( + transferredSealStep.run, + /\/usr\/bin\/jq --exit-status[\s\S]*\/usr\/bin\/sha256sum --strict --check/ + ); + assert.match( + transferredSealStep.run, + /\/usr\/bin\/jq --raw-output[\s\S]*@tsv[\s\S]*while IFS=\$'\\t' read -r ASSET_NAME EXPECTED_SIZE[\s\S]*\/usr\/bin\/stat --format=%s -- "\$\{ASSET_PATH\}"[\s\S]*test "\$\{ACTUAL_SIZE\}" = "\$\{EXPECTED_SIZE\}"/ + ); + + const uploadJobCommands = publishJob.steps + .map((step) => step.run ?? '') + .join('\n'); + assert.doesNotMatch(uploadJobCommands, /\bnode\b/); + assert.doesNotMatch(uploadJobCommands, /release-snap-assets\.cjs/); + const uploadStep = publishJob.steps.at(-1); + assert.deepEqual(uploadStep.env, { + SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}', + }); + assert.match(uploadStep.run, /shopt -s nullglob dotglob/); + assert.match( + uploadStep.run, + /SNAP_FILES=\("\$\{VERIFIED_ASSET_DIRECTORY\}"\/\*\.snap\)/ + ); + assert.match( + uploadStep.run, + /SNAPCRAFT_STORE_CREDENTIALS="\$\{STORE_CREDENTIALS\}" \/snap\/bin\/snapcraft upload --release=edge/ + ); + assert.doesNotMatch(uploadStep.run, /\bfind\b|\bsort\b|\bnode\b/); + assertPublishSnapWorkflowPolicy(workflowText); +}); + +test('rejects environment and execution-surface expansion on the fresh publish runner', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + for (const mutatedWorkflow of [ + workflowText.replace( + ' publish-snap:\n', + ' publish-snap:\n env:\n BASH_ENV: /tmp/release-hook\n' + ), + workflowText.replace( + ' runs-on: ubuntu-latest\n timeout-minutes: 20', + ' runs-on: ubuntu-latest\n container: ubuntu:latest\n timeout-minutes: 20' + ), + workflowText.replace( + 'permissions:\n contents: read', + 'env:\n BASH_ENV: /tmp/release-hook\n\npermissions:\n contents: read' + ), + workflowText.replaceAll( + 'runs-on: ubuntu-latest', + 'runs-on: self-hosted' + ), + workflowText.replace( + ' timeout-minutes: 20', + ' timeout-minutes: 120' + ), + ]) { + assert.notEqual(mutatedWorkflow, workflowText); + assert.doesNotThrow(() => parse(mutatedWorkflow)); + assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); + } +}); + test('rejects Snap uploads that target candidate or stable channels', () => { const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); for (const forbiddenReleaseArgument of [ @@ -178,7 +285,8 @@ test('rejects Snap uploads that target candidate or stable channels', () => { test('rejects edge upload text in non-executing shell contexts', () => { const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); - const edgeUpload = 'snapcraft upload --release=edge "${SNAP_FILE}"'; + const edgeUpload = + 'SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"'; const blockIndent = ' '.repeat(18); for (const replacement of [ `cat <<123\n${edgeUpload}\n123`, diff --git a/tools/packaging/release-snap-assets.cjs b/tools/packaging/release-snap-assets.cjs index 51b4d8f68..0670df809 100644 --- a/tools/packaging/release-snap-assets.cjs +++ b/tools/packaging/release-snap-assets.cjs @@ -2,11 +2,24 @@ 'use strict'; +const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { isDeepStrictEqual } = require('node:util'); +const { + inspectSnapPayload, + inspectSourceArchive, + verifySnapReleaseSourceBinding, +} = require('./release-snap-source-binding.cjs'); +const { + SOURCE_ARCHIVE_NAME, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); -const SOURCE_ARCHIVE_NAME = 'linux-frame-copy-runtime-sources.tar.xz'; +const VERIFIED_RELEASE_RECEIPT_NAME = 'verified-release-assets.json'; +const VERIFIED_RELEASE_RECEIPT_SCHEMA_VERSION = 1; +const VERIFIED_RELEASE_RECEIPT_MAX_BYTES = 1024 * 1024; +const FILE_COPY_BUFFER_BYTES = 1024 * 1024; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; function flattenAssetPages(value) { if (!Array.isArray(value)) { @@ -126,6 +139,458 @@ function verifySnapReleaseDownloads(selection, directoryPath) { return canonical; } +function regularFileRecord(filePath, asset) { + const descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ); + try { + const initialStat = fs.fstatSync(descriptor); + if (!initialStat.isFile() || initialStat.size === 0) { + throw new Error( + `Verified public release asset is not a non-empty regular file: ${asset.name}` + ); + } + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(FILE_COPY_BUFFER_BYTES); + let totalBytes = 0; + for (;;) { + const bytesRead = fs.readSync( + descriptor, + buffer, + 0, + buffer.length, + null + ); + if (bytesRead === 0) { + break; + } + hash.update(buffer.subarray(0, bytesRead)); + totalBytes += bytesRead; + } + const finalStat = fs.fstatSync(descriptor); + if ( + totalBytes !== initialStat.size || + finalStat.dev !== initialStat.dev || + finalStat.ino !== initialStat.ino || + finalStat.size !== initialStat.size || + finalStat.mtimeMs !== initialStat.mtimeMs || + finalStat.ctimeMs !== initialStat.ctimeMs + ) { + throw new Error( + `Verified public release asset changed while being hashed: ${asset.name}` + ); + } + return { + id: asset.id, + name: asset.name, + sha256: hash.digest('hex'), + size: totalBytes, + }; + } finally { + fs.closeSync(descriptor); + } +} + +function copyRegularFileSnapshot(sourcePath, destinationPath, asset) { + const noFollowFlag = fs.constants.O_NOFOLLOW ?? 0; + const sourceDescriptor = fs.openSync( + sourcePath, + fs.constants.O_RDONLY | noFollowFlag + ); + let destinationDescriptor; + try { + const sourceStat = fs.fstatSync(sourceDescriptor); + if (!sourceStat.isFile() || sourceStat.size === 0) { + throw new Error( + `Downloaded public release asset is not a non-empty regular file: ${asset.name}` + ); + } + destinationDescriptor = fs.openSync( + destinationPath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + noFollowFlag, + 0o444 + ); + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(FILE_COPY_BUFFER_BYTES); + let totalBytes = 0; + for (;;) { + const bytesRead = fs.readSync( + sourceDescriptor, + buffer, + 0, + buffer.length, + null + ); + if (bytesRead === 0) { + break; + } + hash.update(buffer.subarray(0, bytesRead)); + let written = 0; + while (written < bytesRead) { + written += fs.writeSync( + destinationDescriptor, + buffer, + written, + bytesRead - written + ); + } + totalBytes += bytesRead; + } + const finalSourceStat = fs.fstatSync(sourceDescriptor); + if ( + totalBytes !== sourceStat.size || + finalSourceStat.dev !== sourceStat.dev || + finalSourceStat.ino !== sourceStat.ino || + finalSourceStat.size !== sourceStat.size || + finalSourceStat.mtimeMs !== sourceStat.mtimeMs || + finalSourceStat.ctimeMs !== sourceStat.ctimeMs + ) { + throw new Error( + `Downloaded public release asset changed while being snapshotted: ${asset.name}` + ); + } + fs.fsyncSync(destinationDescriptor); + return { + id: asset.id, + name: asset.name, + sha256: hash.digest('hex'), + size: totalBytes, + }; + } finally { + if (destinationDescriptor !== undefined) { + fs.closeSync(destinationDescriptor); + } + fs.closeSync(sourceDescriptor); + } +} + +function snapshotSnapReleaseDownloads( + selection, + directoryPath, + verifiedDirectoryPath +) { + const canonical = verifySnapReleaseDownloads(selection, directoryPath); + const outputPath = path.resolve(verifiedDirectoryPath); + const sourcePath = path.resolve(directoryPath); + if ( + outputPath === sourcePath || + outputPath.startsWith(`${sourcePath}${path.sep}`) + ) { + throw new Error( + 'Verified public release asset directory must be separate from downloads.' + ); + } + const outputParent = path.dirname(outputPath); + const outputParentStat = fs.lstatSync(outputParent); + if (!outputParentStat.isDirectory() || outputParentStat.isSymbolicLink()) { + throw new Error( + 'Verified public release asset parent must be a real directory.' + ); + } + try { + fs.lstatSync(outputPath); + throw new Error( + 'Verified public release asset directory must not already exist.' + ); + } catch (error) { + if ( + !( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) + ) { + throw error; + } + } + const temporaryPath = fs.mkdtempSync( + path.join(outputParent, `.${path.basename(outputPath)}-`) + ); + const assets = [...canonical.snapAssets, canonical.sourceAsset]; + try { + const records = assets.map((asset) => + copyRegularFileSnapshot( + path.join(sourcePath, asset.name), + path.join(temporaryPath, asset.name), + asset + ) + ); + fs.renameSync(temporaryPath, outputPath); + return { canonical, records }; + } catch (error) { + fs.rmSync(temporaryPath, { recursive: true, force: true }); + throw error; + } +} + +function inspectStableReleaseFile(filePath, asset, inspector) { + const before = regularFileRecord(filePath, asset); + const inspection = inspector(); + const after = regularFileRecord(filePath, asset); + if (!isDeepStrictEqual(after, before)) { + throw new Error( + `Verified public release asset changed during inspection: ${asset.name}` + ); + } + return { inspection, record: after }; +} + +function writeVerifiedReleaseReceipt( + verifiedDirectoryPath, + expectedRepositoryRevision, + records +) { + if ( + typeof expectedRepositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(expectedRepositoryRevision) + ) { + throw new Error( + 'Verified release receipt requires a full repository revision.' + ); + } + const receipt = { + schemaVersion: VERIFIED_RELEASE_RECEIPT_SCHEMA_VERSION, + repositoryRevision: expectedRepositoryRevision, + assets: records, + }; + const receiptPath = path.join( + verifiedDirectoryPath, + VERIFIED_RELEASE_RECEIPT_NAME + ); + fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, { + flag: 'wx', + mode: 0o444, + }); + return receipt; +} + +function verifyVerifiedReleaseReceipt( + selection, + verifiedDirectoryPath, + receiptPath, + expectedRepositoryRevision +) { + const canonical = canonicalSelection(selection); + const resolvedDirectory = path.resolve(verifiedDirectoryPath); + const resolvedReceipt = path.resolve(receiptPath); + if ( + path.dirname(resolvedReceipt) !== resolvedDirectory || + path.basename(resolvedReceipt) !== VERIFIED_RELEASE_RECEIPT_NAME + ) { + throw new Error( + 'Verified release receipt must use its canonical asset-directory path.' + ); + } + const receiptStat = fs.lstatSync(resolvedReceipt); + if ( + !receiptStat.isFile() || + receiptStat.isSymbolicLink() || + receiptStat.size === 0 || + receiptStat.size > VERIFIED_RELEASE_RECEIPT_MAX_BYTES + ) { + throw new Error( + 'Verified release receipt must be a bounded regular file.' + ); + } + let receipt; + try { + receipt = JSON.parse(fs.readFileSync(resolvedReceipt, 'utf8')); + } catch { + throw new Error('Verified release receipt is not valid JSON.'); + } + if ( + receipt === null || + typeof receipt !== 'object' || + !isDeepStrictEqual(Object.keys(receipt).sort(), [ + 'assets', + 'repositoryRevision', + 'schemaVersion', + ]) || + receipt.schemaVersion !== VERIFIED_RELEASE_RECEIPT_SCHEMA_VERSION || + typeof receipt.repositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(receipt.repositoryRevision) || + receipt.repositoryRevision !== expectedRepositoryRevision || + !Array.isArray(receipt.assets) + ) { + throw new Error('Verified release receipt has an invalid contract.'); + } + const expectedAssets = [...canonical.snapAssets, canonical.sourceAsset]; + if ( + receipt.assets.length !== expectedAssets.length || + receipt.assets.some((record, index) => { + const asset = expectedAssets[index]; + return ( + record === null || + typeof record !== 'object' || + !isDeepStrictEqual(Object.keys(record).sort(), [ + 'id', + 'name', + 'sha256', + 'size', + ]) || + record.id !== asset.id || + record.name !== asset.name || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + typeof record.sha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(record.sha256) + ); + }) + ) { + throw new Error( + 'Verified release receipt does not match the selected assets.' + ); + } + const actualNames = fs.readdirSync(resolvedDirectory).sort(); + const expectedNames = [ + ...expectedAssets.map(({ name }) => name), + VERIFIED_RELEASE_RECEIPT_NAME, + ].sort(); + if (!isDeepStrictEqual(actualNames, expectedNames)) { + throw new Error( + 'Verified release directory does not match its exact receipt.' + ); + } + for (const record of receipt.assets) { + const actual = regularFileRecord( + path.join(resolvedDirectory, record.name), + record + ); + if (!isDeepStrictEqual(actual, record)) { + throw new Error( + `Verified release asset no longer matches its receipt: ${record.name}` + ); + } + } + return receipt; +} + +function verifySnapReleaseCorrespondence( + selection, + directoryPath, + { + expectedRepositoryRevision, + expectedSourceSnapshotSha256, + inspectSnapPayload: inspectSnap = inspectSnapPayload, + inspectSourceArchive: inspectSource = inspectSourceArchive, + validateRuntimeManifest, + verifiedDirectory, + verifiedReceiptPath, + } = {} +) { + if (verifiedDirectory && verifiedReceiptPath) { + throw new Error( + 'Release verification cannot create and consume a verified receipt at the same time.' + ); + } + let canonical; + let inspectionDirectory = directoryPath; + let snapshottedRecords = null; + let verifiedReceipt = null; + if (verifiedDirectory) { + const snapshot = snapshotSnapReleaseDownloads( + selection, + directoryPath, + verifiedDirectory + ); + canonical = snapshot.canonical; + snapshottedRecords = snapshot.records; + inspectionDirectory = path.resolve(verifiedDirectory); + } else if (verifiedReceiptPath) { + canonical = canonicalSelection(selection); + inspectionDirectory = path.resolve(directoryPath); + verifiedReceipt = verifyVerifiedReleaseReceipt( + canonical, + inspectionDirectory, + verifiedReceiptPath, + expectedRepositoryRevision + ); + } else { + canonical = verifySnapReleaseDownloads(selection, directoryPath); + } + try { + const sourceResult = inspectStableReleaseFile( + path.join(inspectionDirectory, canonical.sourceAsset.name), + canonical.sourceAsset, + () => + inspectSource( + path.join(inspectionDirectory, canonical.sourceAsset.name), + { expectedSourceSnapshotSha256 } + ) + ); + const snapResults = canonical.snapAssets.map((asset) => + inspectStableReleaseFile( + path.join(inspectionDirectory, asset.name), + asset, + () => + inspectSnap( + path.join(inspectionDirectory, asset.name), + asset + ) + ) + ); + verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision, + sourceInspection: sourceResult.inspection, + snapPayloads: snapResults.map(({ inspection }) => inspection), + }, + { + expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ); + const inspectedRecords = [ + ...snapResults.map(({ record }) => record), + sourceResult.record, + ]; + if (verifiedDirectory) { + if (!isDeepStrictEqual(inspectedRecords, snapshottedRecords)) { + throw new Error( + 'Verified release assets changed after their stable snapshot was created.' + ); + } + writeVerifiedReleaseReceipt( + inspectionDirectory, + expectedRepositoryRevision, + inspectedRecords + ); + } + if (verifiedReceipt) { + if (!isDeepStrictEqual(inspectedRecords, verifiedReceipt.assets)) { + throw new Error( + 'Verified release assets do not match the initially verified receipt after inspection.' + ); + } + const reverifiedReceipt = verifyVerifiedReleaseReceipt( + canonical, + inspectionDirectory, + verifiedReceiptPath, + expectedRepositoryRevision + ); + if (!isDeepStrictEqual(reverifiedReceipt, verifiedReceipt)) { + throw new Error( + 'Verified release receipt changed during full inspection.' + ); + } + } + return canonical; + } catch (error) { + if (verifiedDirectory) { + fs.rmSync(path.resolve(verifiedDirectory), { + recursive: true, + force: true, + }); + } + throw error; + } +} + function parseArguments(argv) { const [command, ...tokens] = argv; const options = {}; @@ -161,15 +626,57 @@ function main(argv = process.argv.slice(2)) { ); return; } - if (command === 'verify' && options.manifest && options.directory) { - verifySnapReleaseDownloads( + if ( + command === 'verify' && + options.manifest && + options.directory && + options['repository-revision'] && + options['verified-directory'] + ) { + verifySnapReleaseCorrespondence( readJson(options.manifest), - options.directory + options.directory, + { + expectedRepositoryRevision: options['repository-revision'], + verifiedDirectory: options['verified-directory'], + } + ); + return; + } + if ( + command === 'verify-receipt' && + options.manifest && + options.directory && + options.receipt && + options['repository-revision'] + ) { + verifyVerifiedReleaseReceipt( + readJson(options.manifest), + options.directory, + options.receipt, + options['repository-revision'] + ); + return; + } + if ( + command === 'verify-sealed' && + options.manifest && + options.directory && + options.receipt && + options['repository-revision'] + ) { + verifySnapReleaseCorrespondence( + readJson(options.manifest), + options.directory, + { + expectedRepositoryRevision: options['repository-revision'], + verifiedReceiptPath: options.receipt, + } ); return; } throw new Error( - 'Usage: release-snap-assets.cjs select --assets-json --output-json | verify --manifest --directory ' + `Usage: release-snap-assets.cjs select --assets-json --output-json | verify --manifest --directory --repository-revision --verified-directory | verify-receipt --manifest --directory --receipt --repository-revision | verify-sealed --manifest --directory --receipt --repository-revision ` ); } @@ -184,6 +691,11 @@ if (require.main === module) { module.exports = { SOURCE_ARCHIVE_NAME, + VERIFIED_RELEASE_RECEIPT_NAME, selectSnapReleaseAssets, + snapshotSnapReleaseDownloads, + verifyVerifiedReleaseReceipt, + verifySnapReleaseCorrespondence, verifySnapReleaseDownloads, + verifySnapReleaseSourceBinding, }; diff --git a/tools/packaging/release-snap-assets.test.mjs b/tools/packaging/release-snap-assets.test.mjs new file mode 100644 index 000000000..830c4ce96 --- /dev/null +++ b/tools/packaging/release-snap-assets.test.mjs @@ -0,0 +1,1442 @@ +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; + +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); +const helperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-assets.cjs' +); +const sourceBindingHelperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-source-binding.cjs' +); +const sourceArchiveContractPath = path.join( + workspaceRoot, + 'tools', + 'embedded-mpv', + 'linux-source-archive-contract.cjs' +); +const publishWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'publish-snap.yaml' +); + +async function loadHelper() { + return import(pathToFileURL(helperPath).href); +} + +async function loadSourceBindingHelper() { + return import(pathToFileURL(sourceBindingHelperPath).href); +} + +async function loadSourceArchiveContract() { + return import(pathToFileURL(sourceArchiveContractPath).href); +} + +function syntheticSquashfsListing() { + return [ + 'drwxr-xr-x 0/0 0 2026-07-18 00:00 squashfs-root', + '-rw-r--r-- 0/0 1 2026-07-18 00:00 squashfs-root/payload', + '', + ].join('\n'); +} + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +const SYNTHETIC_LIBPLACEBO_CONTENTS = Buffer.from( + 'libplacebo source snapshot\n' +); +const SYNTHETIC_LIBPLACEBO_ENTRIES = Object.freeze([ + Object.freeze({ + path: 'README.md', + type: 'file', + size: SYNTHETIC_LIBPLACEBO_CONTENTS.length, + executable: false, + sha256: sha256(SYNTHETIC_LIBPLACEBO_CONTENTS), + }), +]); +const SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT = (() => { + const canonical = { + schemaVersion: 1, + entryCount: SYNTHETIC_LIBPLACEBO_ENTRIES.length, + totalBytes: SYNTHETIC_LIBPLACEBO_CONTENTS.length, + entries: SYNTHETIC_LIBPLACEBO_ENTRIES, + }; + return Object.freeze({ + schemaVersion: canonical.schemaVersion, + sha256: sha256(`${JSON.stringify(canonical)}\n`), + entryCount: canonical.entryCount, + totalBytes: canonical.totalBytes, + entries: SYNTHETIC_LIBPLACEBO_ENTRIES, + }); +})(); +const EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES = Object.freeze([ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers (v1.4.337)', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float (v6.1.0-275-g97b54ca)', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad (v0.1.11a-302-g73db193)', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja (3.1.6)', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe (3.0.3)', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear (4.12.8)', +]); + +function sourcePackageIdentity(sourcePackage) { + return Object.fromEntries( + [ + 'version', + 'sourceUrl', + 'sourceTag', + 'sourceSha256', + 'sourceGitCommit', + 'license', + ] + .filter((field) => Object.hasOwn(sourcePackage, field)) + .map((field) => [field, sourcePackage[field]]) + ); +} + +function createComplianceInspection(sourceRuntime) { + const licenseInputFiles = []; + const noticeLicenseFiles = []; + const licenseInputPackages = []; + const noticePackages = []; + for (const [id, sourcePackage] of Object.entries(sourceRuntime.packages)) { + const contents = Buffer.from(`${id} license\n`); + const file = { + path: `licenses/${id}/LICENSE`, + size: contents.length, + sha256: sha256(contents), + }; + licenseInputFiles.push(file); + noticeLicenseFiles.push(file); + licenseInputPackages.push({ + id, + ...sourcePackageIdentity(sourcePackage), + files: [ + { + sourcePath: 'LICENSE', + ...file, + }, + ], + }); + noticePackages.push({ + id, + ...sourcePackageIdentity(sourcePackage), + files: [file], + }); + } + const aggregateNoticeContents = Buffer.from('third-party notices\n'); + const noticeFile = { + path: 'THIRD_PARTY_NOTICES.txt', + size: aggregateNoticeContents.length, + sha256: sha256(aggregateNoticeContents), + }; + return { + aggregateNoticeContents, + libplaceboSourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + licenseInputFiles, + licenseInputs: { + schemaVersion: 1, + origin: 'pinned-linux-runtime-license-inputs', + platform: 'linux', + arch: 'x64', + packages: licenseInputPackages, + }, + noticeFile, + noticeLicenseFiles, + notices: { + schemaVersion: 1, + origin: 'pinned-linux-runtime-upstream-licenses', + platform: 'linux', + arch: 'x64', + noticeFile, + packages: noticePackages, + totalBytes: + noticeFile.size + + noticeLicenseFiles.reduce( + (total, file) => total + file.size, + 0 + ), + }, + toolingValidated: true, + }; +} + +function createSourceBindingFixture() { + const repositoryRevision = 'a'.repeat(40); + const archiveSha256 = 'b'.repeat(64); + const sourceRuntime = { + generatedAt: '2026-07-18T00:00:00.000Z', + packages: { + ffmpeg: { + version: '1.0.0', + sourceUrl: 'https://example.test/ffmpeg.tar.xz', + sourceSha256: archiveSha256, + license: 'LGPL-2.1-or-later', + }, + libplacebo: { + version: '2.0.0', + sourceUrl: 'https://example.test/libplacebo.git', + sourceTag: 'v2.0.0', + sourceGitCommit: 'c'.repeat(40), + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], + license: 'LGPL-2.1-or-later', + }, + }, + }; + const compliance = createComplianceInspection(sourceRuntime); + const sourceCompliance = { ...compliance }; + delete sourceCompliance.aggregateNoticeContents; + const sourceIndex = { + schemaVersion: 3, + repositoryRevision, + sourcePackages: sourceRuntime.packages, + archives: [ + { + name: 'ffmpeg.tar.xz', + sha256: archiveSha256, + }, + ], + libplacebo: { + sourceGitCommit: sourceRuntime.packages.libplacebo.sourceGitCommit, + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], + sourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + }, + legal: { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: compliance.notices.noticeFile, + packages: compliance.notices.packages, + }, + }; + const sourceInspection = { + archiveSha256: 'd'.repeat(64), + sourceRuntime, + sourceIndex, + repositoryRevision, + localChanges: Buffer.alloc(0), + archiveFiles: sourceIndex.archives, + compliance: sourceCompliance, + }; + const snapPayloads = { + 'IPTVnator-amd64.snap': { + assetName: 'IPTVnator-amd64.snap', + architecture: 'x64', + markerOnly: false, + manifest: { + platform: 'linux', + arch: 'x64', + profile: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + sourceArchive: { + schemaVersion: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + sha256: 'd'.repeat(64), + repositoryRevision, + }, + sourceRuntime, + }, + }, + 'IPTVnator-arm64.snap': { + assetName: 'IPTVnator-arm64.snap', + architecture: 'arm64', + markerOnly: true, + manifest: null, + }, + }; + return { + expectedSourceSnapshotSha256: + SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT.sha256, + repositoryRevision, + sourceInspection, + snapPayloads, + }; +} + +test('binds source metadata and checksums to every selected Snap before publication', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-binding-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const selection = { + snapAssets: [ + { id: 1, name: 'IPTVnator-amd64.snap' }, + { id: 2, name: 'IPTVnator-arm64.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(temporaryRoot, name), name); + } + const inspectedSnaps = []; + + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, temporaryRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => fixture.sourceInspection, + inspectSnapPayload: (_snapPath, asset) => { + inspectedSnaps.push(asset.name); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + }), + selection + ); + assert.deepEqual( + inspectedSnaps, + selection.snapAssets.map(({ name }) => name) + ); +}); + +test('publishes only a stable verified asset snapshot with an exact receipt', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-snapshot-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + fs.mkdirSync(downloadRoot); + const selection = { + snapAssets: [ + { id: 1, name: 'IPTVnator-amd64.snap' }, + { id: 2, name: 'IPTVnator-arm64.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + const inspectedPaths = []; + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + inspectedPaths.push(sourcePath); + return fixture.sourceInspection; + }, + inspectSnapPayload: (snapPath, asset) => { + inspectedPaths.push(snapPath); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }), + selection + ); + assert.ok( + inspectedPaths.every((filePath) => + filePath.startsWith(`${verifiedRoot}${path.sep}`) + ) + ); + const receiptPath = path.join( + verifiedRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + const receipt = helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + fixture.repositoryRevision + ); + assert.equal(receipt.repositoryRevision, fixture.repositoryRevision); + assert.deepEqual( + receipt.assets.map(({ name }) => name), + [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ] + ); + const sealedInspectionPaths = []; + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, verifiedRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + sealedInspectionPaths.push(sourcePath); + return fixture.sourceInspection; + }, + inspectSnapPayload: (snapPath, asset) => { + sealedInspectionPaths.push(snapPath); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + verifiedReceiptPath: receiptPath, + }), + selection + ); + assert.ok( + sealedInspectionPaths.every((filePath) => + filePath.startsWith(`${verifiedRoot}${path.sep}`) + ) + ); + assert.throws( + () => + helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + 'f'.repeat(40) + ), + /invalid contract/i + ); + + const changedSnapPath = path.join( + verifiedRoot, + selection.snapAssets[0].name + ); + fs.chmodSync(changedSnapPath, 0o644); + fs.appendFileSync(changedSnapPath, 'changed'); + assert.throws( + () => + helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + fixture.repositoryRevision + ), + /no longer matches its receipt/i + ); +}); + +test('binds sealed inspection to the initially verified receipt across a mutually consistent replacement', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-replacement-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + const replacementRoot = path.join(temporaryRoot, 'replacement'); + const originalRoot = path.join(temporaryRoot, 'original'); + fs.mkdirSync(downloadRoot); + fs.mkdirSync(replacementRoot); + const selection = { + snapAssets: [{ id: 1, name: 'IPTVnator-amd64.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + selection.snapAssets[0].name, + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => fixture.sourceInspection, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }); + + const replacementSnapContents = Buffer.from('replacement Snap payload'); + const sourceContents = fs.readFileSync( + path.join(verifiedRoot, selection.sourceAsset.name) + ); + fs.writeFileSync( + path.join(replacementRoot, selection.snapAssets[0].name), + replacementSnapContents + ); + fs.writeFileSync( + path.join(replacementRoot, selection.sourceAsset.name), + sourceContents + ); + const replacementRecords = [ + { + ...selection.snapAssets[0], + sha256: sha256(replacementSnapContents), + size: replacementSnapContents.length, + }, + { + ...selection.sourceAsset, + sha256: sha256(sourceContents), + size: sourceContents.length, + }, + ]; + const replacementReceiptPath = path.join( + replacementRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + fs.writeFileSync( + replacementReceiptPath, + `${JSON.stringify( + { + schemaVersion: 1, + repositoryRevision: fixture.repositoryRevision, + assets: replacementRecords, + }, + null, + 2 + )}\n` + ); + assert.deepEqual( + helper.verifyVerifiedReleaseReceipt( + selection, + replacementRoot, + replacementReceiptPath, + fixture.repositoryRevision + ).assets, + replacementRecords + ); + const receiptPath = path.join( + verifiedRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + let replaced = false; + + assert.throws( + () => + helper.verifySnapReleaseCorrespondence(selection, verifiedRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => { + fs.renameSync(verifiedRoot, originalRoot); + fs.renameSync(replacementRoot, verifiedRoot); + replaced = true; + return fixture.sourceInspection; + }, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedReceiptPath: receiptPath, + }), + /initially verified receipt/i + ); + assert.equal(replaced, true); +}); + +test('removes a verified snapshot when an asset changes during inspection', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-race-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + fs.mkdirSync(downloadRoot); + const selection = { + snapAssets: [{ id: 1, name: 'IPTVnator-amd64.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + selection.snapAssets[0].name, + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + + assert.throws( + () => + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + fs.chmodSync(sourcePath, 0o644); + fs.appendFileSync(sourcePath, 'changed'); + return fixture.sourceInspection; + }, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }), + /changed during inspection/i + ); + assert.equal(fs.existsSync(verifiedRoot), false); +}); + +test('fails closed for stale source identity, archive bytes, and x64 marker-only payloads', async () => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const validateRuntimeManifest = () => []; + + const staleRevision = structuredClone(fixture.sourceInspection); + staleRevision.sourceIndex.repositoryRevision = 'e'.repeat(40); + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: staleRevision, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /repository revision/i + ); + + const forgedSubmodules = structuredClone(fixture); + const forgedSubmoduleRecord = `${'f'.repeat(40)} 3rdparty/Vulkan-Headers (v1.4.337)`; + forgedSubmodules.sourceInspection.sourceRuntime.packages.libplacebo.sourceSubmodules[0] = + forgedSubmoduleRecord; + forgedSubmodules.sourceInspection.sourceIndex.libplacebo.sourceSubmodules[0] = + forgedSubmoduleRecord; + forgedSubmodules.sourceInspection.sourceIndex.sourcePackages = + forgedSubmodules.sourceInspection.sourceRuntime.packages; + forgedSubmodules.snapPayloads[ + 'IPTVnator-amd64.snap' + ].manifest.sourceRuntime = forgedSubmodules.sourceInspection.sourceRuntime; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: forgedSubmodules.sourceInspection, + snapPayloads: Object.values(forgedSubmodules.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /submodule/i + ); + + const tamperedArchive = structuredClone(fixture.sourceInspection); + tamperedArchive.archiveFiles[0].sha256 = 'f'.repeat(64); + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: tamperedArchive, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /archive.*checksum/i + ); + + const staleSnapPayloads = structuredClone(fixture.snapPayloads); + staleSnapPayloads[ + 'IPTVnator-amd64.snap' + ].manifest.sourceRuntime.generatedAt = '2025-01-01T00:00:00.000Z'; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: Object.values(staleSnapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /Snap source runtime.*source archive/i + ); + + const markerOnlyX64 = structuredClone(fixture.snapPayloads); + markerOnlyX64['IPTVnator-amd64.snap'] = { + assetName: 'IPTVnator-amd64.snap', + architecture: 'x64', + markerOnly: true, + manifest: null, + }; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: Object.values(markerOnlyX64), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /x64 Snap.*frame-copy/i + ); + + const incompleteCompliance = structuredClone(fixture.sourceInspection); + incompleteCompliance.compliance.toolingValidated = false; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: incompleteCompliance, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /source archive compliance/i + ); + + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: [ + fixture.snapPayloads['IPTVnator-arm64.snap'], + ], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /exactly one x64 Snap/i + ); + + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: [ + fixture.snapPayloads['IPTVnator-amd64.snap'], + { + ...fixture.snapPayloads['IPTVnator-amd64.snap'], + assetName: 'IPTVnator-second-amd64.snap', + }, + ], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /exactly one x64 Snap/i + ); +}); + +test('hashes the final source archive bytes and reads the exact packaged Snap binding', async (t) => { + const sourceBindingHelper = await loadSourceBindingHelper(); + const sourceArchiveContract = await loadSourceArchiveContract(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-inspection-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + + const sourceRoot = path.join(temporaryRoot, 'source'); + const metadataRoot = path.join(sourceRoot, 'metadata'); + const archivesRoot = path.join(sourceRoot, 'archives'); + fs.mkdirSync(metadataRoot, { recursive: true }); + fs.mkdirSync(archivesRoot, { recursive: true }); + const pinnedSourceContents = Buffer.from('pinned ffmpeg source'); + const pinnedSourceSha256 = crypto + .createHash('sha256') + .update(pinnedSourceContents) + .digest('hex'); + const sourceRuntime = structuredClone( + fixture.sourceInspection.sourceRuntime + ); + sourceRuntime.packages.ffmpeg.sourceSha256 = pinnedSourceSha256; + const sourceIndex = structuredClone(fixture.sourceInspection.sourceIndex); + sourceIndex.sourcePackages = sourceRuntime.packages; + sourceIndex.archives = [ + { + name: 'ffmpeg.tar.xz', + sha256: pinnedSourceSha256, + }, + ]; + sourceIndex.libplacebo = { + sourceGitCommit: sourceRuntime.packages.libplacebo.sourceGitCommit, + sourceSubmodules: [ + ...sourceRuntime.packages.libplacebo.sourceSubmodules, + ], + sourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + }; + const compliance = createComplianceInspection(sourceRuntime); + sourceIndex.legal = { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: compliance.notices.noticeFile, + packages: compliance.notices.packages, + }; + fs.writeFileSync( + path.join(archivesRoot, 'ffmpeg.tar.xz'), + pinnedSourceContents + ); + const licenseInputRoot = path.join(sourceRoot, 'license-inputs'); + const noticesRoot = path.join(sourceRoot, 'notices'); + for (const [root, files] of [ + [licenseInputRoot, compliance.licenseInputFiles], + [noticesRoot, compliance.noticeLicenseFiles], + ]) { + for (const file of files) { + const filePath = path.join(root, ...file.path.split('/')); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${file.path.split('/')[1]} license\n`); + } + } + fs.writeFileSync( + path.join(licenseInputRoot, 'linux-runtime-license-inputs.json'), + `${JSON.stringify(compliance.licenseInputs)}\n` + ); + fs.writeFileSync( + path.join(noticesRoot, 'embedded-mpv-notices.json'), + `${JSON.stringify(compliance.notices)}\n` + ); + fs.writeFileSync( + path.join(noticesRoot, 'THIRD_PARTY_NOTICES.txt'), + compliance.aggregateNoticeContents + ); + const libplaceboRoot = path.join(sourceRoot, 'git', 'libplacebo'); + fs.mkdirSync(libplaceboRoot, { recursive: true }); + fs.writeFileSync( + path.join(libplaceboRoot, 'README.md'), + 'libplacebo source snapshot\n' + ); + const toolingFiles = [ + ['embedded-mpv', 'build-linux-runtime.cjs'], + ['embedded-mpv', 'build-linux-runtime.mjs'], + ['embedded-mpv', 'generate-linux-runtime-notices.cjs'], + ['embedded-mpv', 'linux-runtime-manifest.cjs'], + ['embedded-mpv', 'linux-source-archive-contract.cjs'], + ['embedded-mpv', 'stage-runtime.mjs'], + ['packaging', 'prepare-linux-runtime-source-snapshot.cjs'], + ]; + const toolingRoot = path.join(sourceRoot, 'tooling'); + fs.mkdirSync(toolingRoot, { recursive: true }); + for (const [directoryName, fileName] of toolingFiles) { + fs.copyFileSync( + path.join(workspaceRoot, 'tools', directoryName, fileName), + path.join(toolingRoot, fileName) + ); + } + fs.writeFileSync( + path.join(metadataRoot, 'runtime-manifest.json'), + `${JSON.stringify(sourceRuntime)}\n` + ); + fs.writeFileSync( + path.join(metadataRoot, 'source-index.json'), + `${JSON.stringify(sourceIndex)}\n` + ); + fs.writeFileSync( + path.join(metadataRoot, 'iptvnator-git-revision.txt'), + `${fixture.repositoryRevision}\n` + ); + fs.writeFileSync(path.join(metadataRoot, 'local-changes.patch'), ''); + const archiveChecksumsPath = path.join(metadataRoot, 'archive-sha256.txt'); + fs.writeFileSync( + archiveChecksumsPath, + `${pinnedSourceSha256} ffmpeg.tar.xz\n` + ); + + const sourceArchivePath = path.join( + temporaryRoot, + 'linux-frame-copy-runtime-sources.tar.xz' + ); + const tarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + sourceArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(tarResult.error, undefined); + assert.equal(tarResult.status, 0, tarResult.stderr); + + const sourceArchive = sourceArchiveContract.createLinuxSourceArchiveBinding( + { + archivePath: sourceArchivePath, + repositoryRevision: fixture.repositoryRevision, + } + ); + assert.throws( + () => sourceBindingHelper.inspectSourceArchive(sourceArchivePath), + /snapshot digest mismatch/i + ); + const sourceInspection = sourceBindingHelper.inspectSourceArchive( + sourceArchivePath, + { + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + } + ); + assert.equal(sourceInspection.archiveSha256, sourceArchive.sha256); + assert.deepEqual(sourceInspection.archiveFiles, sourceIndex.archives); + + const hiddenSourceRoot = path.join(temporaryRoot, 'hidden-source'); + fs.mkdirSync(hiddenSourceRoot); + fs.writeFileSync( + path.join(hiddenSourceRoot, 'undeclared-hidden-source.txt'), + 'not part of the canonical source bundle' + ); + const hiddenSourceArchivePath = path.join( + temporaryRoot, + 'hidden-source.tar.xz' + ); + const hiddenSourceTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + hiddenSourceArchivePath, + '--directory', + hiddenSourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(hiddenSourceTarResult.error, undefined); + assert.equal(hiddenSourceTarResult.status, 0, hiddenSourceTarResult.stderr); + const concatenatedSourceArchivePath = path.join( + temporaryRoot, + 'concatenated-source.tar.xz' + ); + fs.writeFileSync( + concatenatedSourceArchivePath, + Buffer.concat([ + fs.readFileSync(sourceArchivePath), + fs.readFileSync(hiddenSourceArchivePath), + ]) + ); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive( + concatenatedSourceArchivePath, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + } + ), + /undeclared source archive member/i + ); + + fs.writeFileSync( + archiveChecksumsPath, + `${'0'.repeat(64)} ffmpeg.tar.xz\n` + ); + const badChecksumArchivePath = path.join( + temporaryRoot, + 'bad-checksum-source.tar.xz' + ); + const badChecksumTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + badChecksumArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(badChecksumTarResult.error, undefined); + assert.equal(badChecksumTarResult.status, 0, badChecksumTarResult.stderr); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive(badChecksumArchivePath, { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + }), + /archive checksum/i + ); + fs.writeFileSync( + archiveChecksumsPath, + `${pinnedSourceSha256} ffmpeg.tar.xz\n` + ); + + const extraSourcePath = path.join(sourceRoot, 'undeclared-source.txt'); + fs.writeFileSync( + extraSourcePath, + 'not part of the canonical source bundle' + ); + const extraMemberArchivePath = path.join( + temporaryRoot, + 'extra-member-source.tar.xz' + ); + const extraMemberTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + extraMemberArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(extraMemberTarResult.error, undefined); + assert.equal(extraMemberTarResult.status, 0, extraMemberTarResult.stderr); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive(extraMemberArchivePath, { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + }), + /undeclared source archive member/i + ); + fs.unlinkSync(extraSourcePath); + + const oversizedSourceArchive = path.join( + temporaryRoot, + 'oversized-source.tar.xz' + ); + fs.writeFileSync(oversizedSourceArchive, ''); + fs.truncateSync(oversizedSourceArchive, 1024 * 1024 * 1024 + 1); + assert.throws( + () => sourceBindingHelper.inspectSourceArchive(oversizedSourceArchive), + /bounded non-empty regular file/i + ); + + const localChangesPath = path.join(metadataRoot, 'local-changes.patch'); + const emptyTargetPath = path.join(sourceRoot, 'empty-target'); + fs.writeFileSync(emptyTargetPath, ''); + fs.unlinkSync(localChangesPath); + fs.symlinkSync('../empty-target', localChangesPath); + const symlinkArchivePath = path.join( + temporaryRoot, + 'symlinked-source.tar.xz' + ); + const symlinkTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + symlinkArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(symlinkTarResult.error, undefined); + assert.equal(symlinkTarResult.status, 0, symlinkTarResult.stderr); + assert.throws( + () => sourceBindingHelper.inspectSourceArchive(symlinkArchivePath), + /required member.*regular file/i + ); + fs.unlinkSync(localChangesPath); + fs.writeFileSync(localChangesPath, ''); + + const oversizedMemberPath = path.join( + libplaceboRoot, + 'oversized-source-member.bin' + ); + fs.writeFileSync(oversizedMemberPath, ''); + fs.truncateSync(oversizedMemberPath, 128 * 1024 * 1024 + 1); + const oversizedMemberArchivePath = path.join( + temporaryRoot, + 'oversized-member-source.tar.xz' + ); + const oversizedMemberTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + oversizedMemberArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(oversizedMemberTarResult.error, undefined); + assert.equal( + oversizedMemberTarResult.status, + 0, + oversizedMemberTarResult.stderr + ); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive( + oversizedMemberArchivePath, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + } + ), + /member exceeds.*size limit/i + ); + fs.unlinkSync(oversizedMemberPath); + + const snapSourceRoot = path.join(temporaryRoot, 'snap-source'); + const appRoot = path.join(snapSourceRoot, 'usr', 'lib', 'iptvnator'); + const nativeRoot = path.join( + appRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeRoot, { recursive: true }); + const electronHeader = Buffer.alloc(20); + electronHeader.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + electronHeader.writeUInt16LE(62, 18); + fs.writeFileSync(path.join(appRoot, 'iptvnator.bin'), electronHeader); + const packagedManifest = { + ...fixture.snapPayloads['IPTVnator-amd64.snap'].manifest, + sourceArchive, + sourceRuntime, + }; + fs.writeFileSync( + path.join(nativeRoot, 'embedded-mpv-runtime.json'), + `${JSON.stringify(packagedManifest)}\n` + ); + const snapPath = path.join(temporaryRoot, 'IPTVnator-amd64.snap'); + fs.writeFileSync(snapPath, 'synthetic Snap bytes'); + const staticValidationCalls = []; + const snapPayload = sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + assert.ok(destinationIndex > 0); + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: (resourceDirectory, options) => { + staticValidationCalls.push({ options, resourceDirectory }); + return []; + }, + } + ); + assert.deepEqual(snapPayload, { + architecture: 'x64', + assetName: 'IPTVnator-amd64.snap', + manifest: packagedManifest, + markerOnly: false, + }); + assert.equal(staticValidationCalls.length, 1); + assert.ok( + staticValidationCalls[0].resourceDirectory.endsWith( + ['payload', 'usr', 'lib', 'iptvnator', 'resources'].join(path.sep) + ) + ); + assert.deepEqual(staticValidationCalls[0].options, { + artifactFormat: 'snap', + executableName: 'iptvnator', + foreignArch: false, + hostPlatform: 'linux', + platform: 'linux', + profile: 'portable', + required: true, + targetArch: 'x64', + targetNames: ['appimage', 'snap'], + }); + assert.equal( + sourceBindingHelper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection, + snapPayloads: [snapPayload], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest: () => [], + } + ), + true + ); + + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => [ + 'Hidden inherited frame-copy artifact.', + ], + } + ), + /Hidden inherited frame-copy artifact/ + ); + + const decoySourceRoot = path.join(temporaryRoot, 'decoy-snap-source'); + const decoyAppRoot = path.join(decoySourceRoot, 'decoy'); + fs.cpSync(appRoot, decoyAppRoot, { recursive: true }); + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(decoySourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => { + throw new Error( + 'Static validation must not inspect a decoy root.' + ); + }, + } + ), + /canonical frame-copy manifest/i + ); + + const manifestPath = path.join(nativeRoot, 'embedded-mpv-runtime.json'); + fs.truncateSync(manifestPath, 16 * 1024 * 1024 + 1); + let oversizedStaticValidationCalls = 0; + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => { + oversizedStaticValidationCalls += 1; + return []; + }, + } + ), + /invalid frame-copy manifest/i + ); + assert.equal(oversizedStaticValidationCalls, 0); +}); + +test('publish workflow installs the source verifier and binds the release tag revision', () => { + const workflow = fs.readFileSync(publishWorkflowPath, 'utf8'); + const parsedWorkflow = parse(workflow); + const verifyJob = parsedWorkflow.jobs['verify-snap']; + const publishJob = parsedWorkflow.jobs['publish-snap']; + const uploadStep = publishJob.steps.find( + (step) => step.name === 'Publish all public-release snaps to edge' + ); + const checkoutStep = verifyJob.steps.find( + (step) => step.name === 'Checkout released tooling' + ); + const snapcraftStep = publishJob.steps.find( + (step) => step.name === 'Install Snapcraft' + ); + const selectStep = verifyJob.steps.find( + (step) => step.name === 'Select exact public release assets' + ); + const downloadStep = verifyJob.steps.find( + (step) => step.name === 'Download exact public release assets' + ); + const verifyStep = verifyJob.steps.find( + (step) => step.name === 'Verify downloaded public release assets' + ); + const sealedVerifyStep = verifyJob.steps.find( + (step) => step.name === 'Reverify sealed public release assets' + ); + const transferStep = verifyJob.steps.find( + (step) => step.name === 'Transfer verified release assets' + ); + const artifactDownloadStep = publishJob.steps.find( + (step) => step.name === 'Download verified release assets' + ); + const transferredSealStep = publishJob.steps.find( + (step) => step.name === 'Seal transferred public release assets' + ); + assert.equal(verifyJob['timeout-minutes'], 45); + assert.equal(publishJob['timeout-minutes'], 20); + assert.equal(publishJob.needs, 'verify-snap'); + assert.match( + workflow, + /apt-get install[\s\S]*binutils[\s\S]*squashfs-tools[\s\S]*xz-utils/ + ); + assert.match( + workflow, + /release-snap-assets\.cjs verify[\s\S]*--repository-revision/ + ); + assert.ok( + workflow.indexOf('--repository-revision') < + workflow.indexOf('snapcraft upload --release=edge') + ); + assert.equal(JSON.stringify(verifyJob).includes('snapcraft_token'), false); + assert.equal( + checkoutStep.with?.['persist-credentials'], + false, + 'release checkout must not persist github.token for later steps' + ); + assert.equal( + checkoutStep.uses, + 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' + ); + assert.equal(snapcraftStep.uses, undefined); + assert.match( + snapcraftStep.run, + /sudo snap install snapcraft --classic --channel=stable/ + ); + assert.match(verifyStep.run, /--verified-directory/); + assert.match(verifyStep.run, /chown -R root:root/); + assert.match(verifyStep.run, /chmod 0555/); + assert.match(verifyStep.run, /chmod 0444/); + assert.match( + verifyStep.run, + /SEALED_ASSET_PARENT="\/var\/lib\/iptvnator-snap-release"/ + ); + assert.match( + verifyStep.run, + /sudo mv "\$\{VERIFIED_ASSET_STAGING\}" "\$\{SEALED_ASSET_DIRECTORY\}"/ + ); + assert.ok(sealedVerifyStep); + assert.equal(sealedVerifyStep.env, undefined); + assert.match( + sealedVerifyStep.run, + /release-snap-assets\.cjs verify-sealed/ + ); + assert.ok( + verifyJob.steps.indexOf(sealedVerifyStep) < + verifyJob.steps.indexOf(transferStep) + ); + assert.equal( + transferStep.uses, + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' + ); + assert.equal( + artifactDownloadStep.uses, + 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' + ); + assert.ok( + publishJob.steps.indexOf(artifactDownloadStep) < + publishJob.steps.indexOf(transferredSealStep) + ); + assert.ok( + publishJob.steps.indexOf(transferredSealStep) < + publishJob.steps.indexOf(snapcraftStep) + ); + assert.ok( + publishJob.steps.indexOf(snapcraftStep) < + publishJob.steps.indexOf(uploadStep) + ); + assert.match( + uploadStep.run, + /VERIFIED_ASSET_DIRECTORY="\/var\/lib\/iptvnator-snap-release\/assets"/ + ); + assert.doesNotMatch(uploadStep.run, /\bnode\b/); + assert.doesNotMatch(uploadStep.run, /release-snap-assets\.cjs/); + assert.doesNotMatch(uploadStep.run, /\bfind\b|\bsort\b/); + assert.match( + uploadStep.run, + /SNAP_FILES=\("\$\{VERIFIED_ASSET_DIRECTORY\}"\/\*\.snap\)/ + ); + assert.match( + uploadStep.run, + /SNAPCRAFT_STORE_CREDENTIALS="\$\{STORE_CREDENTIALS\}" \/snap\/bin\/snapcraft upload/ + ); + assert.doesNotMatch( + uploadStep.run, + /snap-release-downloads\/\$\{SNAP_NAME\}/ + ); + assert.equal( + Object.hasOwn(publishJob.env ?? {}, 'SNAPCRAFT_STORE_CREDENTIALS'), + false + ); + assert.equal(Object.hasOwn(publishJob.env ?? {}, 'GH_TOKEN'), false); + assert.deepEqual(selectStep.env, { + GH_TOKEN: '${{ github.token }}', + }); + assert.deepEqual(downloadStep.env, { + GH_TOKEN: '${{ github.token }}', + }); + assert.equal(verifyStep.env, undefined); + assert.deepEqual(uploadStep.env, { + SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}', + }); +}); diff --git a/tools/packaging/release-snap-source-binding.cjs b/tools/packaging/release-snap-source-binding.cjs new file mode 100644 index 000000000..2624b2fde --- /dev/null +++ b/tools/packaging/release-snap-source-binding.cjs @@ -0,0 +1,1697 @@ +'use strict'; + +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { + validateLinuxRuntimeManifest, +} = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs'); +const { + SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + SOURCE_ARCHIVE_NAME, + sha256File, + validateLinuxSourceArchiveBinding, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); +const { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + inventoryLinuxRuntimeSourceSnapshot, + validateLinuxRuntimeSourceSnapshot, +} = require('./prepare-linux-runtime-source-snapshot.cjs'); + +const COMMAND_OUTPUT_MAX_BUFFER_BYTES = 16 * 1024 * 1024; +const SQUASHFS_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024; +const SOURCE_MEMBER_MAX_BUFFER_BYTES = 128 * 1024 * 1024; +const COMMAND_TIMEOUT_MS = 120_000; +const SOURCE_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024; +const SOURCE_ARCHIVE_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024; +const SNAP_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024; +const SNAP_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024; +const SOURCE_INDEX_SCHEMA_VERSION = 3; +const SNAP_PAYLOAD_ENTRY_LIMIT = 250_000; +const SOURCE_ARCHIVE_ENTRY_LIMIT = 250_000; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const SAFE_BASENAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; +const FRAME_COPY_MANIFEST_NAME = 'embedded-mpv-runtime.json'; +const FRAME_COPY_UNAVAILABLE_MARKER_NAME = 'embedded-mpv-unavailable.txt'; +const NATIVE_PAYLOAD_SUFFIX = [ + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native', +].join('/'); +const SOURCE_TOOLING_FILES = Object.freeze([ + { + archivePath: 'tooling/build-linux-runtime.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'build-linux-runtime.cjs' + ), + }, + { + archivePath: 'tooling/build-linux-runtime.mjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'build-linux-runtime.mjs' + ), + }, + { + archivePath: 'tooling/generate-linux-runtime-notices.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'generate-linux-runtime-notices.cjs' + ), + }, + { + archivePath: 'tooling/linux-runtime-manifest.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'linux-runtime-manifest.cjs' + ), + }, + { + archivePath: 'tooling/linux-source-archive-contract.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'linux-source-archive-contract.cjs' + ), + }, + { + archivePath: 'tooling/stage-runtime.mjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'stage-runtime.mjs' + ), + }, + { + archivePath: 'tooling/prepare-linux-runtime-source-snapshot.cjs', + checkoutPath: path.join( + __dirname, + 'prepare-linux-runtime-source-snapshot.cjs' + ), + }, +]); + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function commandFailureDetails(result) { + const stderr = Buffer.isBuffer(result?.stderr) + ? result.stderr.toString('utf8') + : String(result?.stderr ?? ''); + return stderr.trim().slice(0, 2048); +} + +function defaultRunCommand( + command, + args, + { + encoding = 'utf8', + input, + maxBuffer = COMMAND_OUTPUT_MAX_BUFFER_BYTES, + timeout = COMMAND_TIMEOUT_MS, + } = {} +) { + const spawnOptions = { + killSignal: 'SIGKILL', + maxBuffer, + stdio: 'pipe', + timeout, + windowsHide: true, + }; + if (input !== undefined) { + spawnOptions.input = input; + } + if (encoding !== null) { + spawnOptions.encoding = encoding; + } + const result = childProcess.spawnSync(command, args, spawnOptions); + if (result.error) { + throw new Error( + `Unable to run ${command}: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }` + ); + } + if (result.status !== 0) { + const details = commandFailureDetails(result); + throw new Error( + `${command} exited with status ${String(result.status)}${ + details ? `: ${details}` : '.' + }` + ); + } + return result.stdout ?? (encoding === null ? Buffer.alloc(0) : ''); +} + +function normalizeTarMember(rawName) { + if ( + typeof rawName !== 'string' || + rawName.length === 0 || + rawName.includes('\\') || + [...rawName].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error('Source archive contains an unsafe member name.'); + } + let memberName = rawName; + while (memberName.startsWith('./')) { + memberName = memberName.slice(2); + } + const isDirectory = memberName.endsWith('/'); + if (isDirectory) { + memberName = memberName.slice(0, -1); + } + if (memberName === '') { + return null; + } + const parts = memberName.split('/'); + if ( + path.posix.isAbsolute(memberName) || + parts.some( + (part) => + part === '' || + part === '.' || + part === '..' || + !SAFE_BASENAME_PATTERN.test(part) + ) + ) { + throw new Error( + `Source archive contains an unsafe member name: ${rawName}` + ); + } + return { + isDirectory, + name: parts.join('/'), + rawName, + }; +} + +function parseVerboseTarMembers(listing) { + if (typeof listing !== 'string') { + throw new Error('Unable to read source archive member types.'); + } + const memberTypes = new Map(); + for (const line of listing.split(/\r?\n/).filter(Boolean)) { + let type = line[0]; + if (!['-', 'd', 'h', 'l'].includes(type)) { + throw new Error( + 'Source archive contains an unsupported member type.' + ); + } + let identity = line.trimEnd(); + let linkTarget = null; + for (const marker of [' -> ', ' link to ']) { + const markerIndex = identity.indexOf(marker); + if (markerIndex !== -1) { + linkTarget = identity.slice(markerIndex + marker.length); + identity = identity.slice(0, markerIndex); + if (marker === ' link to ') { + type = 'h'; + } + break; + } + } + const trimmedIdentity = identity.trim(); + const rawName = trimmedIdentity.split(/\s+/).at(-1); + const rawNameOffset = trimmedIdentity.lastIndexOf(rawName); + const metadataTokens = trimmedIdentity + .slice(0, rawNameOffset) + .trim() + .split(/\s+/); + const dateTokenIndex = metadataTokens.findIndex( + (token) => + /^\d{4}-\d{2}-\d{2}$/.test(token) || + /^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/.test( + token + ) + ); + const sizeToken = + dateTokenIndex > 0 ? metadataTokens[dateTokenIndex - 1] : ''; + const size = Number(sizeToken); + if ( + !/^\d+$/.test(sizeToken) || + !Number.isSafeInteger(size) || + size < 0 + ) { + throw new Error( + 'Unable to read a bounded source archive member size.' + ); + } + const member = normalizeTarMember(rawName); + if (!member) { + continue; + } + if (memberTypes.has(member.name)) { + throw new Error( + `Source archive contains duplicate verbose member: ${member.name}` + ); + } + if (member.isDirectory !== (type === 'd')) { + throw new Error( + `Source archive member type does not match its name: ${member.name}` + ); + } + if (type === 'l' || type === 'h') { + if ( + typeof linkTarget !== 'string' || + linkTarget.length === 0 || + linkTarget.includes('\\') || + path.posix.isAbsolute(linkTarget) || + [...linkTarget].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error( + `Source archive contains an unsafe link target: ${member.name}` + ); + } + const resolvedTarget = + type === 'h' + ? path.posix.normalize(linkTarget) + : path.posix.normalize( + path.posix.join( + path.posix.dirname(member.name), + linkTarget + ) + ); + if (resolvedTarget === '..' || resolvedTarget.startsWith('../')) { + throw new Error( + `Source archive link target escapes its root: ${member.name}` + ); + } + linkTarget = resolvedTarget.replace(/^\.\/+/, ''); + } else if (linkTarget !== null) { + throw new Error( + `Source archive regular member has link metadata: ${member.name}` + ); + } + memberTypes.set(member.name, { linkTarget, size, type }); + } + for (const [memberName, member] of memberTypes) { + if ( + member.type === 'h' && + memberTypes.get(member.linkTarget)?.type !== '-' + ) { + throw new Error( + `Source archive hardlink target must be a regular member: ${memberName}` + ); + } + } + return memberTypes; +} + +function listTarMembers(archivePath, runCommand) { + const listing = runCommand( + 'tar', + ['--list', '--ignore-zeros', '--xz', '--file', archivePath], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ); + if (typeof listing !== 'string') { + throw new Error('Unable to read source archive member listing.'); + } + const verboseMembers = parseVerboseTarMembers( + runCommand( + 'tar', + [ + '--list', + '--verbose', + '--ignore-zeros', + '--xz', + '--file', + archivePath, + ], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ) + ); + const members = new Map(); + let extractedBytes = 0; + let entryCount = 0; + for (const rawName of listing.split(/\r?\n/).filter(Boolean)) { + const member = normalizeTarMember(rawName); + if (!member) { + continue; + } + if (members.has(member.name)) { + throw new Error( + `Source archive contains duplicate member: ${member.name}` + ); + } + const verboseMember = verboseMembers.get(member.name); + if (!verboseMember) { + throw new Error( + `Source archive member type is missing: ${member.name}` + ); + } + entryCount += 1; + if (entryCount > SOURCE_ARCHIVE_ENTRY_LIMIT) { + throw new Error( + 'Source archive exceeds the release-verifier entry limit.' + ); + } + if (verboseMember.type === '-') { + if (verboseMember.size > SOURCE_MEMBER_MAX_BUFFER_BYTES) { + throw new Error( + `Source archive member exceeds the release-verifier size limit: ${member.name}` + ); + } + extractedBytes += verboseMember.size; + if ( + !Number.isSafeInteger(extractedBytes) || + extractedBytes > SOURCE_ARCHIVE_EXTRACTED_MAX_BYTES + ) { + throw new Error( + 'Source archive exceeds the release-verifier extracted-size limit.' + ); + } + } + members.set(member.name, { + ...member, + ...verboseMember, + }); + } + if (verboseMembers.size !== members.size) { + throw new Error( + 'Source archive member and type listings do not match.' + ); + } + return members; +} + +function readTarMember( + archivePath, + members, + memberName, + runCommand, + maxBuffer +) { + const member = members.get(memberName); + if (!member || member.type !== '-') { + throw new Error( + `Source archive required member must be a regular file: ${memberName}` + ); + } + const contents = runCommand( + 'tar', + [ + '--extract', + '--xz', + '--to-stdout', + '--file', + archivePath, + '--', + member.rawName, + ], + { + encoding: null, + maxBuffer, + } + ); + const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents); + if (buffer.length !== member.size) { + throw new Error( + `Source archive member size changed during inspection: ${memberName}` + ); + } + return buffer; +} + +function parseJsonMember(contents, memberName) { + try { + return JSON.parse(contents.toString('utf8')); + } catch { + throw new Error( + `Source archive member is not valid JSON: ${memberName}` + ); + } +} + +function sourceMemberRecord( + archivePath, + members, + memberName, + runCommand, + relativePath +) { + const contents = readTarMember( + archivePath, + members, + memberName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + return { + path: relativePath, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + size: contents.length, + }; +} + +function inspectLegalFiles( + archivePath, + members, + runCommand, + rootName, + rootFiles +) { + const rootPrefix = `${rootName}/`; + const licensePrefix = `${rootPrefix}licenses/`; + const allowedRootFiles = new Set( + rootFiles.map((name) => `${rootPrefix}${name}`) + ); + const records = []; + for (const member of members.values()) { + if (member.isDirectory || !member.name.startsWith(rootPrefix)) { + continue; + } + if ( + !member.name.startsWith(licensePrefix) && + !allowedRootFiles.has(member.name) + ) { + throw new Error( + `Source archive contains an undeclared ${rootName} file: ${member.name}` + ); + } + if (member.name.startsWith(licensePrefix)) { + records.push( + sourceMemberRecord( + archivePath, + members, + member.name, + runCommand, + member.name.slice(rootPrefix.length) + ) + ); + } + } + return records.sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); +} + +function inspectLibplaceboSourceSnapshot( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 +) { + const sourceRootName = 'git/libplacebo'; + const sourceRootMember = members.get(sourceRootName); + if (!sourceRootMember || sourceRootMember.type !== 'd') { + throw new Error( + 'Source archive must contain a regular libplacebo source directory.' + ); + } + const sourceMembers = [...members.values()].filter( + ({ name }) => + name === sourceRootName || name.startsWith(`${sourceRootName}/`) + ); + if (sourceMembers.length <= 1) { + throw new Error( + 'Source archive must contain the prepared libplacebo source snapshot.' + ); + } + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-libplacebo-source-verifier-') + ); + try { + runCommand( + 'tar', + [ + '--extract', + '--xz', + '--file', + archivePath, + '--directory', + temporaryRoot, + '--no-same-owner', + '--no-same-permissions', + '-T', + '/dev/stdin', + ], + { + encoding: 'utf8', + input: Buffer.from( + `${sourceMembers.map(({ rawName }) => rawName).join('\n')}\n`, + 'utf8' + ), + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ); + const sourceSnapshot = inventoryLinuxRuntimeSourceSnapshot( + path.join(temporaryRoot, 'git', 'libplacebo') + ); + return validateLinuxRuntimeSourceSnapshot(sourceSnapshot, { + expectedSha256: expectedSourceSnapshotSha256, + }); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function inspectSourceCompliance( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 +) { + if ([...members.keys()].some((name) => name.split('/').includes('.git'))) { + throw new Error( + 'Source archive must not contain VCS metadata directories.' + ); + } + const expectedToolingNames = SOURCE_TOOLING_FILES.map( + ({ archivePath: memberName }) => memberName + ).sort(); + const actualToolingNames = [...members.values()] + .filter( + ({ isDirectory, name }) => + !isDirectory && name.startsWith('tooling/') + ) + .map(({ name }) => name) + .sort(); + if (!isDeepStrictEqual(actualToolingNames, expectedToolingNames)) { + throw new Error( + 'Source archive must contain the exact released runtime tooling set.' + ); + } + for (const toolingFile of SOURCE_TOOLING_FILES) { + const archivedContents = readTarMember( + archivePath, + members, + toolingFile.archivePath, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + const checkoutContents = fs.readFileSync(toolingFile.checkoutPath); + if (!archivedContents.equals(checkoutContents)) { + throw new Error( + `Source archive tooling does not match the released tag: ${toolingFile.archivePath}` + ); + } + } + const libplaceboSourceSnapshot = inspectLibplaceboSourceSnapshot( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 + ); + const licenseInputManifestName = + 'license-inputs/linux-runtime-license-inputs.json'; + const noticeManifestName = 'notices/embedded-mpv-notices.json'; + const noticeFileName = 'notices/THIRD_PARTY_NOTICES.txt'; + const licenseInputs = parseJsonMember( + readTarMember( + archivePath, + members, + licenseInputManifestName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ), + licenseInputManifestName + ); + const notices = parseJsonMember( + readTarMember( + archivePath, + members, + noticeManifestName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ), + noticeManifestName + ); + const noticeFile = sourceMemberRecord( + archivePath, + members, + noticeFileName, + runCommand, + 'THIRD_PARTY_NOTICES.txt' + ); + return { + libplaceboSourceSnapshot, + licenseInputFiles: inspectLegalFiles( + archivePath, + members, + runCommand, + 'license-inputs', + ['linux-runtime-license-inputs.json'] + ), + licenseInputs, + noticeFile, + noticeLicenseFiles: inspectLegalFiles( + archivePath, + members, + runCommand, + 'notices', + ['embedded-mpv-notices.json', 'THIRD_PARTY_NOTICES.txt'] + ), + notices, + toolingValidated: true, + }; +} + +function parseArchiveChecksumRecords(contents) { + const text = contents.toString('utf8'); + if (!text.endsWith('\n') || text.includes('\r')) { + throw new Error( + 'Source archive checksum manifest must use canonical LF-terminated sha256sum records.' + ); + } + const lines = text.slice(0, -1).split('\n'); + const records = lines.map((line) => { + const match = /^([a-f0-9]{64}) {2}([A-Za-z0-9_+.-]+)$/.exec(line); + if (!match) { + throw new Error( + 'Source archive checksum manifest contains an invalid record.' + ); + } + return { + name: match[2], + sha256: match[1], + }; + }); + return normalizeArchiveRecords(records, 'Source archive checksum manifest'); +} + +function assertExactSourceArchiveLayout(members, archiveFiles, compliance) { + const expectedFiles = new Set([ + ...archiveFiles.map(({ name }) => `archives/${name}`), + ...SOURCE_TOOLING_FILES.map(({ archivePath }) => archivePath), + 'license-inputs/linux-runtime-license-inputs.json', + ...compliance.licenseInputFiles.map( + ({ path: relativePath }) => `license-inputs/${relativePath}` + ), + 'metadata/archive-sha256.txt', + 'metadata/iptvnator-git-revision.txt', + 'metadata/local-changes.patch', + 'metadata/runtime-manifest.json', + 'metadata/source-index.json', + 'notices/THIRD_PARTY_NOTICES.txt', + 'notices/embedded-mpv-notices.json', + ...compliance.noticeLicenseFiles.map( + ({ path: relativePath }) => `notices/${relativePath}` + ), + ]); + const expectedDirectories = new Set(['git']); + for (const fileName of expectedFiles) { + const parts = fileName.split('/'); + parts.pop(); + while (parts.length > 0) { + expectedDirectories.add(parts.join('/')); + parts.pop(); + } + } + + const sourceRootName = 'git/libplacebo'; + for (const member of members.values()) { + if ( + member.name === sourceRootName || + member.name.startsWith(`${sourceRootName}/`) + ) { + if ( + member.type === 'h' && + !member.linkTarget.startsWith(`${sourceRootName}/`) + ) { + throw new Error( + `Source archive hardlink leaves the libplacebo snapshot: ${member.name}` + ); + } + continue; + } + if (expectedFiles.has(member.name)) { + if (member.type !== '-') { + throw new Error( + `Canonical source archive file must be regular: ${member.name}` + ); + } + continue; + } + if (expectedDirectories.has(member.name)) { + if (member.type !== 'd') { + throw new Error( + `Canonical source archive directory has the wrong type: ${member.name}` + ); + } + continue; + } + throw new Error( + `Source archive contains an undeclared source archive member: ${member.name}` + ); + } + + for (const fileName of expectedFiles) { + if (members.get(fileName)?.type !== '-') { + throw new Error( + `Source archive is missing canonical regular file: ${fileName}` + ); + } + } + for (const directoryName of expectedDirectories) { + if (members.get(directoryName)?.type !== 'd') { + throw new Error( + `Source archive is missing canonical directory: ${directoryName}` + ); + } + } +} + +function inspectSourceArchive( + archivePath, + { + expectedSourceSnapshotSha256 = EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + runCommand = defaultRunCommand, + } = {} +) { + const archiveStat = fs.lstatSync(archivePath); + if ( + !archiveStat.isFile() || + archiveStat.isSymbolicLink() || + archiveStat.size === 0 || + archiveStat.size > SOURCE_ARCHIVE_MAX_BYTES + ) { + throw new Error( + 'Linux source archive must be a bounded non-empty regular file.' + ); + } + const members = listTarMembers(archivePath, runCommand); + const readMetadata = (memberName) => + readTarMember( + archivePath, + members, + memberName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + const sourceRuntime = parseJsonMember( + readMetadata('metadata/runtime-manifest.json'), + 'metadata/runtime-manifest.json' + ); + const sourceIndex = parseJsonMember( + readMetadata('metadata/source-index.json'), + 'metadata/source-index.json' + ); + const repositoryRevision = readMetadata( + 'metadata/iptvnator-git-revision.txt' + ) + .toString('utf8') + .trim(); + const localChanges = readMetadata('metadata/local-changes.patch'); + const archiveMemberNames = [...members.values()] + .filter( + ({ isDirectory, name }) => + !isDirectory && + name.startsWith('archives/') && + name.split('/').length === 2 + ) + .map(({ name }) => name.slice('archives/'.length)) + .sort(); + const archiveFiles = archiveMemberNames.map((name) => ({ + name, + sha256: crypto + .createHash('sha256') + .update( + readTarMember( + archivePath, + members, + `archives/${name}`, + runCommand, + SOURCE_MEMBER_MAX_BUFFER_BYTES + ) + ) + .digest('hex'), + })); + const archiveChecksums = parseArchiveChecksumRecords( + readMetadata('metadata/archive-sha256.txt') + ); + if ( + !isDeepStrictEqual( + archiveChecksums, + normalizeArchiveRecords( + archiveFiles, + 'Source archive inspected archives' + ) + ) + ) { + throw new Error( + 'Source archive checksum manifest does not match the inspected archives.' + ); + } + const compliance = inspectSourceCompliance( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 + ); + assertExactSourceArchiveLayout(members, archiveFiles, compliance); + return { + archiveSha256: sha256File(archivePath), + archiveFiles, + compliance, + localChanges, + repositoryRevision, + sourceIndex, + sourceRuntime, + }; +} + +function readElfArchitecture(binaryPath) { + const descriptor = fs.openSync(binaryPath, 'r'); + try { + const header = Buffer.alloc(20); + const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0); + if ( + bytesRead !== header.length || + header[0] !== 0x7f || + header[1] !== 0x45 || + header[2] !== 0x4c || + header[3] !== 0x46 || + ![1, 2].includes(header[4]) || + header[5] !== 1 + ) { + throw new Error('Snap Electron executable is not a supported ELF.'); + } + const machine = header.readUInt16LE(18); + if (machine === 62 && header[4] === 2) { + return 'x64'; + } + if (machine === 183 && header[4] === 2) { + return 'arm64'; + } + if (machine === 40 && header[4] === 1) { + return 'arm'; + } + throw new Error( + `Snap Electron executable uses unsupported ELF machine ${machine}.` + ); + } finally { + fs.closeSync(descriptor); + } +} + +function collectSnapNativePayloads(extractionRoot) { + const candidates = []; + const pendingDirectories = [extractionRoot]; + let entryCount = 0; + while (pendingDirectories.length > 0) { + const directoryPath = pendingDirectories.pop(); + const entries = fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort(({ name: left }, { name: right }) => + left.localeCompare(right) + ); + for (const entry of entries) { + entryCount += 1; + if (entryCount > SNAP_PAYLOAD_ENTRY_LIMIT) { + throw new Error( + 'Extracted Snap exceeds the release-verifier entry limit.' + ); + } + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + pendingDirectories.push(entryPath); + continue; + } + if ( + !entry.isFile() || + ![ + FRAME_COPY_MANIFEST_NAME, + FRAME_COPY_UNAVAILABLE_MARKER_NAME, + ].includes(entry.name) + ) { + continue; + } + const relativePath = path + .relative(extractionRoot, entryPath) + .split(path.sep) + .join('/'); + const expectedSuffix = `${NATIVE_PAYLOAD_SUFFIX}/${entry.name}`; + if ( + relativePath === expectedSuffix || + relativePath.endsWith(`/${expectedSuffix}`) + ) { + candidates.push(entryPath); + } + } + } + return candidates; +} + +function inspectSquashfsListing(snapPath, runCommand) { + const listing = runCommand('unsquashfs', ['-lln', snapPath], { + encoding: 'utf8', + maxBuffer: SQUASHFS_LIST_MAX_BUFFER_BYTES, + timeout: COMMAND_TIMEOUT_MS, + }); + if (typeof listing !== 'string') { + throw new Error('Unable to inspect the Snap filesystem listing.'); + } + let entryCount = 0; + let extractedBytes = 0; + for (const line of listing.split(/\r?\n/).filter(Boolean)) { + const match = + /^([bcdlps-])\S*\s+\d+\/\d+\s+(\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+.+$/.exec( + line + ); + if (!match || !['-', 'd', 'l'].includes(match[1])) { + throw new Error( + 'Snap filesystem listing contains an invalid entry.' + ); + } + entryCount += 1; + if (entryCount > SNAP_PAYLOAD_ENTRY_LIMIT) { + throw new Error('Snap exceeds the release-verifier entry limit.'); + } + if (match[1] === '-') { + extractedBytes += Number(match[2]); + if ( + !Number.isSafeInteger(extractedBytes) || + extractedBytes > SNAP_EXTRACTED_MAX_BYTES + ) { + throw new Error( + 'Snap exceeds the release-verifier extracted-size limit.' + ); + } + } + } + if (entryCount === 0) { + throw new Error('Snap filesystem listing must not be empty.'); + } +} + +function inspectSnapPayload( + snapPath, + asset, + { + runCommand = defaultRunCommand, + validatePackagedEmbeddedMpv: + validatePackaged = validatePackagedEmbeddedMpv, + } = {} +) { + const snapStat = fs.lstatSync(snapPath); + if ( + !snapStat.isFile() || + snapStat.isSymbolicLink() || + snapStat.size === 0 || + snapStat.size > SNAP_ARCHIVE_MAX_BYTES + ) { + throw new Error( + `Snap ${asset.name} must be a bounded non-empty regular file.` + ); + } + inspectSquashfsListing(snapPath, runCommand); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-verifier-') + ); + try { + const extractionRoot = path.join(temporaryRoot, 'payload'); + runCommand( + 'unsquashfs', + ['-no-progress', '-dest', extractionRoot, snapPath], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + timeout: COMMAND_TIMEOUT_MS, + } + ); + const payloads = collectSnapNativePayloads(extractionRoot); + const canonicalNativeDirectory = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const canonicalPayloads = [ + FRAME_COPY_MANIFEST_NAME, + FRAME_COPY_UNAVAILABLE_MARKER_NAME, + ] + .map((name) => path.join(canonicalNativeDirectory, name)) + .filter((candidate) => payloads.includes(candidate)); + if ( + payloads.length !== 1 || + canonicalPayloads.length !== 1 || + payloads[0] !== canonicalPayloads[0] + ) { + throw new Error( + `Snap ${asset.name} must contain exactly one canonical frame-copy manifest or unavailable marker.` + ); + } + const payloadPath = canonicalPayloads[0]; + const resourcesDirectory = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'resources' + ); + const electronPath = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'iptvnator.bin' + ); + const electronStat = fs.lstatSync(electronPath); + if ( + !electronStat.isFile() || + electronStat.isSymbolicLink() || + electronStat.size < 20 + ) { + throw new Error( + `Snap ${asset.name} is missing its regular Electron executable.` + ); + } + const architecture = readElfArchitecture(electronPath); + if (path.basename(payloadPath) === FRAME_COPY_MANIFEST_NAME) { + const manifestStat = fs.lstatSync(payloadPath); + if ( + !manifestStat.isFile() || + manifestStat.isSymbolicLink() || + manifestStat.size === 0 || + manifestStat.size > COMMAND_OUTPUT_MAX_BUFFER_BYTES + ) { + throw new Error( + `Snap ${asset.name} contains an invalid frame-copy manifest.` + ); + } + } + const staticErrors = validatePackaged(resourcesDirectory, { + artifactFormat: 'snap', + executableName: 'iptvnator', + foreignArch: architecture !== 'x64', + hostPlatform: 'linux', + platform: 'linux', + profile: 'portable', + required: true, + targetArch: architecture, + targetNames: ['appimage', 'snap'], + }); + if (!Array.isArray(staticErrors) || staticErrors.length > 0) { + throw new Error( + `Snap ${asset.name} failed static frame-copy validation${ + Array.isArray(staticErrors) && staticErrors.length > 0 + ? `: ${staticErrors.join('; ')}` + : '.' + }` + ); + } + if (path.basename(payloadPath) === FRAME_COPY_UNAVAILABLE_MARKER_NAME) { + return { + architecture, + assetName: asset.name, + manifest: null, + markerOnly: true, + }; + } + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(payloadPath, 'utf8')); + } catch { + throw new Error( + `Snap ${asset.name} contains malformed frame-copy manifest JSON.` + ); + } + return { + architecture, + assetName: asset.name, + manifest, + markerOnly: false, + }; + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function normalizeArchiveRecords(records, label) { + if (!Array.isArray(records) || records.length === 0) { + throw new Error(`${label} must contain source archive checksums.`); + } + const normalized = records.map((record) => { + if ( + !isObject(record) || + !isDeepStrictEqual(Object.keys(record).sort(), [ + 'name', + 'sha256', + ]) || + typeof record.name !== 'string' || + !SAFE_BASENAME_PATTERN.test(record.name) || + typeof record.sha256 !== 'string' || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error(`${label} contains an invalid archive record.`); + } + return { + name: record.name, + sha256: record.sha256, + }; + }); + const names = normalized.map(({ name }) => name); + const hashes = normalized.map(({ sha256 }) => sha256); + if ( + new Set(names).size !== names.length || + new Set(hashes).size !== hashes.length + ) { + throw new Error(`${label} contains duplicate archive records.`); + } + return normalized.sort(({ name: left }, { name: right }) => + left.localeCompare(right) + ); +} + +function hasExactFields(value, fields) { + return ( + isObject(value) && + isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()) + ); +} + +function safeRelativePath(value) { + return ( + typeof value === 'string' && + value.length > 0 && + !value.includes('\\') && + !path.posix.isAbsolute(value) && + value + .split('/') + .every( + (part) => + part !== '' && + part !== '.' && + part !== '..' && + SAFE_BASENAME_PATTERN.test(part) + ) + ); +} + +function normalizeLegalFileRecords(records, label) { + if (!Array.isArray(records) || records.length === 0) { + throw new Error(`${label} must contain legal files.`); + } + const normalized = records.map((record) => { + if ( + !hasExactFields(record, ['path', 'sha256', 'size']) || + !safeRelativePath(record.path) || + !record.path.startsWith('licenses/') || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + typeof record.sha256 !== 'string' || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error(`${label} contains an invalid legal file.`); + } + return { ...record }; + }); + const paths = normalized.map(({ path: filePath }) => filePath); + if (new Set(paths).size !== paths.length) { + throw new Error(`${label} contains duplicate legal files.`); + } + return normalized.sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); +} + +function sourcePackageLegalIdentity(sourcePackage) { + return Object.fromEntries( + [ + 'version', + 'sourceUrl', + 'sourceTag', + 'sourceSha256', + 'sourceGitCommit', + 'license', + ] + .filter((field) => Object.hasOwn(sourcePackage, field)) + .map((field) => [field, sourcePackage[field]]) + ); +} + +function verifySourceArchiveCompliance({ + compliance, + sourceIndex, + sourceRuntime, +}) { + if ( + !hasExactFields(compliance, [ + 'libplaceboSourceSnapshot', + 'licenseInputFiles', + 'licenseInputs', + 'noticeFile', + 'noticeLicenseFiles', + 'notices', + 'toolingValidated', + ]) || + compliance.toolingValidated !== true + ) { + throw new Error('Source archive compliance payload is incomplete.'); + } + const { licenseInputs, notices } = compliance; + if ( + !hasExactFields(licenseInputs, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'packages', + ]) || + licenseInputs.schemaVersion !== 1 || + licenseInputs.origin !== 'pinned-linux-runtime-license-inputs' || + licenseInputs.platform !== 'linux' || + licenseInputs.arch !== 'x64' || + !Array.isArray(licenseInputs.packages) || + !hasExactFields(notices, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'noticeFile', + 'packages', + 'totalBytes', + ]) || + notices.schemaVersion !== 1 || + notices.origin !== 'pinned-linux-runtime-upstream-licenses' || + notices.platform !== 'linux' || + notices.arch !== 'x64' || + !Array.isArray(notices.packages) + ) { + throw new Error('Source archive compliance manifests are invalid.'); + } + const runtimePackages = sourceRuntime.packages; + const runtimePackageIds = Object.keys(runtimePackages).sort(); + const inputPackages = new Map( + licenseInputs.packages.map((record) => [record?.id, record]) + ); + const noticePackages = new Map( + notices.packages.map((record) => [record?.id, record]) + ); + if ( + inputPackages.size !== licenseInputs.packages.length || + noticePackages.size !== notices.packages.length || + !isDeepStrictEqual( + [...inputPackages.keys()].sort(), + runtimePackageIds + ) || + !isDeepStrictEqual([...noticePackages.keys()].sort(), runtimePackageIds) + ) { + throw new Error( + 'Source archive legal package set does not match the runtime.' + ); + } + const declaredLegalFiles = []; + for (const packageId of runtimePackageIds) { + if (!SAFE_BASENAME_PATTERN.test(packageId)) { + throw new Error( + 'Source archive runtime contains an unsafe package id.' + ); + } + const identity = sourcePackageLegalIdentity(runtimePackages[packageId]); + const inputPackage = inputPackages.get(packageId); + const noticePackage = noticePackages.get(packageId); + if ( + !hasExactFields(inputPackage, [ + 'id', + ...Object.keys(identity), + 'files', + ]) || + !hasExactFields(noticePackage, [ + 'id', + ...Object.keys(identity), + 'files', + ]) || + !Object.entries(identity).every( + ([field, value]) => + isDeepStrictEqual(inputPackage[field], value) && + isDeepStrictEqual(noticePackage[field], value) + ) || + !Array.isArray(inputPackage.files) || + !Array.isArray(noticePackage.files) || + inputPackage.files.length === 0 || + inputPackage.files.length !== noticePackage.files.length + ) { + throw new Error( + `Source archive legal identity is invalid for ${packageId}.` + ); + } + const inputFiles = inputPackage.files + .map((record) => { + if ( + !hasExactFields(record, [ + 'sourcePath', + 'path', + 'size', + 'sha256', + ]) || + !safeRelativePath(record.sourcePath) || + record.path !== `licenses/${packageId}/${record.sourcePath}` + ) { + throw new Error( + `Source archive license input is invalid for ${packageId}.` + ); + } + return { + path: record.path, + sha256: record.sha256, + size: record.size, + }; + }) + .sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); + const noticeFiles = normalizeLegalFileRecords( + noticePackage.files, + `Source archive notices for ${packageId}` + ); + const normalizedInputFiles = normalizeLegalFileRecords( + inputFiles, + `Source archive license inputs for ${packageId}` + ); + if (!isDeepStrictEqual(normalizedInputFiles, noticeFiles)) { + throw new Error( + `Source archive legal files diverge for ${packageId}.` + ); + } + declaredLegalFiles.push(...noticeFiles); + } + const normalizedDeclaredFiles = normalizeLegalFileRecords( + declaredLegalFiles, + 'Source archive declared legal files' + ); + if ( + !isDeepStrictEqual( + normalizeLegalFileRecords( + compliance.licenseInputFiles, + 'Source archive license input contents' + ), + normalizedDeclaredFiles + ) || + !isDeepStrictEqual( + normalizeLegalFileRecords( + compliance.noticeLicenseFiles, + 'Source archive notice contents' + ), + normalizedDeclaredFiles + ) + ) { + throw new Error( + 'Source archive legal contents do not match their manifests.' + ); + } + if ( + !hasExactFields(compliance.noticeFile, ['path', 'sha256', 'size']) || + compliance.noticeFile.path !== 'THIRD_PARTY_NOTICES.txt' || + !Number.isSafeInteger(compliance.noticeFile.size) || + compliance.noticeFile.size <= 0 || + !SHA256_PATTERN.test(compliance.noticeFile.sha256) || + !isDeepStrictEqual(notices.noticeFile, compliance.noticeFile) || + notices.totalBytes !== + compliance.noticeFile.size + + normalizedDeclaredFiles.reduce( + (total, file) => total + file.size, + 0 + ) + ) { + throw new Error('Source archive aggregate notices are invalid.'); + } + if ( + !isDeepStrictEqual(sourceIndex.legal, { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: notices.noticeFile, + packages: notices.packages, + }) + ) { + throw new Error( + 'Source archive legal index does not match its notices.' + ); + } +} + +function verifySnapReleaseSourceBinding( + { expectedRepositoryRevision, sourceInspection, snapPayloads }, + { + expectedSourceSnapshotSha256 = EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + validateRuntimeManifest = validateLinuxRuntimeManifest, + } = {} +) { + if ( + typeof expectedRepositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(expectedRepositoryRevision) + ) { + throw new Error( + 'Expected release repository revision must be a full Git commit.' + ); + } + if (!isObject(sourceInspection)) { + throw new Error('Source archive inspection is missing.'); + } + const { + archiveFiles, + archiveSha256, + compliance, + localChanges, + repositoryRevision, + sourceIndex, + sourceRuntime, + } = sourceInspection; + const runtimeErrors = validateRuntimeManifest(sourceRuntime); + if (!Array.isArray(runtimeErrors) || runtimeErrors.length > 0) { + throw new Error( + `Source archive runtime manifest is invalid${ + Array.isArray(runtimeErrors) && runtimeErrors.length > 0 + ? `: ${runtimeErrors.join('; ')}` + : '.' + }` + ); + } + if ( + repositoryRevision !== expectedRepositoryRevision || + !isObject(sourceIndex) || + sourceIndex.repositoryRevision !== expectedRepositoryRevision + ) { + throw new Error( + 'Source archive repository revision does not match the released tag.' + ); + } + const expectedSourceArchiveBinding = { + schemaVersion: SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + name: SOURCE_ARCHIVE_NAME, + sha256: archiveSha256, + repositoryRevision, + }; + const sourceArchiveBindingErrors = validateLinuxSourceArchiveBinding( + expectedSourceArchiveBinding, + { + expectedRepositoryRevision, + expectedSha256: archiveSha256, + } + ); + if (sourceArchiveBindingErrors.length > 0) { + throw new Error( + `Source archive byte binding is invalid: ${sourceArchiveBindingErrors.join( + '; ' + )}` + ); + } + const localChangesLength = + typeof localChanges === 'string' + ? Buffer.byteLength(localChanges) + : localChanges instanceof Uint8Array + ? localChanges.byteLength + : -1; + if (localChangesLength !== 0) { + throw new Error( + 'Source archive must describe a clean released repository revision.' + ); + } + if ( + sourceIndex.schemaVersion !== SOURCE_INDEX_SCHEMA_VERSION || + !isDeepStrictEqual(Object.keys(sourceIndex).sort(), [ + 'archives', + 'legal', + 'libplacebo', + 'repositoryRevision', + 'schemaVersion', + 'sourcePackages', + ]) || + !isObject(sourceIndex.legal) || + !isDeepStrictEqual(sourceIndex.sourcePackages, sourceRuntime.packages) + ) { + throw new Error( + 'Source archive index does not match its runtime manifest.' + ); + } + verifySourceArchiveCompliance({ + compliance, + sourceIndex, + sourceRuntime, + }); + const libplaceboPackage = sourceRuntime.packages?.libplacebo; + const sourceSnapshot = validateLinuxRuntimeSourceSnapshot( + compliance.libplaceboSourceSnapshot, + { + expectedSha256: expectedSourceSnapshotSha256, + } + ); + const expectedLibplaceboRecord = { + sourceGitCommit: libplaceboPackage?.sourceGitCommit, + sourceSubmodules: [...EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES], + sourceSnapshot, + }; + if ( + !isDeepStrictEqual( + libplaceboPackage?.sourceSubmodules, + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES + ) + ) { + throw new Error( + 'Source archive libplacebo submodule records do not match the pinned source identity.' + ); + } + if ( + !GIT_COMMIT_PATTERN.test( + expectedLibplaceboRecord.sourceGitCommit ?? '' + ) || + !isDeepStrictEqual(sourceIndex.libplacebo, expectedLibplaceboRecord) + ) { + throw new Error( + 'Source archive libplacebo identity does not match its runtime manifest.' + ); + } + const indexedArchives = normalizeArchiveRecords( + sourceIndex.archives, + 'Source archive index' + ); + const inspectedArchives = normalizeArchiveRecords( + archiveFiles, + 'Source archive contents' + ); + if (!isDeepStrictEqual(inspectedArchives, indexedArchives)) { + throw new Error( + 'Source archive checksums do not match its source index.' + ); + } + const expectedArchiveHashes = Object.values(sourceRuntime.packages ?? {}) + .filter( + (sourcePackage) => + isObject(sourcePackage) && + Object.hasOwn(sourcePackage, 'sourceSha256') + ) + .map((sourcePackage) => sourcePackage.sourceSha256) + .sort(); + const indexedArchiveHashes = indexedArchives + .map(({ sha256 }) => sha256) + .sort(); + if ( + expectedArchiveHashes.length === 0 || + expectedArchiveHashes.some( + (sha256) => + typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256) + ) || + new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length || + !isDeepStrictEqual(indexedArchiveHashes, expectedArchiveHashes) + ) { + throw new Error( + 'Source archive checksums do not match the pinned runtime packages.' + ); + } + if (!Array.isArray(snapPayloads) || snapPayloads.length === 0) { + throw new Error('Every selected Snap must be inspected.'); + } + const assetNames = new Set(); + let x64SnapCount = 0; + for (const payload of snapPayloads) { + if ( + !isObject(payload) || + typeof payload.assetName !== 'string' || + payload.assetName.length === 0 || + assetNames.has(payload.assetName) + ) { + throw new Error('Selected Snap inspections must be unique.'); + } + assetNames.add(payload.assetName); + if (payload.architecture === 'x64') { + x64SnapCount += 1; + if (payload.markerOnly !== false || !isObject(payload.manifest)) { + throw new Error( + `x64 Snap ${payload.assetName} must contain the frame-copy runtime.` + ); + } + const manifest = payload.manifest; + if ( + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + manifest.profile !== 'portable' || + manifest.runtimeMode !== 'bundled' || + !Array.isArray(manifest.targets) || + !manifest.targets.includes('snap') || + !isDeepStrictEqual( + manifest.sourceArchive, + expectedSourceArchiveBinding + ) + ) { + throw new Error( + `x64 Snap ${payload.assetName} has an invalid frame-copy source archive binding.` + ); + } + if (!isDeepStrictEqual(manifest.sourceRuntime, sourceRuntime)) { + throw new Error( + `Snap source runtime does not match source archive: ${payload.assetName}` + ); + } + continue; + } + if ( + !['arm', 'arm64'].includes(payload.architecture) || + payload.markerOnly !== true || + payload.manifest !== null + ) { + throw new Error( + `Non-x64 Snap ${payload.assetName} must remain marker-only.` + ); + } + } + if (x64SnapCount !== 1) { + throw new Error( + `Public release must contain exactly one x64 Snap; received ${x64SnapCount}.` + ); + } + return true; +} + +module.exports = { + inspectSnapPayload, + inspectSourceArchive, + verifySnapReleaseSourceBinding, +}; diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index c9c250a6a..d69a48b89 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -1,9 +1,16 @@ import assert from 'node:assert/strict'; import { parse } from 'yaml'; +const PINNED_CHECKOUT_ACTION = + 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5'; +const PINNED_UPLOAD_ARTIFACT_ACTION = + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02'; +const PINNED_DOWNLOAD_ARTIFACT_ACTION = + 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'; const PUBLISH_ACTION_ALLOWLIST = Object.freeze([ - 'actions/checkout@v4', - 'samuelmeuli/action-snapcraft@v3', + PINNED_CHECKOUT_ACTION, + PINNED_DOWNLOAD_ARTIFACT_ACTION, + PINNED_UPLOAD_ARTIFACT_ACTION, ]); const BUILD_ACTION_ALLOWLIST = Object.freeze([ 'actions/cache/restore@v4', @@ -16,26 +23,213 @@ const BUILD_ACTION_ALLOWLIST = Object.freeze([ 'pnpm/action-setup@v4', 'softprops/action-gh-release@v2', ]); +const VERIFY_JOB_ID = 'verify-snap'; const PUBLISH_JOB_ID = 'publish-snap'; -const PUBLISH_JOB_CONDITION = +const VERIFY_JOB_CONDITION = "${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}"; +const PUBLISH_JOB_CONDITION = + "${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}"; +const VERIFIED_RELEASE_ARTIFACT_NAME = 'verified-snap-release-assets'; const PUBLISH_STEP_NAME = 'Publish all public-release snaps to edge'; -const PUBLISH_STEP_CONTRACT = Object.freeze({ - name: PUBLISH_STEP_NAME, +const PUBLISH_CHECKOUT_STEP_NAME = 'Checkout released tooling'; +const PUBLISH_CHECKOUT_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_CHECKOUT_STEP_NAME, + uses: PINNED_CHECKOUT_ACTION, + with: { + ref: '${{ github.event.release.tag_name }}', + 'persist-credentials': false, + }, +}); +const PUBLISH_SNAPCRAFT_SETUP_STEP_NAME = 'Install Snapcraft'; +const PUBLISH_SNAPCRAFT_SETUP_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_SNAPCRAFT_SETUP_STEP_NAME, shell: 'bash', run: [ 'set -euo pipefail', '', - 'node -e \\', - ` "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of selected.snapAssets) console.log(asset.name);" \\`, - ' "${RUNNER_TEMP}/selected-snap-release-assets.json" |', - ' while IFS= read -r SNAP_NAME; do', - ' SNAP_FILE="${RUNNER_TEMP}/snap-release-downloads/${SNAP_NAME}"', - ' echo "Publishing public release asset: ${SNAP_NAME}"', - ' # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.', - ' # GitHub Actions never promotes automatically.', - ' snapcraft upload --release=edge "${SNAP_FILE}"', - ' done', + 'sudo snap install snapcraft --classic --channel=stable', + '', + ].join('\n'), +}); +const VERIFY_ARTIFACT_UPLOAD_STEP_NAME = 'Transfer verified release assets'; +const VERIFY_ARTIFACT_UPLOAD_STEP_CONTRACT = Object.freeze({ + name: VERIFY_ARTIFACT_UPLOAD_STEP_NAME, + uses: PINNED_UPLOAD_ARTIFACT_ACTION, + with: { + name: VERIFIED_RELEASE_ARTIFACT_NAME, + path: '/var/lib/iptvnator-snap-release/assets', + 'if-no-files-found': 'error', + 'retention-days': 1, + 'compression-level': 0, + 'include-hidden-files': true, + }, +}); +const PUBLISH_ARTIFACT_DOWNLOAD_STEP_NAME = 'Download verified release assets'; +const PUBLISH_ARTIFACT_DOWNLOAD_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_ARTIFACT_DOWNLOAD_STEP_NAME, + uses: PINNED_DOWNLOAD_ARTIFACT_ACTION, + with: { + name: VERIFIED_RELEASE_ARTIFACT_NAME, + path: '${{ runner.temp }}/verified-snap-release-assets', + }, +}); +const PUBLISH_SEALED_VERIFY_STEP_NAME = 'Reverify sealed public release assets'; +const PUBLISH_SEALED_VERIFY_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_SEALED_VERIFY_STEP_NAME, + shell: 'bash', + run: [ + 'set -euo pipefail', + '', + 'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"', + 'node tools/packaging/release-snap-assets.cjs verify-sealed \\', + ' --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \\', + ' --directory "${VERIFIED_ASSET_DIRECTORY}" \\', + ' --receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \\', + ' --repository-revision "$(git rev-parse HEAD)"', + '', + ].join('\n'), +}); +const VERIFY_TRANSFER_BINDING_STEP_NAME = 'Bind verified release transfer'; +const VERIFY_TRANSFER_BINDING_STEP_CONTRACT = Object.freeze({ + name: VERIFY_TRANSFER_BINDING_STEP_NAME, + id: 'bind-transfer', + shell: 'bash', + run: [ + 'set -euo pipefail', + '', + 'RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json"', + 'RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"', + 'RECEIPT_SHA256="${RECEIPT_RECORD%% *}"', + '[[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]', + `printf 'receipt-sha256=%s\\n' "\${RECEIPT_SHA256}" >> "\${GITHUB_OUTPUT}"`, + '', + ].join('\n'), +}); +const PUBLISH_TRANSFER_VERIFY_STEP_NAME = + 'Seal transferred public release assets'; +const PUBLISH_TRANSFER_VERIFY_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_TRANSFER_VERIFY_STEP_NAME, + shell: 'bash', + env: { + EXPECTED_RECEIPT_SHA256: + '${{ needs.verify-snap.outputs.receipt-sha256 }}', + }, + run: [ + 'set -euo pipefail', + '', + 'TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets"', + 'SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"', + 'SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"', + 'test -d "${TRANSFERRED_ASSET_DIRECTORY}"', + 'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}"', + 'shopt -s nullglob dotglob', + 'TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*)', + 'TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap)', + 'test "${#TRANSFERRED_SNAPS[@]}" -gt 0', + 'test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))"', + 'test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"', + 'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"', + 'test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"', + 'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"', + 'for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do', + ' test -f "${ASSET_FILE}"', + ' test ! -L "${ASSET_FILE}"', + 'done', + 'RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"', + 'RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"', + 'ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}"', + '[[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]', + 'test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}"', + "/usr/bin/jq --exit-status '", + ' type == "object" and', + ' (keys == ["assets", "repositoryRevision", "schemaVersion"]) and', + ' (.schemaVersion == 1) and', + ' (.repositoryRevision |', + ' type == "string" and test("^[a-f0-9]{40,64}$")) and', + ' (.assets | type == "array" and length >= 2) and', + ' (.assets | all(.[];', + ' type == "object" and', + ' (keys == ["id", "name", "sha256", "size"]) and', + ' (.id |', + ' type == "number" and . > 0 and', + ' . <= 9007199254740991 and . == floor) and', + ' (.name |', + ' type == "string" and length > 0 and', + ' . != "." and . != ".." and', + ' (contains("/") | not) and', + ' (contains("\\\\") | not) and', + ' (explode | all(.[]; . > 31 and . != 127))) and', + ' (.sha256 |', + ' type == "string" and test("^[a-f0-9]{64}$")) and', + ' (.size |', + ' type == "number" and . > 0 and', + ' . <= 9007199254740991 and . == floor))) and', + ' ([.assets[].name] | length == (unique | length)) and', + ' ([.assets[] |', + ' select(.name == "linux-frame-copy-runtime-sources.tar.xz")] |', + ' length == 1) and', + ' ([.assets[] | select(.name | endswith(".snap"))] |', + ' length >= 1) and', + ' (.assets | all(.[];', + ' .name == "linux-frame-copy-runtime-sources.tar.xz" or', + ' (.name | endswith(".snap"))))', + '\' "${RECEIPT_PATH}" > /dev/null', + `RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "\${RECEIPT_PATH}")"`, + 'test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))"', + 'SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv"', + 'CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt"', + 'umask 077', + '/usr/bin/jq --raw-output \\', + ` '.assets[] | [.name, (.size | tostring)] | @tsv' \\`, + ' "${RECEIPT_PATH}" > "${SIZE_MANIFEST}"', + `while IFS=$'\\t' read -r ASSET_NAME EXPECTED_SIZE; do`, + ' ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}"', + ' ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")"', + ' test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}"', + 'done < "${SIZE_MANIFEST}"', + '/usr/bin/jq --raw-output \\', + ` '.assets[] | "\\(.sha256) \\(.name)"' \\`, + ' "${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}"', + '(', + ' cd "${TRANSFERRED_ASSET_DIRECTORY}"', + ' /usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}"', + ')', + 'rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}"', + 'shopt -u nullglob dotglob', + 'sudo test ! -e "${SEALED_ASSET_PARENT}"', + 'sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"', + 'sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}"', + 'sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"', + 'sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +', + 'sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +', + 'sudo chmod 0555 "${SEALED_ASSET_PARENT}"', + '', + ].join('\n'), +}); +const PUBLISH_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_STEP_NAME, + shell: 'bash', + env: { + SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}', + }, + run: [ + 'set -euo pipefail', + '', + 'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"', + 'STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"', + 'unset SNAPCRAFT_STORE_CREDENTIALS', + 'shopt -s nullglob dotglob', + 'SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)', + 'test "${#SNAP_FILES[@]}" -gt 0', + 'for SNAP_FILE in "${SNAP_FILES[@]}"; do', + ' SNAP_NAME="${SNAP_FILE##*/}"', + ' echo "Publishing public release asset: ${SNAP_NAME}"', + ' # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.', + ' # GitHub Actions never promotes automatically.', + ' SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"', + 'done', + 'unset STORE_CREDENTIALS', + 'shopt -u nullglob dotglob', '', ].join('\n'), }); @@ -212,27 +406,144 @@ export function assertPublishSnapWorkflowPolicy(workflowText) { { release: { types: ['published'] } }, 'the publish workflow must retain its exact release trigger' ); + assert.deepEqual( + Object.keys(workflow).sort(), + ['jobs', 'name', 'on', 'permissions'], + 'the publish workflow must not add global execution or environment surfaces' + ); + assert.deepEqual( + workflow.permissions, + { contents: 'read' }, + 'the publish workflow must retain read-only repository permissions' + ); assert.deepEqual( [...actions].sort(), [...PUBLISH_ACTION_ALLOWLIST].sort(), 'the publish workflow must use exactly the allowlisted actions' ); + assert.deepEqual( + Object.keys(workflow.jobs), + [VERIFY_JOB_ID, PUBLISH_JOB_ID], + 'the publish workflow must isolate verification and credentialed upload on two exact jobs' + ); + const verifyJob = workflow.jobs[VERIFY_JOB_ID]; const publishJob = workflow.jobs[PUBLISH_JOB_ID]; + assert.ok(isRecord(verifyJob), 'the canonical verification job must exist'); assert.ok(isRecord(publishJob), 'the canonical publish job must exist'); + assert.deepEqual( + Object.keys(verifyJob).sort(), + ['env', 'if', 'name', 'outputs', 'runs-on', 'steps', 'timeout-minutes'], + 'the verification job must retain its exact execution surface' + ); + assert.deepEqual( + verifyJob.env, + { + SOURCE_ARCHIVE_NAME: 'linux-frame-copy-runtime-sources.tar.xz', + }, + 'the verification job must expose only the fixed source archive name' + ); + assert.deepEqual( + verifyJob.outputs, + { + 'receipt-sha256': + '${{ steps.bind-transfer.outputs.receipt-sha256 }}', + }, + 'the verification job must expose only the separately bound receipt digest' + ); + assert.deepEqual( + Object.keys(publishJob).sort(), + ['if', 'name', 'needs', 'runs-on', 'steps', 'timeout-minutes'], + 'the fresh credentialed job must retain its exact execution surface' + ); + assert.equal( + verifyJob['runs-on'], + 'ubuntu-latest', + 'the verification job must use a fresh GitHub-hosted runner' + ); + assert.equal( + publishJob['runs-on'], + 'ubuntu-latest', + 'the credentialed job must use a separate fresh GitHub-hosted runner' + ); + assert.equal( + verifyJob['timeout-minutes'], + 45, + 'the verification job must retain its bounded timeout' + ); + assert.equal( + publishJob['timeout-minutes'], + 20, + 'the credentialed job must retain its bounded timeout' + ); + assert.equal( + verifyJob.if, + VERIFY_JOB_CONDITION, + 'the verification job must retain its exact release condition' + ); + assert.equal( + publishJob.needs, + VERIFY_JOB_ID, + 'the publish job must depend on successful isolated verification' + ); assert.equal( publishJob.if, PUBLISH_JOB_CONDITION, - 'the publish job must retain its exact release condition' + 'the publish job must retain its exact verified-release condition' ); assert.deepEqual( - publishJob.steps.filter((step) => step.name === PUBLISH_STEP_NAME), - [PUBLISH_STEP_CONTRACT], - 'the publish workflow must retain the exact reviewed publication step' + verifyJob.steps.filter( + (step) => step.name === PUBLISH_CHECKOUT_STEP_NAME + ), + [PUBLISH_CHECKOUT_STEP_CONTRACT], + 'the publish workflow must checkout released tooling without persisting repository credentials' + ); + assert.deepEqual( + verifyJob.steps.filter( + (step) => step.name === PUBLISH_SEALED_VERIFY_STEP_NAME + ), + [PUBLISH_SEALED_VERIFY_STEP_CONTRACT], + 'the verification job must fully verify root-sealed assets before transfer' + ); + assert.deepEqual( + verifyJob.steps.filter( + (step) => step.name === VERIFY_ARTIFACT_UPLOAD_STEP_NAME + ), + [VERIFY_ARTIFACT_UPLOAD_STEP_CONTRACT], + 'the verification job must transfer only the root-sealed verified data artifact' + ); + assert.deepEqual( + verifyJob.steps.filter( + (step) => step.name === VERIFY_TRANSFER_BINDING_STEP_NAME + ), + [VERIFY_TRANSFER_BINDING_STEP_CONTRACT], + 'the verification job must bind the exact receipt outside artifact transport' + ); + assert.deepEqual( + publishJob.steps, + [ + PUBLISH_ARTIFACT_DOWNLOAD_STEP_CONTRACT, + PUBLISH_TRANSFER_VERIFY_STEP_CONTRACT, + PUBLISH_SNAPCRAFT_SETUP_STEP_CONTRACT, + PUBLISH_STEP_CONTRACT, + ], + 'the fresh publish runner must only download, validate, seal, install Snapcraft, and upload' + ); + assert.equal( + JSON.stringify(verifyJob).includes('snapcraft_token'), + false, + 'the release-tag verification job must never receive the Store credential' + ); + assert.equal( + publishJob.steps + .slice(0, -1) + .some((step) => JSON.stringify(step).includes('snapcraft_token')), + false, + 'only the final credentialed upload step may receive the Store credential' ); assert.equal( literalSnapcraftTokens(commandSource).length, - 1, - 'the publish workflow must contain exactly one literal Snapcraft command' + 2, + 'the publish workflow must contain exactly the reviewed install and upload Snapcraft commands' ); } diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index fd332abfe..ce1cf62e8 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -64,17 +64,20 @@ const UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES = [ 'VK_LAYER_PATH', ]; -function defaultRunCommand(command, args, options = {}) { - return spawnSync(command, args, { +export function runVerifierCommand(command, args, options = {}) { + const spawnOptions = { cwd: options.cwd, env: options.env, encoding: 'utf8', stdio: 'pipe', timeout: options.timeout, killSignal: options.killSignal, - maxBuffer: options.maxBuffer, windowsHide: true, - }); + }; + if (options.maxBuffer !== undefined) { + spawnOptions.maxBuffer = options.maxBuffer; + } + return spawnSync(command, args, spawnOptions); } function assertCommandSucceeded(command, args, result) { @@ -219,7 +222,7 @@ export function extractLinuxArtifact({ artifactPath, format, destination, - runCommand = defaultRunCommand, + runCommand = runVerifierCommand, }) { fs.rmSync(destination, { recursive: true, force: true }); fs.mkdirSync(path.dirname(destination), { recursive: true }); @@ -1210,7 +1213,7 @@ export function readLinuxArtifactMetadata({ artifactPath, format, extractionRoot, - runCommand = defaultRunCommand, + runCommand = runVerifierCommand, }) { const run = (command, args) => assertCommandSucceeded(command, args, runCommand(command, args)); @@ -1396,7 +1399,7 @@ function defaultElfInspector(binaryPath) { const result = assertCommandSucceeded( 'readelf', ['-d', binaryPath], - defaultRunCommand('readelf', ['-d', binaryPath]) + runVerifierCommand('readelf', ['-d', binaryPath]) ); return parseReadelfDynamic(result.stdout); } @@ -1521,7 +1524,7 @@ export function verifyExtractedLinuxFrameCopyRuntime({ packageDependencies = [], declaredArch = null, elfInspector = defaultElfInspector, - probeRunner = defaultRunCommand, + probeRunner = runVerifierCommand, environment = process.env, asarListPackage = defaultListAsarPackage, }) { @@ -1625,12 +1628,12 @@ export function verifyExtractedLinuxFrameCopyRuntime({ export function verifyLinuxFrameCopyArtifact({ artifactPath, profileName, - runCommand = defaultRunCommand, + runCommand = runVerifierCommand, extractArtifact = extractLinuxArtifact, metadataReader = readLinuxArtifactMetadata, payloadVerifier = verifyExtractedLinuxFrameCopyRuntime, elfInspector = defaultElfInspector, - probeRunner = defaultRunCommand, + probeRunner = runVerifierCommand, environment = process.env, }) { const resolvedArtifactPath = path.resolve(artifactPath); diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index 8e195a95d..e608943ef 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -13,6 +13,7 @@ import { parseVerifierArguments, readLinuxArtifactMetadata, readElfArchitecture, + runVerifierCommand, validateSystemPackageDependencies, validateExtractedSnapMetadata, verifyExtractedLinuxFrameCopyRuntime, @@ -35,6 +36,10 @@ const backendRuntimeContractsUrl = new URL( '../../apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts', import.meta.url ); +const backendRuntimeProbeUrl = new URL( + '../../apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts', + import.meta.url +); const electronBackendProjectUrl = new URL( '../../apps/electron-backend/project.json', import.meta.url @@ -147,6 +152,30 @@ test('uses the shared frozen runtime probe resource contract', async () => { backendContractsSource, /RUNTIME_PROBE_TIMEOUT_MS\s*=\s*3000/ ); + for (const sourceUrl of [verifierUrl, backendRuntimeProbeUrl]) { + assert.match( + fs.readFileSync(sourceUrl, 'utf8'), + /maxBuffer:\s*RUNTIME_PROBE_MAX_BUFFER_BYTES/ + ); + } +}); + +test('preserves the child-process default unless an explicit capture bound is requested', () => { + const outputScript = 'process.stdout.write("x".repeat(2 * 1024 * 1024))'; + const defaultCapture = runVerifierCommand(process.execPath, [ + '-e', + outputScript, + ]); + assert.equal(defaultCapture.error?.code, 'ENOBUFS'); + + const explicitCapture = runVerifierCommand( + process.execPath, + ['-e', outputScript], + { maxBuffer: 3 * 1024 * 1024 } + ); + assert.equal(explicitCapture.error, undefined); + assert.equal(explicitCapture.status, 0); + assert.equal(explicitCapture.stdout.length, 2 * 1024 * 1024); }); test('tracks the shared probe contract in cached backend targets and runtime lint', () => {