mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(packaging): harden Linux frame-copy releases
This commit is contained in:
1 parent
a651dd69a4
commit
3236a17e99
41 files changed
+6028
-142
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
+20
@@ -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',
|
||||
|
||||
+1
@@ -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');
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+10
-5
@@ -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;
|
||||
|
||||
+8
@@ -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: {
|
||||
|
||||
+13
@@ -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>) {
|
||||
|
||||
+3
-1
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user