fix(packaging): harden Linux frame-copy releases

This commit is contained in:
4gray committed 2026-07-18 15:37:35 +02:00
1 parent a651dd69a4
commit 3236a17e99
41 files changed
+6028 -142

No files matched your search

+18 -3
View File
@@ -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
+203 -20
View File
@@ -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
@@ -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
+41 -7
View File
@@ -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`.
+41 -9
View File
@@ -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:
+1
View File
@@ -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": [
@@ -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,
@@ -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/
@@ -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,
};
+8
View File
@@ -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"
],
@@ -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;
@@ -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',
@@ -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');
@@ -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,
@@ -30,11 +30,6 @@ function createSourceRuntime(
runtimeDependencyClosure: Record<string, unknown>
): Record<string, unknown> {
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<string, unknown> = {
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;
@@ -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: {
@@ -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<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
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<string, unknown>) {
@@ -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)
);
}
+60 -9
View File
@@ -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.
+53 -7
View File
@@ -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
@@ -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,
@@ -64,7 +64,7 @@ function createPinnedSourceRecords() {
? {
sourceGitCommit: sourcePackage.expectedGitCommit,
sourceSubmodules: [
`${'a'.repeat(40)} 3rdparty/example`,
...sourcePackage.expectedSubmodules,
],
}
: {
@@ -39,7 +39,7 @@ function runtimeManifest() {
: {
sourceGitCommit: sourcePackage.expectedGitCommit,
sourceSubmodules: [
`${'a'.repeat(40)} 3rdparty/example`,
...sourcePackage.expectedSubmodules,
],
}),
license: sourcePackage.license,
@@ -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.`
);
}
}
}
@@ -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';
@@ -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 <path> --repository-revision <commit> --output <path>'
);
}
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,
};
@@ -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;
@@ -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);
+39 -1
View File
@@ -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) => {
@@ -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),
}
: {}),
@@ -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,
@@ -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,
};
@@ -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'),
+10 -2
View File
@@ -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"]
+109 -1
View File
@@ -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`,
+517 -5
View File
@@ -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 <path> --output-json <path> | verify --manifest <path> --directory <path>'
`Usage: release-snap-assets.cjs select --assets-json <path> --output-json <path> | verify --manifest <path> --directory <path> --repository-revision <commit> --verified-directory <path> | verify-receipt --manifest <path> --directory <path> --receipt <path> --repository-revision <commit> | verify-sealed --manifest <path> --directory <path> --receipt <path> --repository-revision <commit>`
);
}
@@ -184,6 +691,11 @@ if (require.main === module) {
module.exports = {
SOURCE_ARCHIVE_NAME,
VERIFIED_RELEASE_RECEIPT_NAME,
selectSnapReleaseAssets,
snapshotSnapReleaseDownloads,
verifyVerifiedReleaseReceipt,
verifySnapReleaseCorrespondence,
verifySnapReleaseDownloads,
verifySnapReleaseSourceBinding,
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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'
);
}
@@ -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);
@@ -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', () => {